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 }}
@@ -64,7 +67,9 @@
No meal plan yet for this week.
-
+
@@ -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;
+}