From 69a7ea60573db70c6dcdf864fa7723f6a3649431 Mon Sep 17 00:00:00 2001 From: Bernd Bestel Date: Thu, 10 Feb 2022 18:06:33 +0100 Subject: [PATCH] Added a new "adaptive" chore period type (closes #1495) --- changelog/66_UNRELEASED_xxxx-xx-xx.md | 5 +- grocy.openapi.json | 4 ++ localization/chore_period_types.pot | 3 + localization/en/chore_period_types.po | 3 + localization/strings.pot | 6 ++ migrations/0167.sql | 98 ++++++++++++++++++++++++++ public/viewjs/choreform.js | 12 ++-- public/viewjs/components/chorecard.js | 9 +++ services/ChoresService.php | 6 +- views/choreform.blade.php | 9 ++- views/components/chorecard.blade.php | 1 + views/components/productcard.blade.php | 4 +- 12 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 migrations/0167.sql diff --git a/changelog/66_UNRELEASED_xxxx-xx-xx.md b/changelog/66_UNRELEASED_xxxx-xx-xx.md index 001e3753..0fb05821 100644 --- a/changelog/66_UNRELEASED_xxxx-xx-xx.md +++ b/changelog/66_UNRELEASED_xxxx-xx-xx.md @@ -47,12 +47,14 @@ - Chore schedules can now be skipped - New button on the chores overview and chore tracking page - Skipped schedules will be highlighted accordingly on the chore journal +- The chorecard now also shows the average execution frequency (how often the chore was executed in the past on average) - Added a new chore option "Start date" which is used as a schedule starting point when the chore was never tracked - Until now, the schedule starting point was the first tracked execution - For all existing chores, the start date will be set to the first tracked execution time (or today, for chores which were never tracked) on migration - The `Yearly` period type has been changed to be schedule the chore on the _same day_ each year - - This period type scheduled chores 1 year _after the last execution_ before, which is also possible by using the `Daily` period type and a period interval of 365 days - All existing `Yearly` schedules will be converted to that on migration + - This period type scheduled chores 1 year _after the last execution_ before, which is also possible by using the `Daily` period type and a period interval of 365 days; all existing `Yearly` schedules will be converted to that on migration - Added a new `Hourly` period type (to schedule chores every `x` hours) +- Added a new `Adaptive` period type (to schedule chores dynamically based on the past average execution frequency) - Removed the period type `Dynamic regular`, since it's the same as `Daily` - All existing `Dynamic regular` schedules will be converted to that on migration @@ -92,4 +94,5 @@ - The API endpoint `/stock/shoppinglist/clear` has now a new optional request body parameter `done_only` (to only remove done items from the given shopping list, defaults to `false`) - The API endpoint `/chores/{choreId}/execute` has now a new optional request body parameter `skipped` (to skip the next chore schedule, defaults to `false`) +- The API endpoint `/chores/{choreId}` has new response field/property `average_execution_frequency_hours` (contains the average past execution frequency in hours or `null`, when the chore was never executed before) - Fixed that the barcode lookup for the "Stock by-barcode" API endpoints was case sensitive diff --git a/grocy.openapi.json b/grocy.openapi.json index 5f3d186a..4fd689b5 100644 --- a/grocy.openapi.json +++ b/grocy.openapi.json @@ -4806,6 +4806,10 @@ }, "next_execution_assigned_user": { "$ref": "#/components/schemas/UserDto" + }, + "average_execution_frequency_hours": { + "type": "integer", + "description": "Contains the average past execution frequency in hours or `null`, when the chore was never executed before" } }, "example": { diff --git a/localization/chore_period_types.pot b/localization/chore_period_types.pot index ff368b5f..6328342a 100644 --- a/localization/chore_period_types.pot +++ b/localization/chore_period_types.pot @@ -29,3 +29,6 @@ msgstr "" msgid "hourly" msgstr "" + +msgid "adaptive" +msgstr "" diff --git a/localization/en/chore_period_types.po b/localization/en/chore_period_types.po index 25a8a4a8..aea63799 100644 --- a/localization/en/chore_period_types.po +++ b/localization/en/chore_period_types.po @@ -30,3 +30,6 @@ msgstr "Yearly" msgid "hourly" msgstr "Hourly" + +msgid "adaptive" +msgstr "Adaptive" diff --git a/localization/strings.pot b/localization/strings.pot index 718aefea..8d02bc23 100644 --- a/localization/strings.pot +++ b/localization/strings.pot @@ -2302,3 +2302,9 @@ msgstr "" msgid "Show the recipe list and the recipe side by side" msgstr "" + +msgid "This means the next execution of this chore is scheduled dynamically based on the past average execution frequency" +msgstr "" + +msgid "Average execution frequency" +msgstr "" diff --git a/migrations/0167.sql b/migrations/0167.sql new file mode 100644 index 00000000..3a3a9308 --- /dev/null +++ b/migrations/0167.sql @@ -0,0 +1,98 @@ +CREATE INDEX ix_chores_log_performance1 ON chores_log ( + id, + undone, + tracked_time +); + +CREATE VIEW chores_execution_timeline +AS + +SELECT + cl.chore_id, + cl.tracked_time, + (SELECT tracked_time FROM chores_log WHERE chore_id = cl.chore_id AND undone = 0 AND tracked_time < cl.tracked_time ORDER BY tracked_time DESC LIMIT 1) AS tracked_time_before, + CAST((JULIANDAY(cl.tracked_time) - JULIANDAY((SELECT tracked_time FROM chores_log WHERE chore_id = cl.chore_id AND undone = 0 AND tracked_time < cl.tracked_time ORDER BY tracked_time DESC LIMIT 1))) * 24 AS INT) AS frequency_hours +FROM chores_log cl +WHERE cl.undone = 0; + +CREATE VIEW chores_execution_average_frequency +AS + +SELECT + cet.chore_id, + AVG(cet.frequency_hours) AS average_frequency_hours +FROM chores_execution_timeline cet +GROUP BY cet.chore_id; + +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 +FROM ( + +SELECT + h.id AS chore_id, + h.name AS chore_name, + MAX(l.tracked_time) AS last_tracked_time, + CASE WHEN MAX(l.tracked_time) IS NULL THEN + h.start_date + ELSE + CASE h.period_type + WHEN 'manually' THEN '2999-12-31 23:59:59' + WHEN 'hourly' THEN DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' hour') + WHEN 'daily' THEN DATETIME(MAX(l.tracked_time), '+' || CAST(h.period_interval AS TEXT) || ' day') + 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 AS next_estimated_execution_time, + h.track_date_only, + h.rollover, + h.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/choreform.js b/public/viewjs/choreform.js index f64f0203..66d45874 100644 --- a/public/viewjs/choreform.js +++ b/public/viewjs/choreform.js @@ -176,6 +176,10 @@ $('.input-group-chore-period-type').on('change keyup', function(e) { $('#chore-schedule-info').text(__n(periodInterval, 'This means the next execution of this chore is scheduled every year on the same day (based on the start date)', 'This means the next execution of this chore is scheduled every %s years on the same day (based on the start date)')); } + else if (periodType === 'adaptive') + { + $('#chore-schedule-info').text(__t('This means the next execution of this chore is scheduled dynamically based on the past average execution frequency')); + } Grocy.FrontendHelpers.ValidateForm('chore-form'); }); @@ -190,23 +194,23 @@ $('.input-group-chore-assignment-type').on('change', function(e) if (assignmentType === 'no-assignment') { - $('#chore-assignment-type-info').attr("data-original-title", __t('This means the next execution of this chore will not be assigned to anyone')); + $('#chore-assignment-type-info').text(__t('This means the next execution of this chore will not be assigned to anyone')); } else if (assignmentType === 'who-least-did-first') { - $('#chore-assignment-type-info').attr("data-original-title", __t('This means the next execution of this chore will be assigned to the one who executed it least')); + $('#chore-assignment-type-info').text(__t('This means the next execution of this chore will be assigned to the one who executed it least')); $("#assignment_config").attr("required", ""); $("#assignment_config").removeAttr("disabled"); } else if (assignmentType === 'random') { - $('#chore-assignment-type-info').attr("data-original-title", __t('This means the next execution of this chore will be assigned randomly')); + $('#chore-assignment-type-info').text(__t('This means the next execution of this chore will be assigned randomly')); $("#assignment_config").attr("required", ""); $("#assignment_config").removeAttr("disabled"); } else if (assignmentType === 'in-alphabetical-order') { - $('#chore-assignment-type-info').attr("data-original-title", __t('This means the next execution of this chore will be assigned to the next one in alphabetical order')); + $('#chore-assignment-type-info').text(__t('This means the next execution of this chore will be assigned to the next one in alphabetical order')); $("#assignment_config").attr("required", ""); $("#assignment_config").removeAttr("disabled"); } diff --git a/public/viewjs/components/chorecard.js b/public/viewjs/components/chorecard.js index 780932bb..ca4a38a1 100644 --- a/public/viewjs/components/chorecard.js +++ b/public/viewjs/components/chorecard.js @@ -31,6 +31,15 @@ Grocy.Components.ChoreCard.Refresh = function(choreId) $("#chorecard-chore-last-tracked-timeago").removeClass("timeago-date-only"); } + if (choreDetails.average_execution_frequency_hours == null) + { + $('#chorecard-average-execution-frequency').text(__t("Unknown")); + } + else + { + $('#chorecard-average-execution-frequency').text(moment.duration(parseInt(choreDetails.average_execution_frequency_hours) / 24, "days").humanize()); + } + RefreshContextualTimeago(".chorecard"); }, function(xhr) diff --git a/services/ChoresService.php b/services/ChoresService.php index 8589239e..bbff0aea 100644 --- a/services/ChoresService.php +++ b/services/ChoresService.php @@ -24,6 +24,8 @@ class ChoresService extends BaseService const CHORE_PERIOD_TYPE_YEARLY = 'yearly'; + const CHORE_PERIOD_TYPE_ADAPTIVE = 'adaptive'; + public function CalculateNextExecutionAssignment($choreId) { if (!$this->ChoreExists($choreId)) @@ -113,6 +115,7 @@ class ChoresService extends BaseService $choreTrackedCount = $this->getDatabase()->chores_log()->where('chore_id = :1 AND undone = 0 AND skipped = 0', $choreId)->count(); $choreLastTrackedTime = $this->getDatabase()->chores_log()->where('chore_id = :1 AND undone = 0 AND skipped = 0', $choreId)->max('tracked_time'); $nextExecutionTime = $this->getDatabase()->chores_current()->where('chore_id', $choreId)->min('next_estimated_execution_time'); + $averageExecutionFrequency = $this->getDatabase()->chores_execution_average_frequency()->where('chore_id', $choreId)->min('average_frequency_hours'); $lastChoreLogRow = $this->getDatabase()->chores_log()->where('chore_id = :1 AND tracked_time = :2 AND undone = 0', $choreId, $choreLastTrackedTime)->fetch(); $lastDoneByUser = null; @@ -133,7 +136,8 @@ class ChoresService extends BaseService 'tracked_count' => $choreTrackedCount, 'last_done_by' => $lastDoneByUser, 'next_estimated_execution_time' => $nextExecutionTime, - 'next_execution_assigned_user' => $nextExecutionAssignedUser + 'next_execution_assigned_user' => $nextExecutionAssignedUser, + 'average_execution_frequency_hours' => $averageExecutionFrequency ]; } diff --git a/views/choreform.blade.php b/views/choreform.blade.php index 55284c55..56b2f8cb 100644 --- a/views/choreform.blade.php +++ b/views/choreform.blade.php @@ -188,11 +188,7 @@ @if(GROCY_FEATURE_FLAG_CHORES_ASSIGNMENTS)
- + {{ $__t('Tracked count') }}:
+ {{ $__t('Average execution frequency') }}:
{{ $__t('Last tracked') }}:
@if(GROCY_FEATURE_FLAG_CHORES_ASSIGNMENTS) diff --git a/views/components/productcard.blade.php b/views/components/productcard.blade.php index e68d4290..ced05336 100644 --- a/views/components/productcard.blade.php +++ b/views/components/productcard.blade.php @@ -54,8 +54,8 @@ {{ $__t('Last used') }}:
@if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Last price') }}:
@endif - @if (GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Average price') }}:
@endif - @if (GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING){{ $__t('Average shelf life') }}:
@endif + @if(GROCY_FEATURE_FLAG_STOCK_PRICE_TRACKING){{ $__t('Average price') }}:
@endif + @if(GROCY_FEATURE_FLAG_STOCK_BEST_BEFORE_DATE_TRACKING){{ $__t('Average shelf life') }}:
@endif {{ $__t('Spoil rate') }}: