diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py index e1d6cbc..ad647e4 100644 --- a/app/api/endpoints/meal_plans.py +++ b/app/api/endpoints/meal_plans.py @@ -81,13 +81,21 @@ async def create_plan( session: CloudUser = Depends(get_session), store: Store = Depends(get_store), ) -> PlanSummary: + import sqlite3 + # Free tier is locked to dinner-only; paid+ may configure 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"] - 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"]) return _plan_summary(plan, slots) diff --git a/app/db/migrations/032_meal_plan_unique_week.sql b/app/db/migrations/032_meal_plan_unique_week.sql new file mode 100644 index 0000000..f5399ca --- /dev/null +++ b/app/db/migrations/032_meal_plan_unique_week.sql @@ -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); diff --git a/app/db/store.py b/app/db/store.py index 49f94ba..7bc7cca 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -1149,7 +1149,7 @@ class Store: def get_plan_slots(self, plan_id: int) -> list[dict]: return self._fetch_all( - """SELECT s.*, r.name AS recipe_title + """SELECT s.*, r.title AS recipe_title FROM meal_plan_slots s LEFT JOIN recipes r ON r.id = s.recipe_id WHERE s.plan_id = ? diff --git a/frontend/src/components/MealPlanView.vue b/frontend/src/components/MealPlanView.vue index 70fbe46..d69de44 100644 --- a/frontend/src/components/MealPlanView.vue +++ b/frontend/src/components/MealPlanView.vue @@ -14,8 +14,11 @@ Week of {{ p.week_start }} - + +

{{ planError }}

@@ -88,6 +93,8 @@ const store = useMealPlanStore() const { plans, activePlan, loading } = storeToRefs(store) const activeTab = ref('shopping') +const planError = ref(null) +const planCreating = ref(false) // canAddMealType is a UI hint — backend enforces the paid gate authoritatively const canAddMealType = computed(() => @@ -96,14 +103,31 @@ const canAddMealType = computed(() => onMounted(() => store.loadPlans()) -async function onNewPlan() { +function mondayOfCurrentWeek(): string { const today = new Date() - const day = today.getDay() - // Compute Monday of current week (getDay: 0=Sun, 1=Mon...) - const monday = new Date(today) - monday.setDate(today.getDate() - ((day + 6) % 7)) - const weekStart = monday.toISOString().split('T')[0] ?? monday.toISOString().slice(0, 10) - await store.createPlan(weekStart, ['dinner']) + const day = today.getDay() // 0=Sun, 1=Mon... + // Build date string from local parts to avoid UTC-offset day drift + const d = new Date(today) + d.setDate(today.getDate() - ((day + 6) % 7)) + const pad = (n: number) => String(n).padStart(2, '0') + 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) { @@ -150,4 +174,11 @@ function onAddMealType() { .tab-panel { padding-top: 0.75rem; } .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; +}