1
0
Fork 0
mirror of https://github.com/mealie-recipes/mealie.git synced 2025-08-02 20:15:24 +02:00

Feature/automated meal planner (#939)

* cleanup oversized buttons

* add get all by category function to reciep repos

* fix shopping-list can_merge logic

* use randomized data for testing

* add random getter to repository for meal-planner

* add stub route for random meals

* cleanup global namespace

* add rules database type

* fix type

* add plan rules schema

* test plan rules methods

* add mealplan rules controller

* add new repository

* update frontend types

* formatting

* fix regression

* update autogenerated types

* add api class for mealplan rules

* add tests and fix bugs

* fix data returns

* proof of concept rules editor

* add tag support

* remove old group categories

* add tag support

* implement random by rules api

* change snack to sides

* remove incorrect typing

* split repo for custom methods

* fix query and use and_ clause

* use repo function

* remove old test

* update changelog
This commit is contained in:
Hayden 2022-02-07 19:03:11 -09:00 committed by GitHub
parent 40d1f586cd
commit d1024e272d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 1153 additions and 175 deletions

View file

@ -0,0 +1,412 @@
<template>
<v-container>
<!-- Create Meal Dialog -->
<BaseDialog
v-model="createMealDialog"
:title="$t('meal-plan.create-a-new-meal-plan')"
color="primary"
:icon="$globals.icons.foods"
@submit="
actions.createOne(newMeal);
resetDialog();
"
>
<v-card-text>
<v-menu
v-model="pickerMenu"
:close-on-content-click="false"
transition="scale-transition"
offset-y
max-width="290px"
min-width="auto"
>
<template #activator="{ on, attrs }">
<v-text-field
v-model="newMeal.date"
label="Date"
hint="MM/DD/YYYY format"
persistent-hint
:prepend-icon="$globals.icons.calendar"
v-bind="attrs"
readonly
v-on="on"
></v-text-field>
</template>
<v-date-picker v-model="newMeal.date" no-title @input="pickerMenu = false"></v-date-picker>
</v-menu>
<v-card-text>
<v-select v-model="newMeal.entryType" :return-object="false" :items="planTypeOptions" label="Entry Type">
</v-select>
<v-autocomplete
v-if="!dialog.note"
v-model="newMeal.recipeId"
label="Meal Recipe"
:items="allRecipes"
item-text="name"
item-value="id"
:return-object="false"
></v-autocomplete>
<template v-else>
<v-text-field v-model="newMeal.title" label="Meal Title"> </v-text-field>
<v-textarea v-model="newMeal.text" rows="2" label="Meal Note"> </v-textarea>
</template>
</v-card-text>
<v-card-actions class="my-0 py-0">
<v-switch v-model="dialog.note" class="mt-n3" label="Note Only"></v-switch>
</v-card-actions>
</v-card-text>
</BaseDialog>
<!-- Date Forward / Back -->
<div class="d-flex justify-center flex-column">
<h3 class="text-h6 mt-2 text-center">{{ $d(weekRange.start, "short") }} - {{ $d(weekRange.end, "short") }}</h3>
<div class="d-flex justify-center my-2 align-center" style="gap: 10px">
<v-btn icon color="info" outlined @click="backOneWeek">
<v-icon>{{ $globals.icons.back }} </v-icon>
</v-btn>
<v-btn icon color="info" outlined @click="forwardOneWeek">
<v-icon>{{ $globals.icons.forward }} </v-icon>
</v-btn>
</div>
</div>
<div class="d-flex align-center justify-space-between">
<v-switch v-model="edit" label="Editor"></v-switch>
<ButtonLink :icon="$globals.icons.calendar" to="/group/mealplan/settings" text="Settings" />
</div>
<v-row class="">
<v-col
v-for="(plan, index) in mealsByDate"
:key="index"
cols="12"
sm="12"
md="4"
lg="3"
xl="2"
class="col-borders my-1 d-flex flex-column"
>
<v-sheet class="mb-2 bottom-color-border">
<p class="headline text-center mb-1">
{{ $d(plan.date, "short") }}
</p>
</v-sheet>
<!-- Day Column Recipes -->
<template v-if="edit">
<draggable
tag="div"
:value="plan.meals"
group="meals"
:data-index="index"
:data-box="plan.date"
style="min-height: 150px"
@end="onMoveCallback"
>
<v-card v-for="mealplan in plan.meals" :key="mealplan.id" v-model="hover[mealplan.id]" class="my-1">
<v-list-item :to="edit || !mealplan.recipe ? null : `/recipe/${mealplan.recipe.slug}`">
<v-list-item-avatar :rounded="false">
<RecipeCardImage
v-if="mealplan.recipe"
tiny
icon-size="25"
:slug="mealplan.recipe ? mealplan.recipe.slug : ''"
/>
<v-icon v-else>
{{ $globals.icons.primary }}
</v-icon>
</v-list-item-avatar>
<v-list-item-content>
<v-list-item-title class="mb-1">
{{ mealplan.recipe ? mealplan.recipe.name : mealplan.title }}
</v-list-item-title>
<v-list-item-subtitle style="min-height: 16px">
{{ mealplan.recipe ? mealplan.recipe.description + " " : mealplan.text }}
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-divider class="mx-2"></v-divider>
<div class="py-2 px-2 d-flex">
<v-menu offset-y>
<template #activator="{ on, attrs }">
<v-chip v-bind="attrs" label small color="accent" v-on="on" @click.prevent>
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ mealplan.entryType }}
</v-chip>
</template>
<v-list>
<v-list-item
v-for="mealType in planTypeOptions"
:key="mealType.value"
@click="actions.setType(mealplan, mealType.value)"
>
<v-list-item-title> {{ mealType.text }} </v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-spacer></v-spacer>
<v-btn color="error" small icon @click="actions.deleteOne(mealplan.id)">
<v-icon>{{ $globals.icons.delete }}</v-icon>
</v-btn>
</div>
</v-card>
</draggable>
<!-- Day Column Actions -->
<div class="d-flex justify-end">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.diceMultiple,
text: 'Random Meal',
event: 'random',
children: [
{
icon: $globals.icons.dice,
text: 'Breakfast',
event: 'randomBreakfast',
},
{
icon: $globals.icons.dice,
text: 'Lunch',
event: 'randomLunch',
},
],
},
{
icon: $globals.icons.potSteam,
text: 'Random Dinner',
event: 'randomDinner',
},
{
icon: $globals.icons.bolwMixOutline,
text: 'Random Side',
event: 'randomSide',
},
{
icon: $globals.icons.createAlt,
text: $t('general.new'),
event: 'create',
},
]"
@create="openDialog(plan.date)"
@randomBreakfast="randomMeal(plan.date, 'breakfast')"
@randomLunch="randomMeal(plan.date, 'lunch')"
@randomDinner="randomMeal(plan.date, 'dinner')"
@randomSide="randomMeal(plan.date, 'side')"
/>
</div>
</template>
<template v-else-if="plan.meals">
<RecipeCard
v-for="mealplan in plan.meals"
:key="mealplan.id"
:recipe-id="0"
:image-height="125"
class="mb-2"
:route="mealplan.recipe ? true : false"
:slug="mealplan.recipe ? mealplan.recipe.slug : mealplan.title"
:description="mealplan.recipe ? mealplan.recipe.description : mealplan.text"
:name="mealplan.recipe ? mealplan.recipe.name : mealplan.title"
>
<template #actions>
<v-divider class="mb-0 mt-2 mx-2"></v-divider>
<v-card-actions class="justify-end mt-1">
<v-chip label small color="accent">
<v-icon left>
{{ $globals.icons.tags }}
</v-icon>
{{ mealplan.entryType }}
</v-chip>
</v-card-actions>
</template>
</RecipeCard>
</template>
<v-skeleton-loader v-else elevation="2" type="image, list-item-two-line"></v-skeleton-loader>
</v-col>
</v-row>
</v-container>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, watch } from "@nuxtjs/composition-api";
import { isSameDay, addDays, subDays, parseISO, format } from "date-fns";
import { SortableEvent } from "sortablejs"; // eslint-disable-line
import draggable from "vuedraggable";
import { useMealplans, planTypeOptions } from "~/composables/use-group-mealplan";
import { useRecipes, allRecipes } from "~/composables/recipes";
import RecipeCardImage from "~/components/Domain/Recipe/RecipeCardImage.vue";
import RecipeCard from "~/components/Domain/Recipe/RecipeCard.vue";
import { PlanEntryType } from "~/types/api-types/meal-plan";
import { useUserApi } from "~/composables/api";
export default defineComponent({
components: {
draggable,
RecipeCardImage,
RecipeCard,
},
setup() {
const state = reactive({
createMealDialog: false,
edit: false,
hover: {},
pickerMenu: null,
today: new Date(),
});
const weekRange = computed(() => {
return {
start: subDays(state.today as Date, 1),
end: addDays(state.today as Date, 6),
};
});
const api = useUserApi();
const { mealplans, actions, loading } = useMealplans(weekRange);
useRecipes(true, true);
function filterMealByDate(date: Date) {
if (!mealplans.value) return [];
return mealplans.value.filter((meal) => {
const mealDate = parseISO(meal.date);
return isSameDay(mealDate, date);
});
}
function forwardOneWeek() {
if (!state.today) return;
state.today = addDays(state.today as Date, +5);
}
function backOneWeek() {
if (!state.today) return;
state.today = addDays(state.today as Date, -5);
}
function onMoveCallback(evt: SortableEvent) {
// Adapted From https://github.com/SortableJS/Vue.Draggable/issues/1029
const ogEvent: DragEvent = (evt as any).originalEvent;
if (ogEvent && ogEvent.type !== "drop") {
// The drop was cancelled, unsure if anything needs to be done?
console.log("Cancel Move Event");
} else {
// A Meal was moved, set the new date value and make an update request and refresh the meals
const fromMealsByIndex = parseInt(evt.from.getAttribute("data-index") ?? "");
const toMealsByIndex = parseInt(evt.to.getAttribute("data-index") ?? "");
if (!isNaN(fromMealsByIndex) && !isNaN(toMealsByIndex)) {
const mealData = mealsByDate.value[fromMealsByIndex].meals[evt.oldIndex as number];
const destDate = mealsByDate.value[toMealsByIndex].date;
mealData.date = format(destDate, "yyyy-MM-dd");
actions.updateOne(mealData);
}
}
}
const mealsByDate = computed(() => {
return days.value.map((day) => {
return { date: day, meals: filterMealByDate(day) };
});
});
const days = computed(() => {
return Array.from(Array(8).keys()).map(
(i) => new Date(weekRange.value.start.getTime() + i * 24 * 60 * 60 * 1000)
);
});
// =====================================================
// New Meal Dialog
const dialog = reactive({
loading: false,
error: false,
note: false,
});
watch(dialog, () => {
if (dialog.note) {
newMeal.recipeId = undefined;
}
newMeal.title = "";
newMeal.text = "";
});
const newMeal = reactive({
date: "",
title: "",
text: "",
recipeId: undefined as number | undefined,
entryType: "dinner" as PlanEntryType,
});
function openDialog(date: Date) {
newMeal.date = format(date, "yyyy-MM-dd");
state.createMealDialog = true;
}
function resetDialog() {
newMeal.date = "";
newMeal.title = "";
newMeal.text = "";
newMeal.entryType = "dinner";
newMeal.recipeId = undefined;
}
async function randomMeal(date: Date, type: PlanEntryType) {
const { data } = await api.mealplans.setRandom({
date: format(date, "yyyy-MM-dd"),
entryType: type,
});
if (data) {
actions.refreshAll();
}
}
return {
...toRefs(state),
actions,
allRecipes,
backOneWeek,
days,
dialog,
forwardOneWeek,
loading,
mealplans,
mealsByDate,
newMeal,
onMoveCallback,
openDialog,
planTypeOptions,
randomMeal,
resetDialog,
weekRange,
};
},
head() {
return {
title: this.$t("meal-plan.dinner-this-week") as string,
};
},
});
</script>
<style lang="css">
.left-color-border {
border-left: 5px solid var(--v-primary-base) !important;
}
.bottom-color-border {
border-bottom: 2px solid var(--v-primary-base) !important;
}
</style>

