From 8504eb9b386228d69bb78822901ab10279935c58 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Tue, 7 May 2019 19:48:14 +0200 Subject: [PATCH] Finished first version of meal planning (for now closes #146) --- changelog/47_UNRELEASED_xxxx-xx-xx.md | 1 + controllers/RecipesController.php | 19 ++-- localization/strings.pot | 8 ++ migrations/0071.sql | 54 ++++++++++++ public/js/extensions.js | 18 ++++ public/js/grocy.js | 18 ++-- public/viewjs/mealplan.js | 120 +++++++++++++++++++++++++- services/RecipesService.php | 4 + views/mealplan.blade.php | 13 ++- 9 files changed, 240 insertions(+), 15 deletions(-) create mode 100644 migrations/0071.sql diff --git a/changelog/47_UNRELEASED_xxxx-xx-xx.md b/changelog/47_UNRELEASED_xxxx-xx-xx.md index 391d86bf..8f960733 100644 --- a/changelog/47_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/47_UNRELEASED_xxxx-xx-xx.md @@ -5,6 +5,7 @@ - => Can be configured under Master data / Userfields - New feature: Meal planning - Simple approach for the beginning (more to come): A week view where you can add recipes for each day (new menu entry in the sidebar, below calendar) + - Of course it's also possible to put missing things directly on the shopping list from there, also for a complete week at once - General improvements - The "expires soon" or "due soon" days (yelllow bar at the top of each overview page) can now be configured - => New settings page for each area under the settings icon at the top right diff --git a/controllers/RecipesController.php b/controllers/RecipesController.php index abaf2166..fc6d2921 100644 --- a/controllers/RecipesController.php +++ b/controllers/RecipesController.php @@ -19,7 +19,14 @@ class RecipesController extends BaseController public function Overview(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { - $recipes = $this->Database->recipes()->orderBy('name'); + if (isset($request->getQueryParams()['include-internal'])) + { + $recipes = $this->Database->recipes()->orderBy('name'); + } + else + { + $recipes = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->orderBy('name'); + } $recipesResolved = $this->RecipesService->GetRecipesResolved(); $selectedRecipe = null; @@ -71,7 +78,7 @@ class RecipesController extends BaseController $recipeId = $args['recipeId']; if ($recipeId == 'new') { - $newRecipe = $this->Database->recipes()->createRow(array( + $newRecipe = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->createRow(array( 'name' => $this->LocalizationService->__t('New recipe') )); $newRecipe->save(); @@ -87,7 +94,7 @@ class RecipesController extends BaseController 'quantityunits' => $this->Database->quantity_units(), 'recipePositionsResolved' => $this->RecipesService->GetRecipesPosResolved(), 'recipesResolved' => $this->RecipesService->GetRecipesResolved(), - 'recipes' => $this->Database->recipes()->orderBy('name'), + 'recipes' => $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->orderBy('name'), 'recipeNestings' => $this->Database->recipes_nestings()->where('recipe_id', $recipeId), 'userfields' => $this->UserfieldsService->GetFields('recipes') ]); @@ -118,7 +125,7 @@ class RecipesController extends BaseController public function MealPlan(\Slim\Http\Request $request, \Slim\Http\Response $response, array $args) { - $recipes = $this->Database->recipes()->fetchAll(); + $recipes = $this->Database->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll(); $events = array(); foreach($this->Database->meal_plan() as $mealPlanEntry) @@ -135,7 +142,9 @@ class RecipesController extends BaseController return $this->AppContainer->view->render($response, 'mealplan', [ 'fullcalendarEventSources' => $events, - 'recipes' => $recipes + 'recipes' => $recipes, + 'internalRecipes' => $this->Database->recipes()->whereNot('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll(), + 'recipesResolved' => $this->RecipesService->GetRecipesResolved() ]); } } diff --git a/localization/strings.pot b/localization/strings.pot index 5456584e..8af2f098 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -1236,3 +1236,11 @@ msgstr "" msgid "Add recipe to %s" msgstr "" + +msgid "%s serving" +msgid_plural "%s servings" +msgstr[0] "" +msgstr[1] "" + +msgid "Week costs" +msgstr "" diff --git a/migrations/0071.sql b/migrations/0071.sql new file mode 100644 index 00000000..2ac2d880 --- /dev/null +++ b/migrations/0071.sql @@ -0,0 +1,54 @@ +ALTER TABLE meal_plan +ADD servings INTEGER DEFAULT 1; + +ALTER TABLE recipes +ADD type TEXT DEFAULT 'normal'; + +CREATE INDEX ix_recipes ON recipes ( + name, + type +); + +CREATE TRIGGER create_internal_recipe AFTER INSERT ON meal_plan +BEGIN + -- Create a recipe per day + DELETE FROM recipes + WHERE name = NEW.day + AND type = 'mealplan-day'; + + INSERT OR REPLACE INTO recipes + (id, name, type) + VALUES + ((SELECT MIN(id) - 1 FROM recipes), NEW.day, 'mealplan-day'); + + -- Create a recipe per week + DELETE FROM recipes + WHERE name = LTRIM(STRFTIME('%Y-%W', NEW.day), '0') + AND type = 'mealplan-week'; + + INSERT INTO recipes + (id, name, type) + VALUES + ((SELECT MIN(id) - 1 FROM recipes), LTRIM(STRFTIME('%Y-%W', NEW.day), '0'), 'mealplan-week'); + + -- Delete all current nestings entries for the day and week recipe + DELETE FROM recipes_nestings + WHERE recipe_id IN (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-day') + OR recipe_id IN (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-week'); + + -- Add all recipes for this day as included recipes in the day-recipe + INSERT INTO recipes_nestings + (recipe_id, includes_recipe_id, servings) + SELECT (SELECT id FROM recipes WHERE name = NEW.day AND type = 'mealplan-day'), recipe_id, SUM(servings) + FROM meal_plan + WHERE day = NEW.day + GROUP BY recipe_id; + + -- Add all recipes for this week as included recipes in the week-recipe + INSERT INTO recipes_nestings + (recipe_id, includes_recipe_id, servings) + SELECT (SELECT id FROM recipes WHERE name = LTRIM(STRFTIME('%Y-%W', NEW.day), '0') AND type = 'mealplan-week'), recipe_id, SUM(servings) + FROM meal_plan + WHERE STRFTIME('%Y-%W', day) = STRFTIME('%Y-%W', NEW.day) + GROUP BY recipe_id; +END; diff --git a/public/js/extensions.js b/public/js/extensions.js index 52f10875..f6bbac37 100644 --- a/public/js/extensions.js +++ b/public/js/extensions.js @@ -16,6 +16,11 @@ String.prototype.isEmpty = function() return (this.length === 0 || !this.trim()); }; +String.prototype.replaceAll = function(search, replacement) +{ + return this.replace(new RegExp(search, "g"), replacement); +}; + GetUriParam = function(key) { var currentUri = decodeURIComponent(window.location.search.substring(1)); @@ -72,3 +77,16 @@ $.extend($.expr[":"], return (elem.textContent || elem.innerText || "").toLowerCase().indexOf((match[3] || "").toLowerCase()) >= 0; } }); + +FindObjectInArrayByPropertyValue = function(array, propertyName, propertyValue) +{ + for (var i = 0; i < array.length; i++) + { + if (array[i][propertyName] == propertyValue) + { + return array[i]; + } + } + + return null; +} diff --git a/public/js/grocy.js b/public/js/grocy.js index 6deac66e..296cb403 100644 --- a/public/js/grocy.js +++ b/public/js/grocy.js @@ -482,15 +482,19 @@ $("#about-dialog-link").on("click", function() }); }); -$(".locale-number-format[data-format='currency']").each(function () +function RefreshLocaleNumberDisplay() { - $(this).text(parseFloat($(this).text()).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency })); -}); + $(".locale-number-format[data-format='currency']").each(function() + { + $(this).text(parseFloat($(this).text()).toLocaleString(undefined, { style: "currency", currency: Grocy.Currency })); + }); -$(".locale-number-format[data-format='quantity-amount']").each(function () -{ - $(this).text(parseFloat($(this).text()).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 3 })); -}); + $(".locale-number-format[data-format='quantity-amount']").each(function() + { + $(this).text(parseFloat($(this).text()).toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 3 })); + }); +} +RefreshLocaleNumberDisplay(); $(document).on("click", ".easy-link-copy-textbox", function() { diff --git a/public/viewjs/mealplan.js b/public/viewjs/mealplan.js index aebfccd0..2e7c61cd 100644 --- a/public/viewjs/mealplan.js +++ b/public/viewjs/mealplan.js @@ -12,21 +12,72 @@ "viewRender": function(view) { $(".fc-day-header").append(''); + + var weekRecipeName = view.start.year().toString() + "-" + (view.start.week() - 1).toString(); + var weekRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", weekRecipeName); + + var weekCosts = 0; + var weekRecipeOrderMissingButtonHtml = ""; + if (weekRecipe !== null) + { + weekCosts = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).costs; + + var weekRecipeOrderMissingButtonDisabledClasses = ""; + if (FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", weekRecipe.id).need_fulfilled_with_shopping_list == 1) + { + weekRecipeOrderMissingButtonDisabledClasses = "disabled"; + } + weekRecipeOrderMissingButtonHtml = '' + } + $(".fc-header-toolbar .fc-center").html("

" + __t("Week costs") + ': ' + weekCosts.toString() + " " + weekRecipeOrderMissingButtonHtml + "

"); }, "eventRender": function(event, element) { var recipe = JSON.parse(event.recipe); + var mealPlanEntry = JSON.parse(event.mealPlanEntry); + var resolvedRecipe = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", recipe.id); element.removeClass("fc-event"); element.addClass("text-center"); + element.attr("data-recipe", event.recipe); element.attr("data-meal-plan-entry", event.mealPlanEntry); - element.html('
' + recipe.name + '
'); + + var recipeOrderMissingButtonDisabledClasses = ""; + if (resolvedRecipe.need_fulfilled_with_shopping_list == 1) + { + recipeOrderMissingButtonDisabledClasses = "disabled"; + } + + var fulfillmentInfoHtml = __t('Enough in stock'); + var fulfillmentIconHtml = ''; + if (resolvedRecipe.need_fulfilled != 1) + { + fulfillmentInfoHtml = __t('Not enough in stock'); + var fulfillmentIconHtml = ''; + } + + element.html(' \ +
\ +
' + recipe.name + '
\ +
' + __n(mealPlanEntry.servings, "%s serving", "%s servings") + '
\ +
' + fulfillmentIconHtml + " " + fulfillmentInfoHtml + '
\ +
' + resolvedRecipe.costs + '
\ +
\ + \ + \ +
\ +
'); + if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty()) { element.html(element.html() + '') } - } + }, + "eventAfterAllRender": function(view) + { + RefreshLocaleNumberDisplay(); + }, }); $(document).on("click", ".add-recipe-button", function(e) @@ -98,3 +149,68 @@ Grocy.Components.RecipePicker.GetInputElement().keydown(function(event) } } }); + +$(document).on("keyodwn", "#servings", function(e) +{ + if (event.keyCode === 13) //Enter + { + event.preventDefault(); + + if (document.getElementById("add-recipe-form").checkValidity() === false) //There is at least one validation error + { + return false; + } + else + { + $("#save-add-recipe-button").click(); + } + } +}); + +$(document).on('click', '.recipe-order-missing-button', function(e) +{ + var objectName = $(e.currentTarget).attr('data-recipe-name'); + var objectId = $(e.currentTarget).attr('data-recipe-id'); + var button = $(this); + + bootbox.confirm({ + message: __t('Are you sure to put all missing ingredients for recipe "%s" on the shopping list?', objectName), + buttons: { + confirm: { + label: __t('Yes'), + className: 'btn-success' + }, + cancel: { + label: __t('No'), + className: 'btn-danger' + } + }, + callback: function(result) + { + if (result === true) + { + Grocy.FrontendHelpers.BeginUiBusy(); + + Grocy.Api.Post('recipes/' + objectId + '/add-not-fulfilled-products-to-shoppinglist', { }, + function(result) + { + if (button.attr("data-recipe-type") == "normal") + { + button.addClass("disabled"); + Grocy.FrontendHelpers.EndUiBusy(); + } + else + { + window.location.reload(); + } + }, + function(xhr) + { + Grocy.FrontendHelpers.EndUiBusy(); + console.error(xhr); + } + ); + } + } + }); +}); diff --git a/services/RecipesService.php b/services/RecipesService.php index 3fb0531e..9011a6ce 100644 --- a/services/RecipesService.php +++ b/services/RecipesService.php @@ -6,6 +6,10 @@ use \Grocy\Services\StockService; class RecipesService extends BaseService { + const RECIPE_TYPE_NORMAL = 'normal'; + const RECIPE_TYPE_MEALPLAN_DAY = 'mealplan-day'; + const RECIPE_TYPE_MEALPLAN_WEEK = 'mealplan-week'; + public function __construct() { parent::__construct(); diff --git a/views/mealplan.blade.php b/views/mealplan.blade.php index 60b31cd4..03a03d99 100644 --- a/views/mealplan.blade.php +++ b/views/mealplan.blade.php @@ -16,6 +16,8 @@ @section('content')
@@ -43,7 +45,16 @@ @include('components.recipepicker', array( 'recipes' => $recipes, - 'isRequired' => true + 'isRequired' => true, + 'nextInputSelector' => '#servings' + )) + + @include('components.numberpicker', array( + 'id' => 'servings', + 'label' => 'Servings', + 'min' => 1, + 'value' => '1', + 'invalidFeedback' => $__t('This cannot be lower than %s', '1') ))