feat: wire meal planner slot editor and meal type picker

Slot click now opens an inline editor panel:
- Pick from saved recipes via dropdown (pre-loaded on mount)
- Or type a custom label
- Clear slot button when a slot is already filled
- Save/Cancel with loading state

Add meal type opens a chip picker showing the types not yet active
(breakfast / lunch / snack minus whatever is already on the plan).
Selecting one calls the new PATCH /meal-plans/{plan_id} endpoint.

Backend:
- PATCH /meal-plans/{plan_id} with UpdatePlanRequest(meal_types)
- store.update_meal_plan_types() UPDATE ... RETURNING *
- 409 on IntegrityError in create_plan (already in place)
This commit is contained in:
pyr0ball 2026-04-16 14:23:38 -07:00
parent de0008f5c7
commit e745ce4375
6 changed files with 202 additions and 9 deletions

View file

@ -19,6 +19,7 @@ from app.models.schemas.meal_plan import (
PrepTaskSummary,
ShoppingListResponse,
SlotSummary,
UpdatePlanRequest,
UpdatePrepTaskRequest,
UpsertSlotRequest,
VALID_MEAL_TYPES,
@ -113,6 +114,28 @@ async def list_plans(
return result
@router.patch("/{plan_id}", response_model=PlanSummary)
async def update_plan(
plan_id: int,
req: UpdatePlanRequest,
session: CloudUser = Depends(get_session),
store: Store = Depends(get_store),
) -> PlanSummary:
plan = await asyncio.to_thread(store.get_meal_plan, plan_id)
if plan is None:
raise HTTPException(status_code=404, detail="Plan not found.")
# Free tier stays dinner-only; paid+ may add meal types
if can_use("meal_plan_config", session.tier):
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
else:
meal_types = ["dinner"]
updated = await asyncio.to_thread(store.update_meal_plan_types, plan_id, meal_types)
if updated is None:
raise HTTPException(status_code=404, detail="Plan not found.")
slots = await asyncio.to_thread(store.get_plan_slots, plan_id)
return _plan_summary(updated, slots)
@router.get("/{plan_id}", response_model=PlanSummary)
async def get_plan(
plan_id: int,

View file

@ -1119,6 +1119,12 @@ class Store:
def get_meal_plan(self, plan_id: int) -> dict | None:
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
def update_meal_plan_types(self, plan_id: int, meal_types: list[str]) -> dict | None:
return self._fetch_one(
"UPDATE meal_plans SET meal_types = ? WHERE id = ? RETURNING *",
(json.dumps(meal_types), plan_id),
)
def list_meal_plans(self) -> list[dict]:
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")

View file

@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
return v
class UpdatePlanRequest(BaseModel):
meal_types: list[str]
class UpsertSlotRequest(BaseModel):
recipe_id: int | None = None
servings: float = Field(2.0, gt=0)

View file

@ -29,6 +29,70 @@
@add-meal-type="onAddMealType"
/>
<!-- Slot editor panel -->
<div v-if="slotEditing" class="slot-editor card">
<div class="slot-editor-header">
<span class="slot-editor-title">
{{ DAY_LABELS[slotEditing.dayOfWeek] }} · {{ slotEditing.mealType }}
</span>
<button class="close-btn" @click="slotEditing = null" aria-label="Close"></button>
</div>
<!-- Custom label -->
<div class="form-group">
<label class="form-label">Custom label</label>
<input
v-model="slotCustomLabel"
class="form-input"
type="text"
placeholder="e.g. Taco night, Leftovers…"
maxlength="80"
/>
</div>
<!-- Pick from saved recipes -->
<div v-if="savedStore.saved.length" class="form-group">
<label class="form-label">Or pick a saved recipe</label>
<select class="week-select" v-model="slotRecipeId">
<option :value="null"> None </option>
<option v-for="r in savedStore.saved" :key="r.recipe_id" :value="r.recipe_id">
{{ r.title }}
</option>
</select>
</div>
<p v-else class="slot-hint">Save recipes from the Recipes tab to pick them here.</p>
<div class="slot-editor-actions">
<button class="btn-secondary" @click="slotEditing = null">Cancel</button>
<button
v-if="currentSlot"
class="btn-danger-subtle"
@click="onClearSlot"
:disabled="slotSaving"
>Clear slot</button>
<button
class="btn-primary"
@click="onSaveSlot"
:disabled="slotSaving"
>{{ slotSaving ? 'Saving…' : 'Save' }}</button>
</div>
</div>
<!-- Meal type picker -->
<div v-if="addingMealType" class="meal-type-picker card">
<span class="slot-editor-title">Add meal type</span>
<div class="chip-row">
<button
v-for="t in availableMealTypes"
:key="t"
class="btn-chip"
:disabled="mealTypeAdding"
@click="onPickMealType(t)"
>{{ t }}</button>
</div>
<button class="close-link" @click="addingMealType = false">Cancel</button>
</div>
<!-- Panel tabs: Shopping List | Prep Schedule -->
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
<button
@ -78,31 +142,56 @@
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useMealPlanStore } from '../stores/mealPlan'
import { useSavedRecipesStore } from '../stores/savedRecipes'
import MealPlanGrid from './MealPlanGrid.vue'
import ShoppingListPanel from './ShoppingListPanel.vue'
import PrepSessionView from './PrepSessionView.vue'
import type { MealPlanSlot } from '../services/api'
const TABS = [
{ id: 'shopping', label: 'Shopping List' },
{ id: 'prep', label: 'Prep Schedule' },
] as const
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const ALL_MEAL_TYPES = ['breakfast', 'lunch', 'dinner', 'snack']
type TabId = typeof TABS[number]['id']
const store = useMealPlanStore()
const savedStore = useSavedRecipesStore()
const { plans, activePlan, loading } = storeToRefs(store)
const activeTab = ref<TabId>('shopping')
const planError = ref<string | null>(null)
const planCreating = ref(false)
// slot editor
const slotEditing = ref<{ dayOfWeek: number; mealType: string } | null>(null)
const slotCustomLabel = ref('')
const slotRecipeId = ref<number | null>(null)
const slotSaving = ref(false)
const currentSlot = computed((): MealPlanSlot | undefined => {
if (!slotEditing.value) return undefined
return store.getSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
})
// meal type picker
const addingMealType = ref(false)
const mealTypeAdding = ref(false)
const availableMealTypes = computed(() =>
ALL_MEAL_TYPES.filter(t => !activePlan.value?.meal_types.includes(t))
)
// canAddMealType is a UI hint backend enforces the paid gate authoritatively
const canAddMealType = computed(() =>
(activePlan.value?.meal_types.length ?? 0) < 4
)
onMounted(async () => {
await store.loadPlans()
await Promise.all([store.loadPlans(), savedStore.load()])
store.autoSelectPlan(mondayOfCurrentWeek())
})
@ -125,11 +214,8 @@ async function onNewPlan() {
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
// Plan for this week exists just activate it instead of erroring
const existing = plans.value.find(p => p.week_start === weekStart)
if (existing) {
await store.setActivePlan(existing.id)
}
if (existing) await store.setActivePlan(existing.id)
} else {
planError.value = `Couldn't create plan: ${msg}`
}
@ -142,12 +228,50 @@ 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 onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
slotEditing.value = payload
const existing = store.getSlot(payload.dayOfWeek, payload.mealType)
slotCustomLabel.value = existing?.custom_label ?? ''
slotRecipeId.value = existing?.recipe_id ?? null
}
async function onSaveSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.upsertSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType, {
recipe_id: slotRecipeId.value,
custom_label: slotCustomLabel.value.trim() || null,
})
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
async function onClearSlot() {
if (!slotEditing.value) return
slotSaving.value = true
try {
await store.clearSlot(slotEditing.value.dayOfWeek, slotEditing.value.mealType)
slotEditing.value = null
} finally {
slotSaving.value = false
}
}
function onAddMealType() {
// Add meal type picker Paid gate enforced by backend
addingMealType.value = true
}
async function onPickMealType(mealType: string) {
mealTypeAdding.value = true
try {
await store.addMealType(mealType)
addingMealType.value = false
} finally {
mealTypeAdding.value = false
}
}
</script>
@ -167,6 +291,29 @@ function onAddMealType() {
}
.new-plan-btn:hover { background: var(--color-accent); color: white; }
/* Slot editor */
.slot-editor, .meal-type-picker {
padding: 1rem; border-radius: 8px;
border: 1px solid var(--color-border); background: var(--color-surface);
display: flex; flex-direction: column; gap: 0.75rem;
}
.slot-editor-header { display: flex; align-items: center; justify-content: space-between; }
.slot-editor-title { font-size: 0.85rem; font-weight: 600; }
.close-btn {
background: none; border: none; cursor: pointer; font-size: 0.9rem;
color: var(--color-text-secondary); padding: 0.1rem 0.3rem; border-radius: 4px;
}
.close-btn:hover { background: var(--color-surface-2); }
.slot-hint { font-size: 0.8rem; opacity: 0.55; margin: 0; }
.slot-editor-actions { display: flex; gap: 0.5rem; justify-content: flex-end; flex-wrap: wrap; }
.chip-row { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.close-link {
background: none; border: none; cursor: pointer; font-size: 0.8rem;
color: var(--color-text-secondary); align-self: flex-start; padding: 0;
}
.close-link:hover { text-decoration: underline; }
.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;

View file

@ -827,6 +827,11 @@ export const mealPlanAPI = {
return resp.data
},
async updateMealTypes(planId: number, mealTypes: string[]): Promise<MealPlan> {
const resp = await api.patch<MealPlan>(`/meal-plans/${planId}`, { meal_types: mealTypes })
return resp.data
},
async upsertSlot(planId: number, dayOfWeek: number, mealType: string, data: { recipe_id?: number | null; servings?: number; custom_label?: string | null }): Promise<MealPlanSlot> {
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
return resp.data

View file

@ -124,6 +124,14 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
}
}
async function addMealType(mealType: string): Promise<void> {
if (!activePlan.value) return
const current = activePlan.value.meal_types
if (current.includes(mealType)) return
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, [...current, mealType])
activePlan.value = updated
}
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
if (!activePlan.value || !prepSession.value) return
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
@ -144,6 +152,6 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
plans, activePlan, shoppingList, prepSession,
loading, shoppingListLoading, prepLoading, slots,
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
}
})