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,
|
PrepTaskSummary,
|
||||||
ShoppingListResponse,
|
ShoppingListResponse,
|
||||||
SlotSummary,
|
SlotSummary,
|
||||||
|
UpdatePlanRequest,
|
||||||
UpdatePrepTaskRequest,
|
UpdatePrepTaskRequest,
|
||||||
UpsertSlotRequest,
|
UpsertSlotRequest,
|
||||||
VALID_MEAL_TYPES,
|
VALID_MEAL_TYPES,
|
||||||
|
|
@ -113,6 +114,28 @@ async def list_plans(
|
||||||
return result
|
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)
|
@router.get("/{plan_id}", response_model=PlanSummary)
|
||||||
async def get_plan(
|
async def get_plan(
|
||||||
plan_id: int,
|
plan_id: int,
|
||||||
|
|
|
||||||
|
|
@ -1119,6 +1119,12 @@ class Store:
|
||||||
def get_meal_plan(self, plan_id: int) -> dict | None:
|
def get_meal_plan(self, plan_id: int) -> dict | None:
|
||||||
return self._fetch_one("SELECT * FROM meal_plans WHERE id = ?", (plan_id,))
|
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]:
|
def list_meal_plans(self) -> list[dict]:
|
||||||
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
return self._fetch_all("SELECT * FROM meal_plans ORDER BY week_start DESC")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,10 @@ class CreatePlanRequest(BaseModel):
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePlanRequest(BaseModel):
|
||||||
|
meal_types: list[str]
|
||||||
|
|
||||||
|
|
||||||
class UpsertSlotRequest(BaseModel):
|
class UpsertSlotRequest(BaseModel):
|
||||||
recipe_id: int | None = None
|
recipe_id: int | None = None
|
||||||
servings: float = Field(2.0, gt=0)
|
servings: float = Field(2.0, gt=0)
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,70 @@
|
||||||
@add-meal-type="onAddMealType"
|
@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 -->
|
<!-- Panel tabs: Shopping List | Prep Schedule -->
|
||||||
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
<div class="panel-tabs" role="tablist" aria-label="Plan outputs">
|
||||||
<button
|
<button
|
||||||
|
|
@ -78,31 +142,56 @@
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { storeToRefs } from 'pinia'
|
import { storeToRefs } from 'pinia'
|
||||||
import { useMealPlanStore } from '../stores/mealPlan'
|
import { useMealPlanStore } from '../stores/mealPlan'
|
||||||
|
import { useSavedRecipesStore } from '../stores/savedRecipes'
|
||||||
import MealPlanGrid from './MealPlanGrid.vue'
|
import MealPlanGrid from './MealPlanGrid.vue'
|
||||||
import ShoppingListPanel from './ShoppingListPanel.vue'
|
import ShoppingListPanel from './ShoppingListPanel.vue'
|
||||||
import PrepSessionView from './PrepSessionView.vue'
|
import PrepSessionView from './PrepSessionView.vue'
|
||||||
|
import type { MealPlanSlot } from '../services/api'
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ id: 'shopping', label: 'Shopping List' },
|
{ id: 'shopping', label: 'Shopping List' },
|
||||||
{ id: 'prep', label: 'Prep Schedule' },
|
{ id: 'prep', label: 'Prep Schedule' },
|
||||||
] as const
|
] 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']
|
type TabId = typeof TABS[number]['id']
|
||||||
|
|
||||||
const store = useMealPlanStore()
|
const store = useMealPlanStore()
|
||||||
|
const savedStore = useSavedRecipesStore()
|
||||||
const { plans, activePlan, loading } = storeToRefs(store)
|
const { plans, activePlan, loading } = storeToRefs(store)
|
||||||
|
|
||||||
const activeTab = ref<TabId>('shopping')
|
const activeTab = ref<TabId>('shopping')
|
||||||
const planError = ref<string | null>(null)
|
const planError = ref<string | null>(null)
|
||||||
const planCreating = ref(false)
|
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
|
// canAddMealType is a UI hint — backend enforces the paid gate authoritatively
|
||||||
const canAddMealType = computed(() =>
|
const canAddMealType = computed(() =>
|
||||||
(activePlan.value?.meal_types.length ?? 0) < 4
|
(activePlan.value?.meal_types.length ?? 0) < 4
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.loadPlans()
|
await Promise.all([store.loadPlans(), savedStore.load()])
|
||||||
store.autoSelectPlan(mondayOfCurrentWeek())
|
store.autoSelectPlan(mondayOfCurrentWeek())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -125,11 +214,8 @@ async function onNewPlan() {
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
if (msg.includes('409') || msg.toLowerCase().includes('already exists')) {
|
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)
|
const existing = plans.value.find(p => p.week_start === weekStart)
|
||||||
if (existing) {
|
if (existing) await store.setActivePlan(existing.id)
|
||||||
await store.setActivePlan(existing.id)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
planError.value = `Couldn't create plan: ${msg}`
|
planError.value = `Couldn't create plan: ${msg}`
|
||||||
}
|
}
|
||||||
|
|
@ -142,12 +228,50 @@ async function onSelectPlan(planId: number) {
|
||||||
if (planId) await store.setActivePlan(planId)
|
if (planId) await store.setActivePlan(planId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSlotClick(_: { dayOfWeek: number; mealType: string }) {
|
function onSlotClick(payload: { dayOfWeek: number; mealType: string }) {
|
||||||
// Recipe picker integration filed as follow-up
|
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() {
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
@ -167,6 +291,29 @@ function onAddMealType() {
|
||||||
}
|
}
|
||||||
.new-plan-btn:hover { background: var(--color-accent); color: white; }
|
.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-tabs { display: flex; gap: 6px; border-bottom: 1px solid var(--color-border); padding-bottom: 0; }
|
||||||
.panel-tab {
|
.panel-tab {
|
||||||
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
font-size: 0.82rem; padding: 0.4rem 1rem; border-radius: 6px 6px 0 0;
|
||||||
|
|
|
||||||
|
|
@ -827,6 +827,11 @@ export const mealPlanAPI = {
|
||||||
return resp.data
|
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> {
|
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)
|
const resp = await api.put<MealPlanSlot>(`/meal-plans/${planId}/slots/${dayOfWeek}/${mealType}`, data)
|
||||||
return resp.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> {
|
async function updatePrepTask(taskId: number, data: Partial<Pick<PrepTask, 'duration_minutes' | 'sequence_order' | 'notes' | 'equipment'>>): Promise<void> {
|
||||||
if (!activePlan.value || !prepSession.value) return
|
if (!activePlan.value || !prepSession.value) return
|
||||||
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
const updated = await mealPlanAPI.updatePrepTask(activePlan.value.id, taskId, data)
|
||||||
|
|
@ -144,6 +152,6 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
|
||||||
plans, activePlan, shoppingList, prepSession,
|
plans, activePlan, shoppingList, prepSession,
|
||||||
loading, shoppingListLoading, prepLoading, slots,
|
loading, shoppingListLoading, prepLoading, slots,
|
||||||
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
|
getSlot, loadPlans, autoSelectPlan, createPlan, setActivePlan,
|
||||||
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue