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:
parent
de0008f5c7
commit
e745ce4375
6 changed files with 202 additions and 9 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue