feat: remove and reorder meal types in weekly planner
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

- MealPlanGrid: edit-mode toggle (visible when >1 meal type) reveals
  per-row ↑/↓ reorder and ✕ remove controls; label column expands to
  auto width via CSS class swap
- mealPlan store: removeMealType() and reorderMealTypes() — both send
  the full updated array to the existing PATCH /meal-plans/{id} endpoint
- MealPlanView: wires remove-meal-type and reorder-meal-types emits;
  shares mealTypeAdding loading flag to disable controls during save
- Guard: remove disabled when only one type remains (always keep ≥1)
This commit is contained in:
pyr0ball 2026-04-16 15:13:59 -07:00
parent 6aa63cf2f0
commit 23a2e8fe38
3 changed files with 126 additions and 10 deletions

View file

@ -14,7 +14,7 @@
<div v-show="!collapsed" class="grid-body"> <div v-show="!collapsed" class="grid-body">
<!-- Day headers --> <!-- Day headers -->
<div class="day-headers"> <div class="day-headers" :class="{ 'headers-editing': editing }">
<div class="meal-type-col-spacer" /> <div class="meal-type-col-spacer" />
<div <div
v-for="(day, i) in DAY_LABELS" v-for="(day, i) in DAY_LABELS"
@ -26,11 +26,35 @@
<!-- One row per meal type --> <!-- One row per meal type -->
<div <div
v-for="mealType in activeMealTypes" v-for="(mealType, idx) in activeMealTypes"
:key="mealType" :key="mealType"
class="meal-row" class="meal-row"
:class="{ 'row-editing': editing }"
> >
<div class="meal-type-label">{{ mealType }}</div> <div class="meal-type-label" :class="{ 'label-editing': editing }">
<template v-if="editing">
<button
class="reorder-btn"
:disabled="idx === 0 || mealTypeChanging"
aria-label="Move up"
@click="onMoveUp(idx)"
></button>
<button
class="reorder-btn"
:disabled="idx === activeMealTypes.length - 1 || mealTypeChanging"
aria-label="Move down"
@click="onMoveDown(idx)"
></button>
<span class="label-text">{{ mealType }}</span>
<button
class="remove-btn"
:disabled="activeMealTypes.length <= 1 || mealTypeChanging"
:aria-label="`Remove ${mealType}`"
@click="$emit('remove-meal-type', mealType)"
></button>
</template>
<template v-else>{{ mealType }}</template>
</div>
<button <button
v-for="dayIndex in 7" v-for="dayIndex in 7"
:key="dayIndex - 1" :key="dayIndex - 1"
@ -46,11 +70,17 @@
</button> </button>
</div> </div>
<!-- Add meal type row (Paid only) --> <!-- Add / edit meal type controls (Paid only) -->
<div v-if="canAddMealType" class="add-meal-type-row"> <div v-if="canAddMealType || activeMealTypes.length > 1" class="add-meal-type-row">
<button class="add-meal-type-btn" @click="$emit('add-meal-type')"> <button v-if="canAddMealType && !editing" class="add-meal-type-btn" @click="$emit('add-meal-type')">
+ Add meal type + Add meal type
</button> </button>
<button
v-if="activeMealTypes.length > 1"
class="edit-types-btn"
:class="{ active: editing }"
@click="editing = !editing"
>{{ editing ? 'Done' : 'Edit types' }}</button>
</div> </div>
</div> </div>
</div> </div>
@ -60,22 +90,44 @@
import { ref } from 'vue' import { ref } from 'vue'
import { useMealPlanStore } from '../stores/mealPlan' import { useMealPlanStore } from '../stores/mealPlan'
defineProps<{ const props = defineProps<{
activeMealTypes: string[] activeMealTypes: string[]
canAddMealType: boolean canAddMealType: boolean
mealTypeChanging?: boolean
}>() }>()
defineEmits<{ const emit = defineEmits<{
(e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void (e: 'slot-click', payload: { dayOfWeek: number; mealType: string }): void
(e: 'add-meal-type'): void (e: 'add-meal-type'): void
(e: 'remove-meal-type', mealType: string): void
(e: 'reorder-meal-types', newOrder: string[]): void
}>() }>()
const store = useMealPlanStore() const store = useMealPlanStore()
const { getSlot } = store const { getSlot } = store
const collapsed = ref(false) const collapsed = ref(false)
const editing = ref(false)
const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
function onMoveUp(idx: number) {
if (idx === 0) return
const arr = [...props.activeMealTypes]
const tmp = arr[idx - 1]!
arr[idx - 1] = arr[idx]!
arr[idx] = tmp
emit('reorder-meal-types', arr)
}
function onMoveDown(idx: number) {
if (idx === props.activeMealTypes.length - 1) return
const arr = [...props.activeMealTypes]
const tmp = arr[idx]!
arr[idx] = arr[idx + 1]!
arr[idx + 1] = tmp
emit('reorder-meal-types', arr)
}
</script> </script>
<style scoped> <style scoped>
@ -117,10 +169,38 @@ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
.slot-title { text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; color: var(--color-text); } .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; } .slot-empty { opacity: 0.25; font-size: 1rem; }
.add-meal-type-row { padding: 0.4rem 0 0.2rem; } .add-meal-type-row { padding: 0.4rem 0 0.2rem; display: flex; gap: 0.75rem; align-items: center; }
.add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; } .add-meal-type-btn { font-size: 0.75rem; background: none; border: none; cursor: pointer; color: var(--color-accent); padding: 0; }
.edit-types-btn {
font-size: 0.75rem; background: none; border: none; cursor: pointer;
color: var(--color-text-secondary); padding: 0;
}
.edit-types-btn:hover { color: var(--color-text); }
.edit-types-btn.active { color: var(--color-accent); font-weight: 600; }
/* Edit mode — expand label column to fit controls */
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
.label-editing {
flex-direction: row; align-items: center; gap: 2px;
opacity: 1; white-space: nowrap;
}
.label-text { flex: 1; font-size: 0.65rem; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.7; font-weight: 600; padding: 0 2px; }
.reorder-btn {
background: none; border: 1px solid var(--color-border); border-radius: 3px;
cursor: pointer; font-size: 0.6rem; padding: 1px 3px; line-height: 1;
color: var(--color-text-secondary); min-width: 16px;
}
.reorder-btn:hover:not(:disabled) { border-color: var(--color-accent); color: var(--color-accent); }
.reorder-btn:disabled { opacity: 0.25; cursor: default; }
.remove-btn {
background: none; border: none; cursor: pointer; font-size: 0.65rem;
color: var(--color-text-secondary); padding: 1px 3px; border-radius: 3px; line-height: 1;
}
.remove-btn:hover:not(:disabled) { color: var(--color-error, #e05252); background: var(--color-error-subtle, #fef2f2); }
.remove-btn:disabled { opacity: 0.25; cursor: default; }
@media (max-width: 600px) { @media (max-width: 600px) {
.day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); } .day-headers, .meal-row { grid-template-columns: 2.5rem repeat(7, 1fr); }
.row-editing, .headers-editing { grid-template-columns: auto repeat(7, 1fr); }
} }
</style> </style>

