diff --git a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue index bcdd9a3e2..cff34b6d5 100644 --- a/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue +++ b/frontend/components/Domain/Household/GroupMealPlanRuleForm.vue @@ -73,6 +73,7 @@ export default defineNuxtComponent({ { title: i18n.t("meal-plan.lunch"), value: "lunch" }, { title: i18n.t("meal-plan.dinner"), value: "dinner" }, { title: i18n.t("meal-plan.side"), value: "side" }, + { title: i18n.t("meal-plan.snack"), value: "snack" }, { title: i18n.t("meal-plan.type-any"), value: "unset" }, ]; diff --git a/frontend/composables/use-group-mealplan.ts b/frontend/composables/use-group-mealplan.ts index f8dcb402f..37b8cc158 100644 --- a/frontend/composables/use-group-mealplan.ts +++ b/frontend/composables/use-group-mealplan.ts @@ -15,6 +15,7 @@ export function usePlanTypeOptions() { { text: i18n.t("meal-plan.lunch"), value: "lunch" }, { text: i18n.t("meal-plan.dinner"), value: "dinner" }, { text: i18n.t("meal-plan.side"), value: "side" }, + { text: i18n.t("meal-plan.snack"), value: "snack" }, ] as PlanOption[]; } diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 59437e6d4..d0111472c 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -334,6 +334,7 @@ "quick-week": "Quick Week", "side": "Side", "sides": "Sides", + "snack": "Snack", "start-date": "Start Date", "rule-day": "Rule Day", "meal-type": "Meal Type", diff --git a/frontend/lib/api/types/meal-plan.ts b/frontend/lib/api/types/meal-plan.ts index aa28e6349..f83094518 100644 --- a/frontend/lib/api/types/meal-plan.ts +++ b/frontend/lib/api/types/meal-plan.ts @@ -7,7 +7,7 @@ import type { HouseholdSummary } from "./household"; -export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side"; +export type PlanEntryType = "breakfast" | "lunch" | "dinner" | "side" | "snack"; export type PlanRulesDay = "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "saturday" | "sunday" | "unset"; export type PlanRulesType = "breakfast" | "lunch" | "dinner" | "side" | "unset"; export type LogicalOperator = "AND" | "OR"; diff --git a/frontend/pages/household/mealplan/planner/view.vue b/frontend/pages/household/mealplan/planner/view.vue index 8c7c84210..cb2d67c82 100644 --- a/frontend/pages/household/mealplan/planner/view.vue +++ b/frontend/pages/household/mealplan/planner/view.vue @@ -71,6 +71,7 @@ const plan = computed(() => { { title: i18n.t("meal-plan.lunch"), meals: [] }, { title: i18n.t("meal-plan.dinner"), meals: [] }, { title: i18n.t("meal-plan.side"), meals: [] }, + { title: i18n.t("meal-plan.snack"), meals: [] }, ], recipes: [], }; @@ -88,6 +89,9 @@ const plan = computed(() => { else if (meal.entryType === "side") { out.sections[3].meals.push(meal); } + else if (meal.entryType === "snack") { + out.sections[4].meals.push(meal); + } if (meal.recipe) { out.recipes.push(meal.recipe); diff --git a/mealie/schema/meal_plan/new_meal.py b/mealie/schema/meal_plan/new_meal.py index c766bf49e..cab8a20e1 100644 --- a/mealie/schema/meal_plan/new_meal.py +++ b/mealie/schema/meal_plan/new_meal.py @@ -21,6 +21,7 @@ class PlanEntryType(str, Enum): lunch = "lunch" dinner = "dinner" side = "side" + snack = "snack" class CreateRandomEntry(MealieModel): diff --git a/mealie/schema/meal_plan/plan_rules.py b/mealie/schema/meal_plan/plan_rules.py index 284eb0aca..59b7e143d 100644 --- a/mealie/schema/meal_plan/plan_rules.py +++ b/mealie/schema/meal_plan/plan_rules.py @@ -38,6 +38,7 @@ class PlanRulesType(str, Enum): lunch = "lunch" dinner = "dinner" side = "side" + snack = "snack" unset = "unset" diff --git a/tests/integration_tests/user_household_tests/test_group_mealplan.py b/tests/integration_tests/user_household_tests/test_group_mealplan.py index f78b0eb86..0a793e572 100644 --- a/tests/integration_tests/user_household_tests/test_group_mealplan.py +++ b/tests/integration_tests/user_household_tests/test_group_mealplan.py @@ -338,3 +338,102 @@ def test_get_mealplan_with_rules_households_filter_includes_any_households( assert response.json()["recipe"]["slug"] == recipe.slug finally: unique_user.repos.group_meal_plan_rules.delete(rule.id) + + +def test_create_mealplan_snack_entry_type(api_client: TestClient, unique_user: TestUser): + """Test creating a meal plan with snack entry type""" + title = random_string(length=25) + text = random_string(length=25) + new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type="snack", title=title, text=text).model_dump() + new_plan["date"] = datetime.now(UTC).date().strftime("%Y-%m-%d") + + response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=unique_user.token) + + assert response.status_code == 201 + response_json = response.json() + assert response_json["entryType"] == "snack" + assert response_json["title"] == title + assert response_json["text"] == text + + +def test_create_mealplan_snack_with_recipe(api_client: TestClient, unique_user: TestUser): + """Test creating a meal plan with snack entry type and recipe""" + recipe_name = random_string(length=25) + response = api_client.post(api_routes.recipes, json={"name": recipe_name}, headers=unique_user.token) + assert response.status_code == 201 + + response = api_client.get(api_routes.recipes_slug(recipe_name), headers=unique_user.token) + recipe = response.json() + recipe_id = recipe["id"] + + new_plan = CreatePlanEntry(date=datetime.now(UTC).date(), entry_type="snack", recipe_id=recipe_id).model_dump( + by_alias=True + ) + new_plan["date"] = datetime.now(UTC).date().strftime("%Y-%m-%d") + new_plan["recipeId"] = str(recipe_id) + + response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=unique_user.token) + response_json = response.json() + assert response.status_code == 201 + assert response_json["entryType"] == "snack" + assert response_json["recipe"]["slug"] == recipe_name + + +def test_get_mealplan_with_snack_rules(api_client: TestClient, unique_user: TestUser): + """Test meal plan rules filtering with snack entry type""" + tag = unique_user.repos.tags.create(TagSave(name=random_string(), group_id=unique_user.group_id)) + recipe = create_recipe(unique_user, tags=[tag]) + [create_recipe(unique_user) for _ in range(5)] + + rule = create_rule( + unique_user, + day=PlanRulesDay.saturday, + entry_type=PlanRulesType.snack, + tags=[tag], + ) + + try: + payload = {"date": "2023-02-25", "entryType": "snack"} + response = api_client.post(api_routes.households_mealplans_random, json=payload, headers=unique_user.token) + assert response.status_code == 200 + recipe_data = response.json()["recipe"] + assert recipe_data["tags"][0]["name"] == tag.name + assert recipe_data["slug"] == recipe.slug + finally: + unique_user.repos.group_meal_plan_rules.delete(rule.id) + + +def test_mixed_entry_types_including_snack(api_client: TestClient, unique_user: TestUser): + """Test creating meal plans with various entry types including snack""" + entry_types = ["breakfast", "lunch", "dinner", "side", "snack"] + created_plans = [] + + for entry_type in entry_types: + new_plan = CreatePlanEntry( + date=datetime.now(UTC).date(), + entry_type=entry_type, + title=f"{entry_type} plan", + text=f"This is a {entry_type} plan", + ).model_dump() + new_plan["date"] = datetime.now(UTC).date().strftime("%Y-%m-%d") + + response = api_client.post(api_routes.households_mealplans, json=new_plan, headers=unique_user.token) + assert response.status_code == 201 + + response_json = response.json() + assert response_json["entryType"] == entry_type + created_plans.append(response_json) + + # Verify all plans were created with correct entry types + response = api_client.get( + api_routes.households_mealplans, headers=unique_user.token, params={"page": 1, "perPage": -1} + ) + assert response.status_code == 200 + + all_plans = response.json()["items"] + created_entry_types = [ + plan["entryType"] for plan in all_plans if plan["date"] == datetime.now(UTC).date().strftime("%Y-%m-%d") + ] + + for entry_type in entry_types: + assert entry_type in created_entry_types