fix: meal planner week add button crashing on r.name / add duplicate guard
- Fix sqlite3.OperationalError: the recipes table uses `title` not `name`; get_plan_slots JOIN was crashing every list_plans call with a 500, making the + New week button appear broken (plans were being created silently but the selector refresh always failed) - Add migration 032 to add UNIQUE INDEX on meal_plans(week_start) to prevent duplicate plans accumulating while the button was broken - Raise HTTP 409 on IntegrityError in create_plan so duplicates produce a clear error instead of a 500 - Fix mondayOfCurrentWeek to build the date string from local date parts instead of toISOString(), which converts through UTC and can produce the wrong calendar day for UTC+ timezones - Add planCreating/planError state to MealPlanView so button shows "Creating..." during the request and displays errors inline
This commit is contained in:
parent
9a277f9b42
commit
dbaf2b6ac8
4 changed files with 54 additions and 11 deletions
|
|
@ -81,13 +81,21 @@ async def create_plan(
|
||||||
session: CloudUser = Depends(get_session),
|
session: CloudUser = Depends(get_session),
|
||||||
store: Store = Depends(get_store),
|
store: Store = Depends(get_store),
|
||||||
) -> PlanSummary:
|
) -> PlanSummary:
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
# Free tier is locked to dinner-only; paid+ may configure meal types
|
# Free tier is locked to dinner-only; paid+ may configure meal types
|
||||||
if can_use("meal_plan_config", session.tier):
|
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"]
|
meal_types = [t for t in req.meal_types if t in VALID_MEAL_TYPES] or ["dinner"]
|
||||||
else:
|
else:
|
||||||
meal_types = ["dinner"]
|
meal_types = ["dinner"]
|
||||||
|
|
||||||
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
try:
|
||||||
|
plan = await asyncio.to_thread(store.create_meal_plan, str(req.week_start), meal_types)
|
||||||
|
except sqlite3.IntegrityError:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"A meal plan for the week of {req.week_start} already exists.",
|
||||||
|
)
|
||||||
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
slots = await asyncio.to_thread(store.get_plan_slots, plan["id"])
|
||||||
return _plan_summary(plan, slots)
|
return _plan_summary(plan, slots)
|
||||||
|
|
||||||
|
|
|
||||||
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
4
app/db/migrations/032_meal_plan_unique_week.sql
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-- 032_meal_plan_unique_week.sql
|
||||||
|
-- Prevent duplicate plans for the same week.
|
||||||
|
-- Existing duplicates must be resolved before applying (keep MIN(id) per week_start).
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_meal_plans_week_start ON meal_plans (week_start);
|
||||||
|
|
@ -1149,7 +1149,7 @@ class Store:
|
||||||
|
|
||||||
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
def get_plan_slots(self, plan_id: int) -> list[dict]:
|
||||||
return self._fetch_all(
|
return self._fetch_all(
|
||||||
"""SELECT s.*, r.name AS recipe_title
|
"""SELECT s.*, r.title AS recipe_title
|
||||||
FROM meal_plan_slots s
|
FROM meal_plan_slots s
|
||||||
LEFT JOIN recipes r ON r.id = s.recipe_id
|
LEFT JOIN recipes r ON r.id = s.recipe_id
|
||||||
WHERE s.plan_id = ?
|
WHERE s.plan_id = ?
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,11 @@
|
||||||
Week of {{ p.week_start }}
|
Week of {{ p.week_start }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="new-plan-btn" @click="onNewPlan">+ New week</button>
|
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
||||||
|
{{ planCreating ? 'Creating…' : '+ New week' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="planError" class="plan-error">{{ planError }}</p>
|
||||||
|
|
||||||
<template v-if="activePlan">
|
<template v-if="activePlan">
|
||||||
<!-- Compact expandable week grid (always visible) -->
|
<!-- Compact expandable week grid (always visible) -->
|
||||||
|
|
@ -64,7 +67,9 @@
|
||||||
|
|
||||||
<div v-else-if="!loading" class="empty-plan-state">
|
<div v-else-if="!loading" class="empty-plan-state">
|
||||||
<p>No meal plan yet for this week.</p>
|
<p>No meal plan yet for this week.</p>
|
||||||
<button class="new-plan-btn" @click="onNewPlan">Start planning</button>
|
<button class="new-plan-btn" @click="onNewPlan" :disabled="planCreating">
|
||||||
|
{{ planCreating ? 'Creating…' : 'Start planning' }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -88,6 +93,8 @@ const store = useMealPlanStore()
|
||||||
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 planCreating = ref(false)
|
||||||
|
|
||||||
// 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(() =>
|
||||||
|
|
@ -96,14 +103,31 @@ const canAddMealType = computed(() =>
|
||||||
|
|
||||||
onMounted(() => store.loadPlans())
|
onMounted(() => store.loadPlans())
|
||||||
|
|
||||||
async function onNewPlan() {
|
function mondayOfCurrentWeek(): string {
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const day = today.getDay()
|
const day = today.getDay() // 0=Sun, 1=Mon...
|
||||||
// Compute Monday of current week (getDay: 0=Sun, 1=Mon...)
|
// Build date string from local parts to avoid UTC-offset day drift
|
||||||
const monday = new Date(today)
|
const d = new Date(today)
|
||||||
monday.setDate(today.getDate() - ((day + 6) % 7))
|
d.setDate(today.getDate() - ((day + 6) % 7))
|
||||||
const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10)
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
await store.createPlan(weekStart, ['dinner'])
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onNewPlan() {
|
||||||
|
planError.value = null
|
||||||
|
planCreating.value = true
|
||||||
|
try {
|
||||||
|
const weekStart = mondayOfCurrentWeek()
|
||||||
|
await store.createPlan(weekStart, ['dinner'])
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err)
|
||||||
|
// 409 means a plan for this week already exists — surface it helpfully
|
||||||
|
planError.value = msg.includes('409') || msg.toLowerCase().includes('already exists')
|
||||||
|
? 'A plan for this week already exists. Select it from the dropdown above.'
|
||||||
|
: `Couldn't create plan: ${msg}`
|
||||||
|
} finally {
|
||||||
|
planCreating.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSelectPlan(planId: number) {
|
async function onSelectPlan(planId: number) {
|
||||||
|
|
@ -150,4 +174,11 @@ function onAddMealType() {
|
||||||
.tab-panel { padding-top: 0.75rem; }
|
.tab-panel { padding-top: 0.75rem; }
|
||||||
|
|
||||||
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
.empty-plan-state { text-align: center; padding: 2rem 0; opacity: 0.6; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.plan-error {
|
||||||
|
font-size: 0.82rem; color: var(--color-error, #e05252);
|
||||||
|
background: var(--color-error-subtle, #fef2f2);
|
||||||
|
border: 1px solid var(--color-error, #e05252); border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.75rem; margin: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue