Make it possible to manually re-assign chores (closes #1492, references #1830)

This commit is contained in:
Bernd Bestel 2022-04-03 13:56:14 +02:00
parent a5294262e6
commit 3efecb8bed
No known key found for this signature in database
GPG Key ID: 71BD34C0D4891300
8 changed files with 232 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

83
migrations/0185.sql Normal file
View File

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

View File

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

View File

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

View File

@ -165,7 +165,7 @@
<i class="fas fa-ellipsis-v"></i>
</button>
<div class="table-inline-menu dropdown-menu dropdown-menu-right">
<a class="dropdown-item reschedule-chore-button @if(FindObjectInArrayByPropertyValue($chores, 'id', $curentChoreEntry->chore_id)->period_type == \Grocy\Services\ChoresService::CHORE_PERIOD_TYPE_MANUALLY) disabled @endif"
<a class="dropdown-item reschedule-chore-button"
data-chore-id="{{ $curentChoreEntry->chore_id }}"
type="button"
href="#">
@ -210,16 +210,18 @@
{{ FindObjectInArrayByPropertyValue($chores, 'id', $curentChoreEntry->chore_id)->name }}
</td>
<td>
@if(FindObjectInArrayByPropertyValue($chores, 'id', $curentChoreEntry->chore_id)->period_type !== \Grocy\Services\ChoresService::CHORE_PERIOD_TYPE_MANUALLY)
@if(!empty($curentChoreEntry->next_estimated_execution_time))
<span id="chore-{{ $curentChoreEntry->chore_id }}-next-execution-time">{{ $curentChoreEntry->next_estimated_execution_time }}</span>
<time id="chore-{{ $curentChoreEntry->chore_id }}-next-execution-time-timeago"
class="timeago timeago-contextual @if($curentChoreEntry->track_date_only == 1) timeago-date-only @endif"
datetime="{{ $curentChoreEntry->next_estimated_execution_time }}"></time>
@else
<span>-</span>
<span id="chore-{{ $curentChoreEntry->chore_id }}-next-execution-time">-</span>
<time id="chore-{{ $curentChoreEntry->chore_id }}-next-execution-time-timeago"
class="timeago timeago-contextual @if($curentChoreEntry->track_date_only == 1) timeago-date-only @endif"></time>
@endif
@if($curentChoreEntry->is_rescheduled == 1)
<span id="chore-{{ $curentChoreEntry->chore_id }}-reschedule-icon"
<span id="chore-{{ $curentChoreEntry->chore_id }}-rescheduled-icon"
class="text-muted"
data-toggle="tooltip"
title="{{ $__t('Rescheduled') }}">
@ -228,10 +230,16 @@
@endif
</td>
<td>
@if(!empty($curentChoreEntry->last_tracked_time))
<span id="chore-{{ $curentChoreEntry->chore_id }}-last-tracked-time">{{ $curentChoreEntry->last_tracked_time }}</span>
<time id="chore-{{ $curentChoreEntry->chore_id }}-last-tracked-time-timeago"
class="timeago timeago-contextual @if($curentChoreEntry->track_date_only == 1) timeago-date-only @endif"
datetime="{{ $curentChoreEntry->last_tracked_time }}"></time>
@else
<span id="chore-{{ $curentChoreEntry->chore_id }}-last-tracked-time">-</span>
<time id="chore-{{ $curentChoreEntry->chore_id }}-last-tracked-time-timeago"
class="timeago timeago-contextual @if($curentChoreEntry->track_date_only == 1) timeago-date-only @endif"></time>
@endif
</td>
<td class="@if(!GROCY_FEATURE_FLAG_CHORES_ASSIGNMENTS) d-none @endif">
@ -241,6 +249,14 @@
@else
<span>-</span>
@endif
@if($curentChoreEntry->is_reassigned == 1)
<span id="chore-{{ $curentChoreEntry->chore_id }}-reassigned-icon"
class="text-muted"
data-toggle="tooltip"
title="{{ $__t('Reassigned') }}">
<i class="fas fa-exchange-alt"></i>
</span>
@endif
</span>
</td>
<td id="chore-{{ $curentChoreEntry->chore_id }}-due-filter-column"
@ -309,6 +325,11 @@
'invalidFeedback' => $__t('This can only be in the future')
))
@include('components.userpicker', array(
'label' => 'Assigned to',
'users' => $users
))
</form>
</div>
<div class="modal-footer">