From 3efecb8bed261bb7f0ce4a1fc1f1d41885a72fd8 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Sun, 3 Apr 2022 13:56:14 +0200 Subject: [PATCH] Make it possible to manually re-assign chores (closes #1492, references #1830) --- changelog/67_UNRELEASED_xxxx-xx-xx.md | 4 +- controllers/ChoresController.php | 2 +- grocy.openapi.json | 9 ++ localization/strings.pot | 3 + migrations/0185.sql | 83 +++++++++++++++++++ public/viewjs/choresoverview.js | 52 ++++++++++-- services/ChoresService.php | 113 +++++++++++++++----------- views/choresoverview.blade.php | 29 ++++++- 8 files changed, 232 insertions(+), 63 deletions(-) create mode 100644 migrations/0185.sql diff --git a/changelog/67_UNRELEASED_xxxx-xx-xx.md b/changelog/67_UNRELEASED_xxxx-xx-xx.md index d7814cb5..db9fb0de 100644 --- a/changelog/67_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/67_UNRELEASED_xxxx-xx-xx.md @@ -39,7 +39,7 @@ ### Shopping list -- Added a new shopping list setting (top right corner settings menu) to automatically add products, that are below their defined min. stock amount, to the shopping list +- Added a new shopping list setting (top right corner settings menu) to automatically add products, that are below their defined min. stock amount, to the shopping list (defaults to disabled) - Fixed that when using "Add products that are below defined min. stock amount", the calculated missing amount was wrong for products which had the new product option `Treat opened as out of stock` set and when having at least one opened stock entry ### Recipes @@ -61,7 +61,7 @@ - The `Daily` period type has been changed to schedule the chore at the _same time_ (based on the start date) each `n` days - This period type scheduled chores `n` days _after the last execution_ before, which is also possible by using the `Hourly` period type and a corresponding period interval; all existing `Daily` schedules will be converted to that on migration -- It's now possible to manually reschedule chores +- It's now possible to manually reschedule / assign chores - New entry "Reschedule next execution" in the context/more menu on the chores overview page - If you have rescheduled a chore and want to continue the normal schedule instead, use the "Clear" button in the dialog - Rescheduled chores will be highlighted with an corresponding icon next to the "next estiamted tracking date" diff --git a/controllers/ChoresController.php b/controllers/ChoresController.php index 6eed1a5d..46305fff 100644 --- a/controllers/ChoresController.php +++ b/controllers/ChoresController.php @@ -98,7 +98,7 @@ class ChoresController extends BaseController $currentChores = $this->getChoresService()->GetCurrent(); foreach ($currentChores as $currentChore) { - if (FindObjectInArrayByPropertyValue($chores, 'id', $currentChore->chore_id)->period_type !== \Grocy\Services\ChoresService::CHORE_PERIOD_TYPE_MANUALLY) + if (!empty($currentChore->next_estimated_execution_time)) { if ($currentChore->next_estimated_execution_time < date('Y-m-d H:i:s')) { diff --git a/grocy.openapi.json b/grocy.openapi.json index 55e0c1de..d9a61478 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -5246,6 +5246,9 @@ "type": "string", "format": "date-time" }, + "rescheduled_next_execution_assigned_to_user_id": { + "type": "integer" + }, "row_created_timestamp": { "type": "string", "format": "date-time" @@ -5516,6 +5519,12 @@ "next_execution_assigned_to_user_id": { "type": "integer" }, + "is_rescheduled": { + "type": "boolean" + }, + "is_reassigned": { + "type": "boolean" + }, "next_execution_assigned_user": { "$ref": "#/components/schemas/UserDto" } diff --git a/localization/strings.pot b/localization/strings.pot index 2d70954b..00b293b3 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2353,3 +2353,6 @@ msgstr "" msgid "Automatically add products that are below their defined min. stock amount to the shopping list" msgstr "" + +msgid "Reassigned" +msgstr "" diff --git a/migrations/0185.sql b/migrations/0185.sql new file mode 100644 index 00000000..501eb43d --- /dev/null +++ b/migrations/0185.sql @@ -0,0 +1,83 @@ +ALTER TABLE chores +ADD rescheduled_next_execution_assigned_to_user_id INT; + +DROP VIEW chores_current; +CREATE VIEW chores_current +AS +SELECT + x.chore_id AS id, -- Dummy, LessQL needs an id column + x.chore_id, + x.chore_name, + x.last_tracked_time, + CASE WHEN x.rollover = 1 AND DATETIME('now', 'localtime') > x.next_estimated_execution_time THEN + CASE WHEN IFNULL(x.track_date_only, 0) = 1 THEN + DATETIME(STRFTIME('%Y-%m-%d', DATETIME('now', 'localtime')) || ' 23:59:59') + ELSE + DATETIME(STRFTIME('%Y-%m-%d', DATETIME('now', 'localtime')) || ' ' || STRFTIME('%H:%M:%S', x.next_estimated_execution_time)) + END + ELSE + CASE WHEN IFNULL(x.track_date_only, 0) = 1 THEN + DATETIME(STRFTIME('%Y-%m-%d', x.next_estimated_execution_time) || ' 23:59:59') + ELSE + x.next_estimated_execution_time + END + END AS next_estimated_execution_time, + x.track_date_only, + x.next_execution_assigned_to_user_id, + CASE WHEN IFNULL(x.rescheduled_date, '') != '' THEN 1 ELSE 0 END AS is_rescheduled, + CASE WHEN IFNULL(x.rescheduled_next_execution_assigned_to_user_id, '') != '' THEN 1 ELSE 0 END AS is_reassigned +FROM ( + +SELECT + h.id AS chore_id, + h.name AS chore_name, + MAX(l.tracked_time) AS last_tracked_time, + CASE WHEN IFNULL(h.rescheduled_date, '') != '' THEN + h.rescheduled_date + ELSE + CASE WHEN MAX(l.tracked_time) IS NULL AND h.period_type != 'manually' THEN + h.start_date + ELSE + CASE h.period_type + WHEN 'manually' THEN NULL + WHEN 'hourly' THEN DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' hour') + WHEN 'daily' THEN DATETIME(SUBSTR(CAST(DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' days') AS TEXT), 1, 11) || SUBSTR(CAST(h.start_date AS TEXT), -8)) + WHEN 'weekly' THEN ( + SELECT next + FROM ( + SELECT 'sunday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 0') AS next + UNION + SELECT 'monday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 1') AS next + UNION + SELECT 'tuesday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 2') AS next + UNION + SELECT 'wednesday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 3') AS next + UNION + SELECT 'thursday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 4') AS next + UNION + SELECT 'friday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 5') AS next + UNION + SELECT 'saturday' AS day, DATETIME((SELECT tracked_time FROM chores_log WHERE chore_id = h.id ORDER BY tracked_time DESC LIMIT 1), '1 days', '+' || CAST((h.period_interval - 1) * 7 AS TEXT) || ' days', 'weekday 6') AS next + ) + WHERE INSTR(period_config, day) > 0 + ORDER BY next + LIMIT 1 + ) + WHEN 'monthly' THEN DATETIME(MAX(l.tracked_time), 'start of month', '+' || CAST(h.period_interval AS TEXT) || ' month', '+' || CAST(h.period_days - 1 AS TEXT) || ' day') + WHEN 'yearly' THEN DATETIME(SUBSTR(CAST(DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' years') AS TEXT), 1, 4) || SUBSTR(CAST(h.start_date AS TEXT), 5, 6) || SUBSTR(CAST(DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' years') AS TEXT), -9)) + WHEN 'adaptive' THEN DATETIME(MAX(l.tracked_time), '+' || CAST(IFNULL((SELECT average_frequency_hours FROM chores_execution_average_frequency WHERE chore_id = h.id), 0) AS TEXT) || ' hour') + END + END + END AS next_estimated_execution_time, + h.track_date_only, + h.rollover, + h.next_execution_assigned_to_user_id, + h.rescheduled_date, + h.rescheduled_next_execution_assigned_to_user_id +FROM chores h +LEFT JOIN chores_log l + ON h.id = l.chore_id + AND l.undone = 0 +WHERE h.active = 1 +GROUP BY h.id, h.name, h.period_days +) x; diff --git a/public/viewjs/choresoverview.js b/public/viewjs/choresoverview.js index fe7be3d0..4be94c1d 100644 --- a/public/viewjs/choresoverview.js +++ b/public/viewjs/choresoverview.js @@ -152,18 +152,28 @@ $(document).on('click', '.track-chore-button', function(e) $('#chore-' + choreId + '-last-tracked-time').text(trackedTime); $('#chore-' + choreId + '-last-tracked-time-timeago').attr('datetime', trackedTime); - if (result.chore.period_type != "manually") + if (result.next_estimated_execution_time != null && !result.next_estimated_execution_time.isEmpty()) { $('#chore-' + choreId + '-next-execution-time').text(result.next_estimated_execution_time); $('#chore-' + choreId + '-next-execution-time-timeago').attr('datetime', result.next_estimated_execution_time); } + else + { + $('#chore-' + choreId + '-next-execution-time').text("-"); + $('#chore-' + choreId + '-next-execution-time-timeago').removeAttr('datetime'); + } if (result.chore.next_execution_assigned_to_user_id != null) { $('#chore-' + choreId + '-next-execution-assigned-user').text(result.next_execution_assigned_user.display_name); } + else + { + $('#chore-' + choreId + '-next-execution-assigned-user').text("-"); + } - $('#chore-' + choreId + '-reschedule-icon').remove(); + $('#chore-' + choreId + '-rescheduled-icon').remove(); + $('#chore-' + choreId + '-reassigned-icon').remove(); Grocy.FrontendHelpers.EndUiBusy(); toastr.success(__t('Tracked execution of chore %1$s on %2$s', choreName, trackedTime)); @@ -280,7 +290,7 @@ $(document).on("click", ".reschedule-chore-button", function(e) Grocy.EditObjectId = choreId; Grocy.Api.Get("chores/" + choreId, function(choreDetails) { - var prefillDate = choreDetails.next_estimated_execution_time; + var prefillDate = choreDetails.next_estimated_execution_time || moment().format("YYYY-MM-DD HH:mm:ss"); if (choreDetails.chore.rescheduled_date != null && !choreDetails.chore.rescheduled_date.isEmpty()) { prefillDate = choreDetails.chore.rescheduled_date; @@ -297,6 +307,16 @@ $(document).on("click", ".reschedule-chore-button", function(e) Grocy.Components.DateTimePicker.SetValue(moment(prefillDate).format("YYYY-MM-DD HH:mm:ss")); } + if (choreDetails.chore.next_execution_assigned_to_user_id != null && !choreDetails.chore.next_execution_assigned_to_user_id.isEmpty()) + { + Grocy.Components.UserPicker.SetId(choreDetails.chore.next_execution_assigned_to_user_id) + } + else + { + Grocy.Components.UserPicker.SetValue(""); + Grocy.Components.UserPicker.SetId(null); + } + $("#reschedule-chore-modal-title").text(choreDetails.chore.name); $("#reschedule-chore-modal").modal("show"); }); @@ -311,10 +331,19 @@ $("#reschedule-chore-save-button").on("click", function(e) return; } - Grocy.Api.Put('objects/chores/' + Grocy.EditObjectId, { "rescheduled_date": Grocy.Components.DateTimePicker.GetValue() }, + Grocy.Api.Put('objects/chores/' + Grocy.EditObjectId, { "rescheduled_date": Grocy.Components.DateTimePicker.GetValue(), "rescheduled_next_execution_assigned_to_user_id": Grocy.Components.UserPicker.GetValue() }, function(result) { - window.location.reload(); + Grocy.Api.Post('chores/executions/calculate-next-assignments', { "chore_id": Grocy.EditObjectId }, + function(result) + { + window.location.reload(); + }, + function(xhr) + { + console.error(xhr); + } + ); }, function(xhr) { @@ -327,10 +356,19 @@ $("#reschedule-chore-clear-button").on("click", function(e) { e.preventDefault(); - Grocy.Api.Put('objects/chores/' + Grocy.EditObjectId, { "rescheduled_date": null }, + Grocy.Api.Put('objects/chores/' + Grocy.EditObjectId, { "rescheduled_date": null, "rescheduled_next_execution_assigned_to_user_id": null }, function(result) { - window.location.reload(); + Grocy.Api.Post('chores/executions/calculate-next-assignments', { "chore_id": Grocy.EditObjectId }, + function(result) + { + window.location.reload(); + }, + function(xhr) + { + console.error(xhr); + } + ); }, function(xhr) { diff --git a/services/ChoresService.php b/services/ChoresService.php index 7f069cb6..ac988206 100644 --- a/services/ChoresService.php +++ b/services/ChoresService.php @@ -34,66 +34,74 @@ class ChoresService extends BaseService } $chore = $this->getDatabase()->chores($choreId); - $choreLastTrackedTime = $this->getDatabase()->chores_log()->where('chore_id = :1 AND undone = 0', $choreId)->max('tracked_time'); - $lastChoreLogRow = $this->getDatabase()->chores_log()->where('chore_id = :1 AND tracked_time = :2 AND undone = 0', $choreId, $choreLastTrackedTime)->orderBy('row_created_timestamp', 'DESC')->fetch(); - $lastDoneByUserId = $lastChoreLogRow->done_by_user_id; - $users = $this->getUsersService()->GetUsersAsDto(); - $assignedUsers = []; - foreach ($users as $user) + if (!empty($chore->rescheduled_next_execution_assigned_to_user_id)) { - if (in_array($user->id, explode(',', $chore->assignment_config))) - { - $assignedUsers[] = $user; - } + $nextExecutionUserId = $chore->rescheduled_next_execution_assigned_to_user_id; } - - $nextExecutionUserId = null; - if ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_RANDOM) + else { - // Random assignment and only 1 user in the group? Well, ok - will be hard to guess the next one... - if (count($assignedUsers) == 1) - { - $nextExecutionUserId = array_shift($assignedUsers)->id; - } - else - { - $nextExecutionUserId = $assignedUsers[array_rand($assignedUsers)]->id; - } - } - elseif ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_IN_ALPHABETICAL_ORDER) - { - usort($assignedUsers, function ($a, $b) { - return strcmp($a->display_name, $b->display_name); - }); + $choreLastTrackedTime = $this->getDatabase()->chores_log()->where('chore_id = :1 AND undone = 0', $choreId)->max('tracked_time'); + $lastChoreLogRow = $this->getDatabase()->chores_log()->where('chore_id = :1 AND tracked_time = :2 AND undone = 0', $choreId, $choreLastTrackedTime)->orderBy('row_created_timestamp', 'DESC')->fetch(); + $lastDoneByUserId = $lastChoreLogRow->done_by_user_id; - $nextRoundMatches = false; - foreach ($assignedUsers as $user) + $users = $this->getUsersService()->GetUsersAsDto(); + $assignedUsers = []; + foreach ($users as $user) { - if ($nextRoundMatches) + if (in_array($user->id, explode(',', $chore->assignment_config))) { - $nextExecutionUserId = $user->id; - break; - } - - if ($user->id == $lastDoneByUserId) - { - $nextRoundMatches = true; + $assignedUsers[] = $user; } } - // If nothing has matched, probably it was the last user in the sorted list -> the first one is the next one - if ($nextExecutionUserId == null) + $nextExecutionUserId = null; + if ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_RANDOM) { - $nextExecutionUserId = array_shift($assignedUsers)->id; + // Random assignment and only 1 user in the group? Well, ok - will be hard to guess the next one... + if (count($assignedUsers) == 1) + { + $nextExecutionUserId = array_shift($assignedUsers)->id; + } + else + { + $nextExecutionUserId = $assignedUsers[array_rand($assignedUsers)]->id; + } } - } - elseif ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_WHO_LEAST_DID_FIRST) - { - $row = $this->getDatabase()->chores_execution_users_statistics()->where('chore_id = :1', $choreId)->orderBy('execution_count')->limit(1)->fetch(); - if ($row != null) + elseif ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_IN_ALPHABETICAL_ORDER) { - $nextExecutionUserId = $row->user_id; + usort($assignedUsers, function ($a, $b) { + return strcmp($a->display_name, $b->display_name); + }); + + $nextRoundMatches = false; + foreach ($assignedUsers as $user) + { + if ($nextRoundMatches) + { + $nextExecutionUserId = $user->id; + break; + } + + if ($user->id == $lastDoneByUserId) + { + $nextRoundMatches = true; + } + } + + // If nothing has matched, probably it was the last user in the sorted list -> the first one is the next one + if ($nextExecutionUserId == null) + { + $nextExecutionUserId = array_shift($assignedUsers)->id; + } + } + elseif ($chore->assignment_type == self::CHORE_ASSIGNMENT_TYPE_WHO_LEAST_DID_FIRST) + { + $row = $this->getDatabase()->chores_execution_users_statistics()->where('chore_id = :1', $choreId)->orderBy('execution_count')->limit(1)->fetch(); + if ($row != null) + { + $nextExecutionUserId = $row->user_id; + } } } @@ -197,8 +205,6 @@ class ChoresService extends BaseService $logRow->save(); $lastInsertId = $this->getDatabase()->lastInsertId(); - $this->CalculateNextExecutionAssignment($choreId); - if ($chore->consume_product_on_execution == 1 && !empty($chore->product_id)) { $transactionId = uniqid(); @@ -212,6 +218,15 @@ class ChoresService extends BaseService ]); } + if (!empty($chore->rescheduled_next_execution_assigned_to_user_id)) + { + $chore->update([ + 'rescheduled_next_execution_assigned_to_user_id' => null + ]); + } + + $this->CalculateNextExecutionAssignment($choreId); + return $lastInsertId; } diff --git a/views/choresoverview.blade.php b/views/choresoverview.blade.php index 9a4fc9fb..212b1336 100644 --- a/views/choresoverview.blade.php +++ b/views/choresoverview.blade.php @@ -165,7 +165,7 @@