View file

@ -25,8 +25,11 @@
<MealPlanGrid <MealPlanGrid
:active-meal-types="activePlan.meal_types" :active-meal-types="activePlan.meal_types"
:can-add-meal-type="canAddMealType" :can-add-meal-type="canAddMealType"
:meal-type-changing="mealTypeAdding"
@slot-click="onSlotClick" @slot-click="onSlotClick"
@add-meal-type="onAddMealType" @add-meal-type="onAddMealType"
@remove-meal-type="onRemoveMealType"
@reorder-meal-types="onReorderMealTypes"
/> />
<!-- Slot editor panel --> <!-- Slot editor panel -->
@ -273,6 +276,24 @@ async function onPickMealType(mealType: string) {
mealTypeAdding.value = false mealTypeAdding.value = false
} }
} }
async function onRemoveMealType(mealType: string) {
mealTypeAdding.value = true
try {
await store.removeMealType(mealType)
} finally {
mealTypeAdding.value = false
}
}
async function onReorderMealTypes(newOrder: string[]) {
mealTypeAdding.value = true
try {
await store.reorderMealTypes(newOrder)
} finally {
mealTypeAdding.value = false
}
}
</script> </script>
<style scoped> <style scoped>

View file

@ -132,6 +132,20 @@ export const useMealPlanStore = defineStore('mealPlan', () => {
activePlan.value = updated activePlan.value = updated
} }
async function removeMealType(mealType: string): Promise<void> {
if (!activePlan.value) return
const next = activePlan.value.meal_types.filter(t => t !== mealType)
if (next.length === 0) return // always keep at least one
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, next)
activePlan.value = updated
}
async function reorderMealTypes(newOrder: string[]): Promise<void> {
if (!activePlan.value) return
const updated = await mealPlanAPI.updateMealTypes(activePlan.value.id, newOrder)
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)
@ -152,6 +166,7 @@ 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,
addMealType, upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask, addMealType, removeMealType, reorderMealTypes,
upsertSlot, clearSlot, loadShoppingList, loadPrepSession, updatePrepTask,
} }
}) })