Finished first version of meal planning (for now closes #146)

This commit is contained in:
Bernd Bestel 2019-05-07 19:48:14 +02:00
parent 3f53919ddd
commit 8504eb9b38
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
9 changed files with 240 additions and 15 deletions

View File

@ -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

View File

@ -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()
]);
}
}

View File

@ -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 ""

54
migrations/0071.sql Normal file
View File

@ -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;

View File

@ -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;
}

View File

@ -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()
{

View File

@ -12,21 +12,72 @@
"viewRender": function(view)
{
$(".fc-day-header").append('<a class="ml-1 btn btn-outline-dark btn-xs my-1 add-recipe-button" href="#"><i class="fas fa-plus"></i></a>');
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 = '<a class="ml-1 btn btn-outline-primary btn-xs recipe-order-missing-button ' + weekRecipeOrderMissingButtonDisabledClasses + '" href="#" data-toggle="tooltip" title="' + __t("Put missing products on shopping list") + '" data-recipe-id="' + weekRecipe.id.toString() + '" data-recipe-name="' + weekRecipe.name + '" data-recipe-type="' + weekRecipe.type + '"><i class="fas fa-cart-plus"></i></a>'
}
$(".fc-header-toolbar .fc-center").html("<h4>" + __t("Week costs") + ': <span class="locale-number-format" data-format="currency">' + weekCosts.toString() + "</span> " + weekRecipeOrderMissingButtonHtml + "</h4>");
},
"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('<h5 class="text-truncate">' + recipe.name + '<br><a class="ml-1 btn btn-outline-danger btn-xs remove-recipe-button" href="#"><i class="fas fa-trash"></i></a></h5>');
var recipeOrderMissingButtonDisabledClasses = "";
if (resolvedRecipe.need_fulfilled_with_shopping_list == 1)
{
recipeOrderMissingButtonDisabledClasses = "disabled";
}
var fulfillmentInfoHtml = __t('Enough in stock');
var fulfillmentIconHtml = '<i class="fas fa-check text-success"></i>';
if (resolvedRecipe.need_fulfilled != 1)
{
fulfillmentInfoHtml = __t('Not enough in stock');
var fulfillmentIconHtml = '<i class="fas fa-times text-danger"></i>';
}
element.html(' \
<div class="text-truncate"> \
<h5>' + recipe.name + '<h5> \
<h5 class="small">' + __n(mealPlanEntry.servings, "%s serving", "%s servings") + '</h5> \
<h5 class="small timeago-contextual">' + fulfillmentIconHtml + " " + fulfillmentInfoHtml + '</h5> \
<h5 class="small locale-number-format" data-format="currency">' + resolvedRecipe.costs + '<h5> \
<h5> \
<a class="ml-1 btn btn-outline-danger btn-xs remove-recipe-button" href="#"><i class="fas fa-trash"></i></a> \
<a class="ml-1 btn btn-outline-primary btn-xs recipe-order-missing-button ' + recipeOrderMissingButtonDisabledClasses + '" href="#" data-toggle="tooltip" title="' + __t("Put missing products on shopping list") + '" data-recipe-id="' + recipe.id.toString() + '" data-recipe-name="' + recipe.name + '" data-recipe-type="' + recipe.type + '"><i class="fas fa-cart-plus"></i></a> \
</h5> \
</div>');
if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty())
{
element.html(element.html() + '<img src="' + U("/api/files/recipepictures/") + btoa(recipe.picture_file_name) + '" class="img-fluid">')
}
}
},
"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);
}
);
}
}
});
});

View File

@ -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();

View File

@ -16,6 +16,8 @@
@section('content')
<script>
var fullcalendarEventSources = {!! json_encode(array($fullcalendarEventSources)) !!}
var internalRecipes = {!! json_encode($internalRecipes) !!}
var recipesResolved = {!! json_encode($recipesResolved) !!}
</script>
<div class="row">
@ -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')
))
<input type="hidden" id="day" name="day" value="">