View file

@ -0,0 +1,179 @@
<template>
<v-container class="md-container">
<BasePageTitle divider>
<template #header>
<v-img max-height="100" max-width="100" :src="require('~/static/svgs/manage-cookbooks.svg')"></v-img>
</template>
<template #title> Meal Plan Rules </template>
Here you can set rules for auto selecting recipes for you meal plans. These rules are used by the server to
determine the random pool of recipes to select from when creating meal plans. Note that if rules have the same
day/type constraints then the categories of the rules will be merged. In practice, it's unnecessary to create
duplicate rules, but it's possible to do so.
</BasePageTitle>
<v-card>
<v-card-title class="headline"> New Rule </v-card-title>
<v-divider class="mx-2"></v-divider>
<v-card-text>
When creating a new rule for a meal plan you can restrict the rule to be applicable for a specific day of the
week and/or a specific type of meal. To apply a rule to all days or all meal types you can set the rule to "Any"
which will apply it to all the possible values for the day and/or meal type.
<GroupMealPlanRuleForm
class="mt-2"
:day.sync="createData.day"
:entry-type.sync="createData.entryType"
:categories.sync="createData.categories"
:tags.sync="createData.tags"
/>
</v-card-text>
<v-card-actions class="justify-end">
<BaseButton create @click="createRule" />
</v-card-actions>
</v-card>
<section>
<BaseCardSectionTitle class="mt-10" title="Recipe Rules" />
<div>
<div v-for="(rule, idx) in allRules" :key="rule.id">
<v-card class="my-2">
<v-card-title>
{{ rule.day }} - {{ rule.entryType }}
<span class="ml-auto">
<BaseButtonGroup
:buttons="[
{
icon: $globals.icons.edit,
text: $t('general.edit'),
event: 'edit',
},
{
icon: $globals.icons.delete,
text: $t('general.delete'),
event: 'delete',
},
]"
@delete="deleteRule(rule.id)"
@edit="toggleEditState(rule.id)"
/>
</span>
</v-card-title>
<v-card-text>
<template v-if="!editState[rule.id]">
<div>Categories: {{ rule.categories.map((c) => c.name).join(", ") }}</div>
<div>Tags: {{ rule.tags.map((t) => t.name).join(", ") }}</div>
</template>
<template v-else>
<GroupMealPlanRuleForm
:day.sync="allRules[idx].day"
:entry-type.sync="allRules[idx].entryType"
:categories.sync="allRules[idx].categories"
:tags.sync="allRules[idx].tags"
/>
<div class="d-flex justify-end">
<BaseButton update @click="updateRule(rule)" />
</div>
</template>
</v-card-text>
</v-card>
</div>
</div>
</section>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, useAsync } from "@nuxtjs/composition-api";
import { useUserApi } from "~/composables/api";
import { PlanRulesCreate, PlanRulesOut } from "~/types/api-types/meal-plan";
import GroupMealPlanRuleForm from "~/components/Domain/Group/GroupMealPlanRuleForm.vue";
import { useAsyncKey } from "~/composables/use-utils";
export default defineComponent({
components: {
GroupMealPlanRuleForm,
},
props: {
value: {
type: Boolean,
default: false,
},
},
setup() {
const api = useUserApi();
// ======================================================
// Manage All
const editState = ref<{ [key: string]: boolean }>({});
const allRules = ref<PlanRulesOut[]>([]);
function toggleEditState(id: string) {
editState.value[id] = !editState.value[id];
editState.value = { ...editState.value };
}
async function refreshAll() {
const { data } = await api.mealplanRules.getAll();
if (data) {
allRules.value = data;
}
}
useAsync(async () => {
await refreshAll();
}, useAsyncKey());
// ======================================================
// Creating Rules
const createData = ref<PlanRulesCreate>({
entryType: "unset",
day: "unset",
categories: [],
tags: [],
});
async function createRule() {
const { data } = await api.mealplanRules.createOne(createData.value);
if (data) {
refreshAll();
createData.value = {
entryType: "unset",
day: "unset",
categories: [],
tags: [],
};
}
}
async function deleteRule(ruleId: string) {
const { data } = await api.mealplanRules.deleteOne(ruleId);
if (data) {
refreshAll();
}
}
async function updateRule(rule: PlanRulesOut) {
const { data } = await api.mealplanRules.updateOne(rule.id, rule);
if (data) {
refreshAll();
toggleEditState(rule.id);
}
}
return {
allRules,
createData,
createRule,
deleteRule,
editState,
updateRule,
toggleEditState,
};
},
head: {
title: "Meal Plan Settings",
},
});
</script>

View file

@ -0,0 +1,21 @@
<template>
<div>This Week</div>
</template>
<script lang="ts">
import { defineComponent } from "@nuxtjs/composition-api";
export default defineComponent({
setup() {
return {};
},
head() {
return {
title: this.$t("meal-plan.dinner-this-week") as string,
};
},
});
</script>
<style scoped>
</style>