Added a new "adaptive" chore period type (closes #1495)

This commit is contained in:
Bernd Bestel 2022-02-10 18:06:33 +01:00
parent 091a93ff4e
commit 69a7ea6057
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
12 changed files with 147 additions and 13 deletions

View File

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

View File

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

View File

@ -29,3 +29,6 @@ msgstr ""
msgid "hourly"
msgstr ""
msgid "adaptive"
msgstr ""

View File

@ -30,3 +30,6 @@ msgstr "Yearly"
msgid "hourly"
msgstr "Hourly"
msgid "adaptive"
msgstr "Adaptive"

View File

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

98
migrations/0167.sql Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -188,11 +188,7 @@
@if(GROCY_FEATURE_FLAG_CHORES_ASSIGNMENTS)
<div class="form-group">
<label for="assignment_type">{{ $__t('Assignment type') }} <i id="chore-assignment-type-info"
class="fas fa-question-circle text-muted"
data-toggle="tooltip"
data-trigger="hover click"
title=""></i></label>
<label for="assignment_type">{{ $__t('Assignment type') }}</label>
<select required
class="custom-control custom-select input-group-chore-assignment-type"
id="assignment_type"
@ -223,6 +219,9 @@
</select>
<div class="invalid-feedback">{{ $__t('This assignment type requires that at least one is assigned') }}</div>
</div>
<p id="chore-assignment-type-info"
class="form-text text-info mt-n2"></p>
@else
<input type="hidden"
id="assignment_type"

View File

@ -28,6 +28,7 @@
<strong>{{ $__t('Tracked count') }}:</strong> <span id="chorecard-chore-tracked-count"
class="locale-number locale-number-generic"></span><br>
<strong>{{ $__t('Average execution frequency') }}:</strong> <span id="chorecard-average-execution-frequency"></span><br>
<strong>{{ $__t('Last tracked') }}:</strong> <span id="chorecard-chore-last-tracked"></span> <time id="chorecard-chore-last-tracked-timeago"
class="timeago timeago-contextual"></time><br>
@if(GROCY_FEATURE_FLAG_CHORES_ASSIGNMENTS)