diff --git a/changelog/62_UNRELEASED_xxxx-xx-xx.md b/changelog/62_UNRELEASED_xxxx-xx-xx.md
index b7c4f867..0edbdc9f 100644
--- a/changelog/62_UNRELEASED_xxxx-xx-xx.md
+++ b/changelog/62_UNRELEASED_xxxx-xx-xx.md
@@ -2,7 +2,8 @@
> ⚠️ PHP 8 is now supported and from now on the only tested runtime version (although currently PHP 7.2 should still work).
-### New feature: (Own) Product/stock entry/chores/batteries labels/barcodes ("grocycode")
+### New feature: grocycode
+#### (Own) Product/stock entry/chores/batteries labels/barcodes
- Print own labels/barcodes for products/stock entries/chores/batteries and then scan that code on every place a product/stock entry/chore/battery can be selected
- Can be printed (or downloaded) via
- The product/chore/battery edit page
@@ -19,6 +20,11 @@
- https://github.com/grocy/grocy/blob/master/docs/label-printing.md
- (Thanks a lot @mistressofjellyfish for the initial work on this)
+### New feature: Meal plan sections
+- Split the meal plan into sections like Breakfast/Lunch/Dinner
+ - => New button "Configure sections" on the meal plan page to configure the sections (top right corner)
+ - => Each meal plan entry can be assigned to a section
+
### New feature: Shopping list thermal printer support
- The shopping list can now be printed on a thermal printer
- The printer must be compatible to the `ESC/POS` protocol and needs to be locally attached or network reachable to/by the machine hosting grocy (so the server)
diff --git a/controllers/RecipesController.php b/controllers/RecipesController.php
index 5e6ce33a..af8e99ae 100644
--- a/controllers/RecipesController.php
+++ b/controllers/RecipesController.php
@@ -8,16 +8,15 @@ class RecipesController extends BaseController
{
public function MealPlan(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
{
- // Load +- 8 days to always include week boundaries
-
+ // Given date is always the first day of the week => load the coming week / 7 days
if (isset($request->getQueryParams()['week']) && IsIsoDate($request->getQueryParams()['week']))
{
$week = $request->getQueryParams()['week'];
- $mealPlanWhereTimespan = "day BETWEEN DATE('$week', '-8 day') AND DATE('$week', '+8 day')";
+ $mealPlanWhereTimespan = "day BETWEEN DATE('$week') AND DATE('$week', '+7 days')";
}
else
{
- $mealPlanWhereTimespan = "day BETWEEN DATE(DATE('now', 'localtime'), '-8 day') AND DATE(DATE('now', 'localtime'), '+8 day')";
+ $mealPlanWhereTimespan = "day BETWEEN DATE('now', 'localtime', 'weekday 0', '-7 days') AND DATE(DATE('now', 'localtime', 'weekday 0', '-7 days'), '+7 days')";
}
$recipes = $this->getDatabase()->recipes()->where('type', RecipesService::RECIPE_TYPE_NORMAL)->fetchAll();
@@ -58,7 +57,9 @@ class RecipesController extends BaseController
'recipesResolved' => $this->getRecipesService()->GetRecipesResolved2("recipe_id IN (SELECT recipe_id FROM meal_plan_internal_recipe_relation WHERE $mealPlanWhereTimespan)"),
'products' => $this->getDatabase()->products()->orderBy('name', 'COLLATE NOCASE'),
'quantityUnits' => $this->getDatabase()->quantity_units()->orderBy('name', 'COLLATE NOCASE'),
- 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved()
+ 'quantityUnitConversionsResolved' => $this->getDatabase()->quantity_unit_conversions_resolved(),
+ 'mealplanSections' => $this->getDatabase()->meal_plan_sections()->orderBy('sort_number'),
+ 'usedMealplanSections' => $this->getDatabase()->meal_plan_sections()->where("id IN (SELECT section_id FROM meal_plan WHERE $mealPlanWhereTimespan)")->orderBy('sort_number')
]);
}
@@ -190,6 +191,30 @@ class RecipesController extends BaseController
return $this->renderPage($response, 'recipessettings');
}
+ public function MealPlanSectionEditForm(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
+ {
+ if ($args['sectionId'] == 'new')
+ {
+ return $this->renderPage($response, 'mealplansectionform', [
+ 'mode' => 'create'
+ ]);
+ }
+ else
+ {
+ return $this->renderPage($response, 'mealplansectionform', [
+ 'mealplanSection' => $this->getDatabase()->meal_plan_sections($args['sectionId']),
+ 'mode' => 'edit'
+ ]);
+ }
+ }
+
+ public function MealPlanSectionsList(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Message\ResponseInterface $response, array $args)
+ {
+ return $this->renderPage($response, 'mealplansections', [
+ 'mealplanSections' => $this->getDatabase()->meal_plan_sections()->where('id > 0')->orderBy('sort_number')
+ ]);
+ }
+
public function __construct(\DI\Container $container)
{
parent::__construct($container);
diff --git a/grocy.openapi.json b/grocy.openapi.json
index e94b0cb3..e3325477 100644
--- a/grocy.openapi.json
+++ b/grocy.openapi.json
@@ -5579,7 +5579,8 @@
"stock_log",
"stock",
"stock_current_locations",
- "chores_log"
+ "chores_log",
+ "meal_plan_sections"
]
},
"ExposedEntityNoListing": {
diff --git a/localization/demo_data.pot b/localization/demo_data.pot
index 8c5618cd..3c4ef745 100644
--- a/localization/demo_data.pot
+++ b/localization/demo_data.pot
@@ -377,3 +377,12 @@ msgstr ""
msgid "Finnish"
msgstr ""
+
+msgid "Breakfast"
+msgstr ""
+
+msgid "Lunch"
+msgstr ""
+
+msgid "Dinner"
+msgstr ""
diff --git a/localization/strings.pot b/localization/strings.pot
index aee0d9db..23a70624 100644
--- a/localization/strings.pot
+++ b/localization/strings.pot
@@ -2223,3 +2223,24 @@ msgstr ""
msgid "Stock entry"
msgstr ""
+
+msgid "Configure sections"
+msgstr ""
+
+msgid "Meal plan sections"
+msgstr ""
+
+msgid "Create meal plan section"
+msgstr ""
+
+msgid "Sections will be ordered by that number on the meal plan"
+msgstr ""
+
+msgid "Edit meal plan section"
+msgstr ""
+
+msgid "Are you sure to delete meal plan section \"%s\"?"
+msgstr ""
+
+msgid "Section"
+msgstr ""
diff --git a/migrations/0139.sql b/migrations/0139.sql
index b180a395..a88f61ac 100644
--- a/migrations/0139.sql
+++ b/migrations/0139.sql
@@ -69,7 +69,7 @@ BEGIN
GROUP BY product_id, product_qu_id;
-- Create a shadow recipe per meal plan recipe
- INSERT OR REPLACE INTO recipes
+ INSERT INTO recipes
(id, name, type)
SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow'
FROM meal_plan
@@ -77,9 +77,6 @@ BEGIN
AND type = 'recipe'
AND recipe_id IS NOT NULL;
- DELETE FROM recipes_nestings
- WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow');
-
INSERT INTO recipes_nestings
(recipe_id, includes_recipe_id, servings)
SELECT (SELECT id FROM recipes WHERE name = CAST(NEW.day AS TEXT) || '#' || CAST(meal_plan.id AS TEXT) AND type = 'mealplan-shadow'), recipe_id, recipe_servings
@@ -190,7 +187,7 @@ BEGIN
*/
-- Create a shadow recipe per meal plan recipe
- INSERT OR REPLACE INTO recipes
+ INSERT INTO recipes
(id, name, type)
SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow'
FROM meal_plan
@@ -292,7 +289,14 @@ BEGIN
GROUP BY product_id, product_qu_id;
-- Create a shadow recipe per meal plan recipe
- INSERT OR REPLACE INTO recipes
+ DELETE FROM recipes_nestings
+ WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(NEW.id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow');
+
+ DELETE FROM recipes
+ WHERE type = 'mealplan-shadow'
+ AND name = CAST(NEW.day AS TEXT) || '#' || CAST(NEW.id AS TEXT);
+
+ INSERT INTO recipes
(id, name, type)
SELECT (SELECT MIN(id) - 1 FROM recipes), CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT), 'mealplan-shadow'
FROM meal_plan
@@ -300,9 +304,6 @@ BEGIN
AND type = 'recipe'
AND recipe_id IS NOT NULL;
- DELETE FROM recipes_nestings
- WHERE recipe_id IN (SELECT id FROM recipes WHERE name IN (SELECT CAST(NEW.day AS TEXT) || '#' || CAST(id AS TEXT) FROM meal_plan WHERE day = NEW.day) AND type = 'mealplan-shadow');
-
INSERT INTO recipes_nestings
(recipe_id, includes_recipe_id, servings)
SELECT (SELECT id FROM recipes WHERE name = CAST(NEW.day AS TEXT) || '#' || CAST(meal_plan.id AS TEXT) AND type = 'mealplan-shadow'), recipe_id, recipe_servings
diff --git a/migrations/0149.sql b/migrations/0149.sql
new file mode 100644
index 00000000..cade364f
--- /dev/null
+++ b/migrations/0149.sql
@@ -0,0 +1,14 @@
+CREATE TABLE meal_plan_sections (
+ id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT UNIQUE,
+ name TEXT NOT NULL UNIQUE,
+ sort_number INTEGER,
+ row_created_timestamp DATETIME DEFAULT (datetime('now', 'localtime'))
+);
+
+INSERT INTO meal_plan_sections
+ (id, name, sort_number)
+VALUES
+ (-1, '', -1);
+
+ALTER TABLE meal_plan
+ADD section_id INTEGER NOT NULL DEFAULT -1;
diff --git a/public/js/grocy.js b/public/js/grocy.js
index 4b7c119b..9b8d1593 100644
--- a/public/js/grocy.js
+++ b/public/js/grocy.js
@@ -1116,3 +1116,5 @@ $(document).on("click", ".change-table-columns-rowgroup-toggle", function()
dataTable.draw();
});
+
+$("#meal-plan-nav-link").attr("href", $("#meal-plan-nav-link").attr("href") + "?week=" + moment().startOf("week").format("YYYY-MM-DD"));
diff --git a/public/viewjs/mealplan.js b/public/viewjs/mealplan.js
index be5bb14f..3e2dee49 100644
--- a/public/viewjs/mealplan.js
+++ b/public/viewjs/mealplan.js
@@ -12,23 +12,50 @@ if (!Grocy.MealPlanFirstDayOfWeek.isEmpty())
firstDay = parseInt(Grocy.MealPlanFirstDayOfWeek);
}
-var calendar = $("#calendar").fullCalendar({
- "themeSystem": "bootstrap4",
- "header": {
+$(".calendar").each(function()
+{
+ var container = $(this);
+ var sectionId = container.attr("data-section-id");
+ var sectionName = container.attr("data-section-name");
+ var isPrimarySection = BoolVal(container.attr("data-primary-section"));
+ var isLastSection = BoolVal(container.attr("data-last-section"));
+
+ var headerConfig = {
"left": "title",
"center": "",
"right": "prev,today,next"
- },
- "weekNumbers": false,
- "eventLimit": false,
- "eventSources": fullcalendarEventSources,
- "defaultView": ($(window).width() < 768) ? "basicDay" : "basicWeek",
- "firstDay": firstDay,
- "height": "auto",
- "defaultDate": GetUriParam("week"),
- "viewRender": function(view)
+ };
+ if (!isPrimarySection)
{
- $(".fc-day-header").prepend('\
+ headerConfig = {
+ "left": "",
+ "center": "",
+ "right": ""
+ };
+ }
+
+ container.fullCalendar({
+ "themeSystem": "bootstrap4",
+ "header": headerConfig,
+ "weekNumbers": false,
+ "eventLimit": false,
+ "eventSources": fullcalendarEventSources,
+ "defaultView": ($(window).width() < 768) ? "agendaDay" : "agendaWeek",
+ "allDayText": sectionName,
+ "minTime": "00:00:00",
+ "maxTime": "00:00:01",
+ "scrollTime": "00:00:00",
+ "firstDay": firstDay,
+ "height": "auto",
+ "defaultDate": GetUriParam("week"),
+ "viewRender": function(view)
+ {
+ if (!isPrimarySection)
+ {
+ return;
+ }
+
+ $(".calendar[data-primary-section='true'] .fc-day-header").prepend('\
\
@@ -161,84 +193,63 @@ var calendar = $("#calendar").fullCalendar({
\
');
- if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty())
- {
- element.prepend('
')
- }
-
- var dayRecipeName = event.start.format("YYYY-MM-DD");
- if (!$("#day-summary-" + dayRecipeName).length) // This runs for every event/recipe, so maybe multiple times per day, so only add the day summary once
- {
- var dayRecipe = FindObjectInArrayByPropertyValue(internalRecipes, "name", dayRecipeName);
- if (dayRecipe != null)
+ if (recipe.picture_file_name && !recipe.picture_file_name.isEmpty())
{
- var dayRecipeResolved = FindObjectInArrayByPropertyValue(recipesResolved, "recipe_id", dayRecipe.id);
-
- var costsAndCaloriesPerDay = ""
- if (Grocy.FeatureFlags.GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING)
- {
- costsAndCaloriesPerDay = '
+@foreach($usedMealplanSections as $mealplanSection)
-
+
last doesn't work however, is always null... --}}
+ data-last-section="{{ BoolToString(array_values(array_slice($usedMealplanSections->fetchAll(), -1))[0]->id == $mealplanSection->id) }}">
+
+@endforeach
+
+{{-- Default empty calendar/section when no single meal plan entry is in the given date range --}}
+@if($usedMealplanSections->count() === 0)
+