diff --git a/lib/BackgroundJob/ReopenRecurringItemsJob.php b/lib/BackgroundJob/ReopenRecurringItemsJob.php index 2bfd341..3422c28 100644 --- a/lib/BackgroundJob/ReopenRecurringItemsJob.php +++ b/lib/BackgroundJob/ReopenRecurringItemsJob.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace OCA\Pantry\BackgroundJob; +use OCA\Pantry\Db\ChecklistItem; use OCA\Pantry\Service\ChecklistService; use OCA\Pantry\Service\NotificationService; use OCP\AppFramework\Utility\ITimeFactory; @@ -14,8 +15,10 @@ use OCP\BackgroundJob\TimedJob; use Psr\Log\LoggerInterface; /** - * Periodically reopens recurring checklist items whose next_due_at has - * passed, so they appear unchecked without waiting for a user to open the list. + * Periodically processes recurring checklist items: + * 1. Reopens done items whose next_due_at has passed. + * 2. Re-notifies for undone fixed-schedule items whose next_due_at has passed + * (the item is already visible but the schedule ticked again — nudge). */ class ReopenRecurringItemsJob extends TimedJob { public function __construct( @@ -31,23 +34,53 @@ class ReopenRecurringItemsJob extends TimedJob { } protected function run(mixed $argument): void { + // 1. Reopen done items whose schedule has passed. $reopened = $this->lists->reopenDueItems(); if (count($reopened) > 0) { $this->logger->info('Pantry: reopened {count} recurring item(s)', ['count' => count($reopened)]); - foreach ($reopened as $item) { - try { - $list = $this->lists->getList($item->getListId()); - $this->notifications->notifyItemRecurred( + $this->notifyGrouped($reopened, 'recurred'); + } + + // 2. Nudge for undone fixed-schedule items whose schedule ticked again. + $reminded = $this->lists->advanceDueReminders(); + if (count($reminded) > 0) { + $this->logger->info('Pantry: sent reminders for {count} undone item(s)', ['count' => count($reminded)]); + $this->notifyGrouped($reminded, 'reminder'); + } + } + + /** + * Group items by list and send one notification per list. + * + * @param ChecklistItem[] $items + */ + private function notifyGrouped(array $items, string $type): void { + $byList = []; + foreach ($items as $item) { + $byList[$item->getListId()][] = $item->getName(); + } + foreach ($byList as $listId => $itemNames) { + try { + $list = $this->lists->getList($listId); + if ($type === 'recurred') { + $this->notifications->notifyItemsRecurred( $list->getHouseId(), - $item->getName(), + $itemNames, + $list->getName(), + ); + } else { + $this->notifications->notifyItemsReminder( + $list->getHouseId(), + $itemNames, $list->getName(), ); - } catch (\Throwable $e) { - $this->logger->warning('Pantry: failed to notify for recurring item {id}: {msg}', [ - 'id' => $item->getId(), - 'msg' => $e->getMessage(), - ]); } + } catch (\Throwable $e) { + $this->logger->warning('Pantry: failed to notify for {type} items in list {id}: {msg}', [ + 'type' => $type, + 'id' => $listId, + 'msg' => $e->getMessage(), + ]); } } } diff --git a/lib/Controller/ChecklistController.php b/lib/Controller/ChecklistController.php index 26bfeb0..318d4ea 100644 --- a/lib/Controller/ChecklistController.php +++ b/lib/Controller/ChecklistController.php @@ -208,7 +208,7 @@ final class ChecklistController extends OCSController { * @param int|null $categoryId Optional category id (must belong to the same house). * @param string|null $quantity Optional quantity string. * @param string|null $rrule Optional RFC 5545 RRULE for recurrence. - * @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked bought; if false, the schedule is anchored at item creation. + * @param bool $repeatFromCompletion If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation. * @param int|null $sortOrder Optional sort order. * * @return DataResponse @@ -320,7 +320,7 @@ final class ChecklistController extends OCSController { } /** - * Toggle an item's bought status + * Toggle an item's done status * * @param int $houseId House id. * @param int $listId List id. @@ -343,6 +343,9 @@ final class ChecklistController extends OCSController { throw new NotFoundException('Item does not belong to this list'); } $toggled = $this->lists->toggleItem($itemId, $uid); + if ($toggled->getDone()) { + $this->notifications->notifyItemDone($houseId, $uid, $toggled->getName(), $list->getName()); + } return new DataResponse($toggled->jsonSerialize()); }); } diff --git a/lib/Controller/PrefsController.php b/lib/Controller/PrefsController.php index 56b9935..fbf3e56 100644 --- a/lib/Controller/PrefsController.php +++ b/lib/Controller/PrefsController.php @@ -153,6 +153,7 @@ final class PrefsController extends OCSController { * @param bool|null $notifyNoteEdit Note edit notifications. * @param bool|null $notifyItemAdd Checklist item added notifications. * @param bool|null $notifyItemRecur Recurring item reappeared notifications. + * @param bool|null $notifyItemDone Item completed notifications. * * @return DataResponse * @@ -160,8 +161,8 @@ final class PrefsController extends OCSController { */ #[ApiRoute(verb: 'PUT', url: '/api/houses/{houseId}/prefs/notifications')] #[NoAdminRequired] - public function setNotificationPrefs(int $houseId, ?bool $notifyPhoto = null, ?bool $notifyNoteCreate = null, ?bool $notifyNoteEdit = null, ?bool $notifyItemAdd = null, ?bool $notifyItemRecur = null): DataResponse { - return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit, $notifyItemAdd, $notifyItemRecur): DataResponse { + public function setNotificationPrefs(int $houseId, ?bool $notifyPhoto = null, ?bool $notifyNoteCreate = null, ?bool $notifyNoteEdit = null, ?bool $notifyItemAdd = null, ?bool $notifyItemRecur = null, ?bool $notifyItemDone = null): DataResponse { + return $this->runAction(function () use ($houseId, $notifyPhoto, $notifyNoteCreate, $notifyNoteEdit, $notifyItemAdd, $notifyItemRecur, $notifyItemDone): DataResponse { $uid = $this->requireUid(); $this->auth->requireMember($houseId, $uid); if ($notifyPhoto !== null) { @@ -179,6 +180,9 @@ final class PrefsController extends OCSController { if ($notifyItemRecur !== null) { $this->prefs->setNotificationPref($uid, $houseId, 'notify_item_recur', $notifyItemRecur); } + if ($notifyItemDone !== null) { + $this->prefs->setNotificationPref($uid, $houseId, 'notify_item_done', $notifyItemDone); + } return new DataResponse($this->prefs->getNotificationPrefs($uid, $houseId)); }); } diff --git a/lib/Db/ChecklistItem.php b/lib/Db/ChecklistItem.php index e46f94c..65d9e16 100644 --- a/lib/Db/ChecklistItem.php +++ b/lib/Db/ChecklistItem.php @@ -18,12 +18,12 @@ use OCP\AppFramework\Db\Entity; * @method void setCategoryId(?int $categoryId) * @method string|null getQuantity() * @method void setQuantity(?string $quantity) - * @method bool getBought() - * @method void setBought(bool $bought) - * @method int|null getBoughtAt() - * @method void setBoughtAt(?int $boughtAt) - * @method string|null getBoughtBy() - * @method void setBoughtBy(?string $boughtBy) + * @method bool getDone() + * @method void setDone(bool $done) + * @method int|null getDoneAt() + * @method void setDoneAt(?int $doneAt) + * @method string|null getDoneBy() + * @method void setDoneBy(?string $doneBy) * @method string|null getRrule() * @method void setRrule(?string $rrule) * @method bool getRepeatFromCompletion() @@ -44,9 +44,9 @@ class ChecklistItem extends Entity implements \JsonSerializable { protected string $name = ''; protected ?int $categoryId = null; protected ?string $quantity = null; - protected bool $bought = false; - protected ?int $boughtAt = null; - protected ?string $boughtBy = null; + protected bool $done = false; + protected ?int $doneAt = null; + protected ?string $doneBy = null; protected ?string $rrule = null; protected bool $repeatFromCompletion = false; protected ?int $nextDueAt = null; @@ -58,8 +58,8 @@ class ChecklistItem extends Entity implements \JsonSerializable { public function __construct() { $this->addType('listId', 'integer'); $this->addType('categoryId', 'integer'); - $this->addType('bought', 'boolean'); - $this->addType('boughtAt', 'integer'); + $this->addType('done', 'boolean'); + $this->addType('doneAt', 'integer'); $this->addType('repeatFromCompletion', 'boolean'); $this->addType('nextDueAt', 'integer'); $this->addType('imageFileId', 'integer'); @@ -70,7 +70,7 @@ class ChecklistItem extends Entity implements \JsonSerializable { // match the initial value, so the magic setter would otherwise never // mark them dirty and the column would be omitted from the INSERT. // fromRow() resets updated fields after hydration, so reads are unaffected. - $this->markFieldUpdated('bought'); + $this->markFieldUpdated('done'); $this->markFieldUpdated('repeatFromCompletion'); } @@ -81,9 +81,9 @@ class ChecklistItem extends Entity implements \JsonSerializable { 'name' => $this->name, 'categoryId' => $this->categoryId, 'quantity' => $this->quantity, - 'bought' => $this->bought, - 'boughtAt' => $this->boughtAt, - 'boughtBy' => $this->boughtBy, + 'done' => $this->done, + 'doneAt' => $this->doneAt, + 'doneBy' => $this->doneBy, 'rrule' => $this->rrule, 'repeatFromCompletion' => $this->repeatFromCompletion, 'nextDueAt' => $this->nextDueAt, diff --git a/lib/Db/ChecklistItemMapper.php b/lib/Db/ChecklistItemMapper.php index 5866761..c9afd86 100644 --- a/lib/Db/ChecklistItemMapper.php +++ b/lib/Db/ChecklistItemMapper.php @@ -48,7 +48,7 @@ class ChecklistItemMapper extends QBMapper { } /** - * Find all bought items whose next_due_at has passed. + * Find all done items whose next_due_at has passed. * * @return ChecklistItem[] */ @@ -56,7 +56,26 @@ class ChecklistItemMapper extends QBMapper { $qb = $this->db->getQueryBuilder(); $qb->select('*') ->from($this->getTableName()) - ->where($qb->expr()->eq('bought', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->where($qb->expr()->eq('done', $qb->createNamedParameter(true, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNotNull('next_due_at')) + ->andWhere($qb->expr()->lte('next_due_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))); + + return $this->findEntities($qb); + } + + /** + * Find undone fixed-schedule items whose next_due_at has passed. + * These are items the user never checked off but whose schedule has ticked again. + * + * @return ChecklistItem[] + */ + public function findDueFixedScheduleUndone(int $now): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('done', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->eq('repeat_from_completion', $qb->createNamedParameter(false, IQueryBuilder::PARAM_BOOL))) + ->andWhere($qb->expr()->isNotNull('rrule')) ->andWhere($qb->expr()->isNotNull('next_due_at')) ->andWhere($qb->expr()->lte('next_due_at', $qb->createNamedParameter($now, IQueryBuilder::PARAM_INT))); diff --git a/lib/Migration/Version1Date20260405000000.php b/lib/Migration/Version1Date20260405000000.php index 319cfd0..98369ce 100644 --- a/lib/Migration/Version1Date20260405000000.php +++ b/lib/Migration/Version1Date20260405000000.php @@ -194,14 +194,14 @@ class Version1Date20260405000000 extends SimpleMigrationStep { 'notnull' => false, 'length' => 64, ]); - $table->addColumn('bought', Types::BOOLEAN, [ + $table->addColumn('done', Types::BOOLEAN, [ 'notnull' => false, ]); - $table->addColumn('bought_at', Types::BIGINT, [ + $table->addColumn('done_at', Types::BIGINT, [ 'notnull' => false, 'length' => 20, ]); - $table->addColumn('bought_by', Types::STRING, [ + $table->addColumn('done_by', Types::STRING, [ 'notnull' => false, 'length' => 64, ]); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index 6c1eb8d..f5ecc98 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -127,10 +127,15 @@ class Notifier implements INotifier { ); break; - case 'item_recurred': + case 'item_done': $notification->setRichSubject( - $l->t('"{item}" is back on {list} in {house}'), + $l->t('{user} completed "{item}" on {list} in {house}'), [ + 'user' => [ + 'type' => 'user', + 'id' => $params['userId'] ?? '', + 'name' => $params['userDisplayName'] ?? '', + ], 'item' => [ 'type' => 'highlight', 'id' => 'item', @@ -150,6 +155,62 @@ class Notifier implements INotifier { ); break; + case 'item_reminder': + $names = $params['itemNames'] ?? []; + $count = (int)($params['itemCount'] ?? count($names)); + $reminderLabel = $count <= 3 + ? implode(', ', $names) + : $l->n('%n item', '%n items', $count); + $notification->setRichSubject( + $l->t('{items} still undone on {list} in {house}'), + [ + 'items' => [ + 'type' => 'highlight', + 'id' => 'items', + 'name' => $reminderLabel, + ], + 'list' => [ + 'type' => 'highlight', + 'id' => 'list', + 'name' => $params['listName'] ?? '', + ], + 'house' => [ + 'type' => 'highlight', + 'id' => (string)($params['houseId'] ?? ''), + 'name' => $params['houseName'] ?? '', + ], + ] + ); + break; + + case 'item_recurred': + $names = $params['itemNames'] ?? []; + $count = (int)($params['itemCount'] ?? count($names)); + $itemLabel = $count <= 3 + ? implode(', ', $names) + : $l->n('%n item', '%n items', $count); + $notification->setRichSubject( + $l->t('{items} back on {list} in {house}'), + [ + 'items' => [ + 'type' => 'highlight', + 'id' => 'items', + 'name' => $itemLabel, + ], + 'list' => [ + 'type' => 'highlight', + 'id' => 'list', + 'name' => $params['listName'] ?? '', + ], + 'house' => [ + 'type' => 'highlight', + 'id' => (string)($params['houseId'] ?? ''), + 'name' => $params['houseName'] ?? '', + ], + ] + ); + break; + default: throw new \InvalidArgumentException('Unknown notification'); } diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 431efef..4dd1962 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -44,9 +44,9 @@ namespace OCA\Pantry; * name: string, * categoryId: int|null, * quantity: string|null, - * bought: bool, - * boughtAt: int|null, - * boughtBy: string|null, + * done: bool, + * doneAt: int|null, + * doneBy: string|null, * rrule: string|null, * repeatFromCompletion: bool, * nextDueAt: int|null, @@ -79,6 +79,7 @@ namespace OCA\Pantry; * notifyNoteEdit: bool, * notifyItemAdd: bool, * notifyItemRecur: bool, + * notifyItemDone: bool, * } * * @psalm-type PantryPhotoFolder = array{ diff --git a/lib/Service/ChecklistService.php b/lib/Service/ChecklistService.php index d7f4d46..9fda085 100644 --- a/lib/Service/ChecklistService.php +++ b/lib/Service/ChecklistService.php @@ -136,12 +136,18 @@ class ChecklistService { $item->setName($name); $item->setCategoryId($this->intOrNull($data['categoryId'] ?? null)); $item->setQuantity($this->strOrNull($data['quantity'] ?? null)); - $item->setBought(false); - $item->setBoughtAt(null); - $item->setBoughtBy(null); + $item->setDone(false); + $item->setDoneAt(null); + $item->setDoneBy(null); $item->setRrule($rrule); - $item->setRepeatFromCompletion(!empty($data['repeatFromCompletion'])); - $item->setNextDueAt(null); + $repeatFromCompletion = !empty($data['repeatFromCompletion']); + $item->setRepeatFromCompletion($repeatFromCompletion); + // For fixed-schedule items, compute the first due time immediately. + if ($rrule !== null && !$repeatFromCompletion) { + $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); + } else { + $item->setNextDueAt(null); + } $item->setImageFileId($this->intOrNull($data['imageFileId'] ?? null)); $item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0); $item->setCreatedAt($now); @@ -172,7 +178,7 @@ class ChecklistService { if ($rrule === null || (is_string($rrule) && trim($rrule) === '')) { $item->setRrule(null); // Clearing recurrence also clears any scheduled re-open. - if ($item->getBought()) { + if ($item->getDone()) { $item->setNextDueAt(null); } } else { @@ -187,8 +193,8 @@ class ChecklistService { if (array_key_exists('imageFileId', $patch)) { $item->setImageFileId($this->intOrNull($patch['imageFileId'])); } - // If already bought and rrule or mode changed, recompute next due. - if ($item->getBought() && $item->getRrule() !== null + // If already done and rrule or mode changed, recompute next due. + if ($item->getDone() && $item->getRrule() !== null && (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) { $item->setNextDueAt($this->computeNextDueAt($item, time())?->getTimestamp()); } @@ -205,17 +211,17 @@ class ChecklistService { $item = $this->getItem($itemId); $now ??= time(); - if (!$item->getBought()) { - $item->setBought(true); - $item->setBoughtAt($now); - $item->setBoughtBy($uid); + if (!$item->getDone()) { + $item->setDone(true); + $item->setDoneAt($now); + $item->setDoneBy($uid); if ($item->getRrule() !== null) { $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); } } else { - $item->setBought(false); - $item->setBoughtAt(null); - $item->setBoughtBy(null); + $item->setDone(false); + $item->setDoneAt(null); + $item->setDoneBy(null); $item->setNextDueAt(null); } $item->setUpdatedAt($now); @@ -224,7 +230,7 @@ class ChecklistService { } /** - * Compute the next due time for an item that was just marked bought. + * Compute the next due time for an item that was just marked done. * * - "from completion" mode: interval counts from now — next occurrence = now + one step. * - "fixed schedule" mode: the schedule is anchored at the item's creation time; next @@ -254,9 +260,9 @@ class ChecklistService { $now ??= time(); $items = $this->itemMapper->findDueRecurring($now); foreach ($items as $item) { - $item->setBought(false); - $item->setBoughtAt(null); - $item->setBoughtBy(null); + $item->setDone(false); + $item->setDoneAt(null); + $item->setDoneBy(null); if ($item->getRepeatFromCompletion()) { // Completion-based: next interval starts when the user checks // the item off again, so clear the schedule for now. @@ -272,6 +278,26 @@ class ChecklistService { return $items; } + /** + * Advance fixed-schedule undone items whose next_due_at has passed. + * + * Unlike reopenDueItems(), these items are already undone — nothing to + * reopen. We just bump next_due_at to the next occurrence so the + * background job can re-notify on the next cycle. + * + * @return ChecklistItem[] The items whose schedule was advanced. + */ + public function advanceDueReminders(?int $now = null): array { + $now ??= time(); + $items = $this->itemMapper->findDueFixedScheduleUndone($now); + foreach ($items as $item) { + $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); + $item->setUpdatedAt($now); + $this->itemMapper->update($item); + } + return $items; + } + public function deleteItem(int $itemId): void { $item = $this->getItem($itemId); $this->itemMapper->delete($item); diff --git a/lib/Service/NotificationService.php b/lib/Service/NotificationService.php index 5d095ad..8df35d7 100644 --- a/lib/Service/NotificationService.php +++ b/lib/Service/NotificationService.php @@ -19,6 +19,7 @@ class NotificationService { public const PREF_NOTIFY_NOTE_EDIT = 'notify_note_edit'; public const PREF_NOTIFY_ITEM_ADD = 'notify_item_add'; public const PREF_NOTIFY_ITEM_RECUR = 'notify_item_recur'; + public const PREF_NOTIFY_ITEM_DONE = 'notify_item_done'; public function __construct( private INotificationManager $notificationManager, @@ -88,14 +89,57 @@ class NotificationService { }); } - public function notifyItemRecurred(int $houseId, string $itemName, string $listName): void { - // No author to exclude — this is a system event. - $this->sendToHouseMembers($houseId, '', 'item_recurred', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemName, $listName) { + public function notifyItemDone(int $houseId, string $authorUid, string $itemName, string $listName): void { + $this->sendToHouseMembers($houseId, $authorUid, 'item_done', 'item', self::PREF_NOTIFY_ITEM_DONE, function () use ($houseId, $authorUid, $itemName, $listName) { + $house = $this->houseService->get($houseId); + $author = $this->userManager->get($authorUid); + return [ + 'userId' => $authorUid, + 'userDisplayName' => $author ? $author->getDisplayName() : $authorUid, + 'houseId' => $houseId, + 'houseName' => $house->getName(), + 'itemName' => $itemName, + 'listName' => $listName, + ]; + }); + } + + /** + * Notify that undone fixed-schedule items are due again (reminder nudge). + * + * @param string[] $itemNames Names of the items that are still undone. + */ + public function notifyItemsReminder(int $houseId, array $itemNames, string $listName): void { + if (empty($itemNames)) { + return; + } + $this->sendToHouseMembers($houseId, '', 'item_reminder', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemNames, $listName) { $house = $this->houseService->get($houseId); return [ 'houseId' => $houseId, 'houseName' => $house->getName(), - 'itemName' => $itemName, + 'itemNames' => $itemNames, + 'itemCount' => count($itemNames), + 'listName' => $listName, + ]; + }); + } + + /** + * @param string[] $itemNames Names of the items that recurred. + */ + public function notifyItemsRecurred(int $houseId, array $itemNames, string $listName): void { + if (empty($itemNames)) { + return; + } + // No author to exclude — this is a system event. + $this->sendToHouseMembers($houseId, '', 'item_recurred', 'item', self::PREF_NOTIFY_ITEM_RECUR, function () use ($houseId, $itemNames, $listName) { + $house = $this->houseService->get($houseId); + return [ + 'houseId' => $houseId, + 'houseName' => $house->getName(), + 'itemNames' => $itemNames, + 'itemCount' => count($itemNames), 'listName' => $listName, ]; }); diff --git a/lib/Service/PrefsService.php b/lib/Service/PrefsService.php index f46ddd0..b9df34d 100644 --- a/lib/Service/PrefsService.php +++ b/lib/Service/PrefsService.php @@ -83,6 +83,7 @@ class PrefsService { 'notifyNoteEdit' => $this->getNotificationPref($uid, $houseId, 'notify_note_edit'), 'notifyItemAdd' => $this->getNotificationPref($uid, $houseId, 'notify_item_add'), 'notifyItemRecur' => $this->getNotificationPref($uid, $houseId, 'notify_item_recur'), + 'notifyItemDone' => $this->getNotificationPref($uid, $houseId, 'notify_item_done'), ]; } diff --git a/openapi.json b/openapi.json index d59d17b..e1d4a8d 100644 --- a/openapi.json +++ b/openapi.json @@ -180,9 +180,9 @@ "name", "categoryId", "quantity", - "bought", - "boughtAt", - "boughtBy", + "done", + "doneAt", + "doneBy", "rrule", "repeatFromCompletion", "nextDueAt", @@ -212,15 +212,15 @@ "type": "string", "nullable": true }, - "bought": { + "done": { "type": "boolean" }, - "boughtAt": { + "doneAt": { "type": "integer", "format": "int64", "nullable": true }, - "boughtBy": { + "doneBy": { "type": "string", "nullable": true }, @@ -346,7 +346,8 @@ "notifyNoteCreate", "notifyNoteEdit", "notifyItemAdd", - "notifyItemRecur" + "notifyItemRecur", + "notifyItemDone" ], "properties": { "notifyPhoto": { @@ -363,6 +364,9 @@ }, "notifyItemRecur": { "type": "boolean" + }, + "notifyItemDone": { + "type": "boolean" } } }, @@ -1798,7 +1802,7 @@ "repeatFromCompletion": { "type": "boolean", "default": false, - "description": "If true, the next occurrence is measured from when the item is marked bought; if false, the schedule is anchored at item creation." + "description": "If true, the next occurrence is measured from when the item is marked done; if false, the schedule is anchored at item creation." }, "sortOrder": { "type": "integer", @@ -2202,7 +2206,7 @@ "/ocs/v2.php/apps/pantry/api/houses/{houseId}/lists/{listId}/items/{itemId}/toggle": { "post": { "operationId": "checklist-toggle-item", - "summary": "Toggle an item's bought status", + "summary": "Toggle an item's done status", "tags": [ "checklist" ], @@ -6127,6 +6131,12 @@ "nullable": true, "default": null, "description": "Recurring item reappeared notifications." + }, + "notifyItemDone": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "Item completed notifications." } } } diff --git a/src/api/prefs.ts b/src/api/prefs.ts index b9fa2a1..c7f5cc9 100644 --- a/src/api/prefs.ts +++ b/src/api/prefs.ts @@ -27,6 +27,7 @@ export interface NotificationPrefs { notifyNoteEdit: boolean notifyItemAdd: boolean notifyItemRecur: boolean + notifyItemDone: boolean } export async function getNotificationPrefs(houseId: number): Promise { @@ -38,6 +39,7 @@ export async function getNotificationPrefs(houseId: number): Promise { expect(getNotificationPrefs).toHaveBeenCalledWith(7) }) - it('renders five notification checkboxes', async () => { + it('renders six notification checkboxes', async () => { const wrapper = mountComponent({ open: true, houseId: 1 }) await flushPromises() const checkboxes = wrapper.findAll('.nc-checkbox') - expect(checkboxes).toHaveLength(5) + expect(checkboxes).toHaveLength(6) }) it('calls setNotificationPrefs when a checkbox is toggled', async () => { @@ -236,6 +236,7 @@ describe('AccountSettingsDialog', () => { notifyNoteEdit: true, notifyItemAdd: true, notifyItemRecur: true, + notifyItemDone: true, }) const wrapper = mountComponent({ open: true, houseId: 3 }) diff --git a/src/components/AccountSettingsDialog/AccountSettingsDialog.vue b/src/components/AccountSettingsDialog/AccountSettingsDialog.vue index dbc3a2a..a81cf3e 100644 --- a/src/components/AccountSettingsDialog/AccountSettingsDialog.vue +++ b/src/components/AccountSettingsDialog/AccountSettingsDialog.vue @@ -64,6 +64,12 @@ > {{ strings.notifyItemRecur }} + + {{ strings.notifyItemDone }} + @@ -156,6 +162,7 @@ const notifPrefs = reactive({ notifyNoteEdit: true, notifyItemAdd: true, notifyItemRecur: true, + notifyItemDone: true, }) async function loadNotifPrefs() { @@ -203,6 +210,7 @@ const strings = { notifyNoteEdit: t('pantry', 'Note edits'), notifyItemAdd: t('pantry', 'Checklist items added'), notifyItemRecur: t('pantry', 'Recurring items reappearing'), + notifyItemDone: t('pantry', 'Checklist items completed'), } diff --git a/src/components/RecurrenceEditor/RecurrenceEditor.vue b/src/components/RecurrenceEditor/RecurrenceEditor.vue index 3468662..45d1e89 100644 --- a/src/components/RecurrenceEditor/RecurrenceEditor.vue +++ b/src/components/RecurrenceEditor/RecurrenceEditor.vue @@ -450,7 +450,7 @@ const fromCompletionHint = computed(() => fromCompletionLocal.value ? t( 'pantry', - 'The next occurrence is counted from the moment you tick the item off, so it always comes back a full interval after it was bought.', + 'The next occurrence is counted from the moment you tick the item off, so it always comes back a full interval after it was completed.', ) : t( 'pantry', diff --git a/src/composables/useChecklist.ts b/src/composables/useChecklist.ts index f292e77..7cccf18 100644 --- a/src/composables/useChecklist.ts +++ b/src/composables/useChecklist.ts @@ -77,7 +77,7 @@ export function useChecklistItems(houseId: number, listId: number) { // Optimistic flip. const prev = items.value.find((i) => i.id === itemId) if (prev) { - items.value = items.value.map((i) => (i.id === itemId ? { ...i, bought: !i.bought } : i)) + items.value = items.value.map((i) => (i.id === itemId ? { ...i, done: !i.done } : i)) } try { const updated = await api.toggleItem(houseId, listId, itemId) diff --git a/src/views/ChecklistDetail.vue b/src/views/ChecklistDetail.vue index 509c011..1ea9d13 100644 --- a/src/views/ChecklistDetail.vue +++ b/src/views/ChecklistDetail.vue @@ -66,10 +66,10 @@ v-for="item in sortedItems" :key="item.id" class="pantry-item" - :class="{ 'pantry-item--bought': item.bought }" + :class="{ 'pantry-item--done': item.done }" > @@ -313,7 +313,7 @@ watch( const sortedItems = computed(() => { return [...items.value].sort((a, b) => { - if (a.bought !== b.bought) return a.bought ? 1 : -1 + if (a.done !== b.done) return a.done ? 1 : -1 if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder return a.name.localeCompare(b.name) }) @@ -572,7 +572,7 @@ const strings = { border-radius: var(--border-radius, 8px); background: var(--color-main-background); - &--bought { + &--done { opacity: 0.6; .pantry-item__name { diff --git a/tests/unit/Notification/NotifierTest.php b/tests/unit/Notification/NotifierTest.php index 20f8ac7..3c75635 100644 --- a/tests/unit/Notification/NotifierTest.php +++ b/tests/unit/Notification/NotifierTest.php @@ -142,11 +142,45 @@ class NotifierTest extends TestCase { $this->assertSame($n, $result); } + public function testItemDoneSetsRichSubject(): void { + $n = $this->makeNotification('pantry', 'item_done', [ + 'userId' => 'alice', + 'userDisplayName' => 'Alice', + 'houseId' => 1, + 'houseName' => 'My House', + 'itemName' => 'Milk', + 'listName' => 'Groceries', + ]); + + $n->expects($this->once())->method('setRichSubject'); + $n->expects($this->once())->method('setParsedSubject'); + + $result = $this->notifier->prepare($n, 'en'); + $this->assertSame($n, $result); + } + + public function testItemReminderSetsRichSubject(): void { + $n = $this->makeNotification('pantry', 'item_reminder', [ + 'houseId' => 1, + 'houseName' => 'My House', + 'itemNames' => ['Milk'], + 'itemCount' => 1, + 'listName' => 'Groceries', + ]); + + $n->expects($this->once())->method('setRichSubject'); + $n->expects($this->once())->method('setParsedSubject'); + + $result = $this->notifier->prepare($n, 'en'); + $this->assertSame($n, $result); + } + public function testItemRecurredSetsRichSubject(): void { $n = $this->makeNotification('pantry', 'item_recurred', [ 'houseId' => 1, 'houseName' => 'My House', - 'itemName' => 'Milk', + 'itemNames' => ['Milk', 'Eggs'], + 'itemCount' => 2, 'listName' => 'Groceries', ]); diff --git a/tests/unit/Service/ChecklistServiceTest.php b/tests/unit/Service/ChecklistServiceTest.php index 8f7522f..90a8d0d 100644 --- a/tests/unit/Service/ChecklistServiceTest.php +++ b/tests/unit/Service/ChecklistServiceTest.php @@ -39,9 +39,9 @@ class ChecklistServiceTest extends TestCase { $item->setName($overrides['name'] ?? 'Milk'); $item->setCategoryId($overrides['categoryId'] ?? null); $item->setQuantity($overrides['quantity'] ?? null); - $item->setBought($overrides['bought'] ?? false); - $item->setBoughtAt($overrides['boughtAt'] ?? null); - $item->setBoughtBy($overrides['boughtBy'] ?? null); + $item->setDone($overrides['done'] ?? false); + $item->setDoneAt($overrides['doneAt'] ?? null); + $item->setDoneBy($overrides['doneBy'] ?? null); $item->setRrule($overrides['rrule'] ?? null); $item->setRepeatFromCompletion($overrides['repeatFromCompletion'] ?? false); $item->setNextDueAt($overrides['nextDueAt'] ?? null); @@ -54,17 +54,17 @@ class ChecklistServiceTest extends TestCase { public function testListItemsAutoUnchecksDueRecurring(): void { $now = 2_000_000_000; $dueItem = $this->makeItem([ - 'bought' => true, - 'boughtAt' => $now - 86400 * 8, - 'boughtBy' => 'alice', + 'done' => true, + 'doneAt' => $now - 86400 * 8, + 'doneBy' => 'alice', 'rrule' => 'FREQ=WEEKLY', 'repeatFromCompletion' => true, 'nextDueAt' => $now - 10, ]); $freshItem = $this->makeItem([ - 'bought' => true, - 'boughtAt' => $now - 3600, - 'boughtBy' => 'alice', + 'done' => true, + 'doneAt' => $now - 3600, + 'doneBy' => 'alice', 'rrule' => 'FREQ=WEEKLY', 'nextDueAt' => $now + 86400 * 3, ]); @@ -74,16 +74,16 @@ class ChecklistServiceTest extends TestCase { $this->itemMapper->expects($this->once()) ->method('update') ->with($this->callback(function (ChecklistItem $i) { - return $i->getBought() === false - && $i->getBoughtAt() === null - && $i->getBoughtBy() === null + return $i->getDone() === false + && $i->getDoneAt() === null + && $i->getDoneBy() === null && $i->getNextDueAt() === null; })); $result = $this->svc->listItems(1, $now); $this->assertCount(2, $result); - $this->assertFalse($result[0]->getBought(), 'Due item should be reopened'); - $this->assertTrue($result[1]->getBought(), 'Fresh item should stay bought'); + $this->assertFalse($result[0]->getDone(), 'Due item should be reopened'); + $this->assertTrue($result[1]->getDone(), 'Fresh item should stay done'); } public function testToggleItemOnNonRecurringDoesNotSetNextDue(): void { @@ -92,9 +92,9 @@ class ChecklistServiceTest extends TestCase { $this->itemMapper->expects($this->once())->method('update')->willReturn($item); $toggled = $this->svc->toggleItem(42, 'alice', 1_000_000_000); - $this->assertTrue($toggled->getBought()); - $this->assertSame('alice', $toggled->getBoughtBy()); - $this->assertSame(1_000_000_000, $toggled->getBoughtAt()); + $this->assertTrue($toggled->getDone()); + $this->assertSame('alice', $toggled->getDoneBy()); + $this->assertSame(1_000_000_000, $toggled->getDoneAt()); $this->assertNull($toggled->getNextDueAt()); } @@ -109,7 +109,7 @@ class ChecklistServiceTest extends TestCase { $this->itemMapper->expects($this->once())->method('update')->willReturn($item); $toggled = $this->svc->toggleItem(42, 'alice', $now); - $this->assertTrue($toggled->getBought()); + $this->assertTrue($toggled->getDone()); $this->assertSame($now + 7 * 86400, $toggled->getNextDueAt()); } @@ -128,15 +128,15 @@ class ChecklistServiceTest extends TestCase { $this->itemMapper->expects($this->once())->method('update')->willReturn($item); $toggled = $this->svc->toggleItem(42, 'alice', $now); - $this->assertTrue($toggled->getBought()); + $this->assertTrue($toggled->getDone()); $this->assertSame($expected, $toggled->getNextDueAt()); } public function testToggleItemCheckingOffClearsEverything(): void { $item = $this->makeItem([ - 'bought' => true, - 'boughtAt' => 123, - 'boughtBy' => 'alice', + 'done' => true, + 'doneAt' => 123, + 'doneBy' => 'alice', 'rrule' => 'FREQ=WEEKLY', 'nextDueAt' => 456, ]); @@ -144,9 +144,9 @@ class ChecklistServiceTest extends TestCase { $this->itemMapper->expects($this->once())->method('update')->willReturn($item); $toggled = $this->svc->toggleItem(42, 'alice', 999); - $this->assertFalse($toggled->getBought()); - $this->assertNull($toggled->getBoughtAt()); - $this->assertNull($toggled->getBoughtBy()); + $this->assertFalse($toggled->getDone()); + $this->assertNull($toggled->getDoneAt()); + $this->assertNull($toggled->getDoneBy()); $this->assertNull($toggled->getNextDueAt()); } diff --git a/tests/unit/Service/PrefsServiceTest.php b/tests/unit/Service/PrefsServiceTest.php index d22260f..03a3123 100644 --- a/tests/unit/Service/PrefsServiceTest.php +++ b/tests/unit/Service/PrefsServiceTest.php @@ -75,6 +75,9 @@ class PrefsServiceTest extends TestCase { if ($key === 'notify_item_recur_1') { return '0'; } + if ($key === 'notify_item_done_1') { + return '1'; + } return $default; } ); @@ -86,6 +89,7 @@ class PrefsServiceTest extends TestCase { 'notifyNoteEdit' => false, 'notifyItemAdd' => true, 'notifyItemRecur' => false, + 'notifyItemDone' => true, ], $result); }