feat: updated notifications, refactored bought -> done

This commit is contained in:
2026-04-07 09:20:03 +03:00
parent f9dc3d75da
commit de4d58a071
22 changed files with 362 additions and 110 deletions

View File

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

View File

@@ -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<Http::STATUS_OK, PantryListItem, array{}>
@@ -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());
});
}

View File

@@ -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<Http::STATUS_OK, PantryNotificationPrefs, array{}>
*
@@ -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));
});
}

View File

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

View File

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

View File

@@ -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,
]);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ export interface NotificationPrefs {
notifyNoteEdit: boolean
notifyItemAdd: boolean
notifyItemRecur: boolean
notifyItemDone: boolean
}
export async function getNotificationPrefs(houseId: number): Promise<NotificationPrefs> {
@@ -38,6 +39,7 @@ export async function getNotificationPrefs(houseId: number): Promise<Notificatio
notifyNoteEdit: true,
notifyItemAdd: true,
notifyItemRecur: true,
notifyItemDone: true,
}
)
}
@@ -54,6 +56,7 @@ export async function setNotificationPrefs(
notifyNoteEdit: true,
notifyItemAdd: true,
notifyItemRecur: true,
notifyItemDone: true,
}
)
}

View File

@@ -47,9 +47,9 @@ export interface ChecklistItem {
name: string
categoryId: number | null
quantity: string | null
bought: boolean
boughtAt: number | null
boughtBy: string | null
done: boolean
doneAt: number | null
doneBy: string | null
rrule: string | null
repeatFromCompletion: boolean
nextDueAt: number | null

View File

@@ -221,12 +221,12 @@ describe('AccountSettingsDialog', () => {
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 })

View File

@@ -64,6 +64,12 @@
>
{{ strings.notifyItemRecur }}
</NcCheckboxRadioSwitch>
<NcCheckboxRadioSwitch
:model-value="notifPrefs.notifyItemDone"
@update:model-value="updateNotifPref('notifyItemDone', $event)"
>
{{ strings.notifyItemDone }}
</NcCheckboxRadioSwitch>
</div>
</NcAppSettingsSection>
</NcAppSettingsDialog>
@@ -156,6 +162,7 @@ const notifPrefs = reactive<NotificationPrefs>({
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'),
}
</script>

View File

@@ -450,7 +450,7 @@ const fromCompletionHint = computed<string>(() =>
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',

View File

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

View File

@@ -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 }"
>
<NcCheckboxRadioSwitch
:model-value="item.bought"
:model-value="item.done"
@update:model-value="handleToggle(item.id)"
>
<span class="pantry-item__label">
@@ -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 {

View File

@@ -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',
]);

View File

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

View File

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