From cac1159588e9e2da325e96f087a9f52f389d5294 Mon Sep 17 00:00:00 2001 From: Chen Asraf Date: Sun, 5 Apr 2026 23:13:12 +0300 Subject: [PATCH] feat: repeat from completion, categories table, navigation fixes --- lib/Controller/CategoryController.php | 158 +++++ lib/Controller/ShoppingListController.php | 36 +- lib/Db/Category.php | 56 ++ lib/Db/CategoryMapper.php | 78 +++ lib/Db/ShoppingListItem.php | 14 +- lib/Migration/Version1Date20260405000000.php | 52 +- lib/ResponseDefinitions.php | 14 +- lib/Service/CategoryService.php | 149 +++++ lib/Service/HouseService.php | 3 + lib/Service/RecurrenceService.php | 69 ++- lib/Service/ShoppingListService.php | 62 +- openapi.json | 585 +++++++++++++++++- src/api/categories.ts | 28 + src/api/lists.ts | 3 +- src/api/types.ts | 14 +- src/components/CategoryPicker.vue | 358 +++++++++++ src/components/RecurrenceEditor.vue | 40 +- src/components/categoryIcons.ts | 76 +++ src/composables/useCategories.ts | 63 ++ src/views/ShoppingListDetail.vue | 47 +- src/views/SideNavigation.vue | 16 + tests/integration/.gitkeep | 0 tests/phpunit.integration.xml | 21 + .../unit/Service/ShoppingListServiceTest.php | 31 +- 24 files changed, 1910 insertions(+), 63 deletions(-) create mode 100644 lib/Controller/CategoryController.php create mode 100644 lib/Db/Category.php create mode 100644 lib/Db/CategoryMapper.php create mode 100644 lib/Service/CategoryService.php create mode 100644 src/api/categories.ts create mode 100644 src/components/CategoryPicker.vue create mode 100644 src/components/categoryIcons.ts create mode 100644 src/composables/useCategories.ts create mode 100644 tests/integration/.gitkeep create mode 100644 tests/phpunit.integration.xml diff --git a/lib/Controller/CategoryController.php b/lib/Controller/CategoryController.php new file mode 100644 index 0000000..30e096f --- /dev/null +++ b/lib/Controller/CategoryController.php @@ -0,0 +1,158 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Controller; + +use OCA\Pantry\Exception\ForbiddenException; +use OCA\Pantry\ResponseDefinitions; +use OCA\Pantry\Service\CategoryService; +use OCA\Pantry\Service\HouseAuthService; +use OCP\AppFramework\Http; +use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; +use OCP\AppFramework\Http\DataResponse; +use OCP\AppFramework\OCSController; +use OCP\IRequest; +use OCP\IUserSession; + +/** + * @psalm-import-type PantryCategory from ResponseDefinitions + * @psalm-import-type PantrySuccess from ResponseDefinitions + */ +final class CategoryController extends OCSController { + use TranslatesDomainExceptions; + + public function __construct( + string $appName, + IRequest $request, + private CategoryService $categories, + private HouseAuthService $auth, + private IUserSession $userSession, + ) { + parent::__construct($appName, $request); + } + + /** + * List all categories in a house + * + * @param int $houseId House id. + * @param int<1, 500> $limit Maximum number of categories to return. + * @param int<0, max> $offset Number of categories to skip. + * + * @return DataResponse, array{}> + * + * 200: Categories returned + */ + #[ApiRoute(verb: 'GET', url: '/api/houses/{houseId}/categories')] + #[NoAdminRequired] + public function index(int $houseId, int $limit = 100, int $offset = 0): DataResponse { + return $this->runAction(function () use ($houseId, $limit, $offset): DataResponse { + $this->auth->requireMember($houseId, $this->requireUid()); + $all = $this->categories->listForHouse($houseId); + $sliced = array_slice($all, max(0, $offset), max(0, $limit)); + return new DataResponse(array_map(fn ($c) => $c->jsonSerialize(), $sliced)); + }); + } + + /** + * Create a category + * + * @param int $houseId House id. + * @param string $name Category name. + * @param string $icon Icon key from the palette. + * @param string $color Hex color (e.g. "#4caf50"). + * + * @return DataResponse + * + * 200: Category created + */ + #[ApiRoute(verb: 'POST', url: '/api/houses/{houseId}/categories')] + #[NoAdminRequired] + public function create(int $houseId, string $name, string $icon, string $color): DataResponse { + return $this->runAction(function () use ($houseId, $name, $icon, $color): DataResponse { + $this->auth->requireMember($houseId, $this->requireUid()); + $cat = $this->categories->create($houseId, $name, $icon, $color); + return new DataResponse($cat->jsonSerialize()); + }); + } + + /** + * Update a category + * + * @param int $houseId House id. + * @param int $categoryId Category id. + * @param string|null $name New name. + * @param string|null $icon New icon key. + * @param string|null $color New hex color. + * @param int|null $sortOrder New sort order. + * + * @return DataResponse + * + * 200: Category updated + */ + #[ApiRoute(verb: 'PATCH', url: '/api/houses/{houseId}/categories/{categoryId}')] + #[NoAdminRequired] + public function update( + int $houseId, + int $categoryId, + ?string $name = null, + ?string $icon = null, + ?string $color = null, + ?int $sortOrder = null, + ): DataResponse { + return $this->runAction(function () use ($houseId, $categoryId, $name, $icon, $color, $sortOrder): DataResponse { + $this->auth->requireMember($houseId, $this->requireUid()); + $this->categories->assertInHouse($categoryId, $houseId); + $patch = []; + if ($name !== null) { + $patch['name'] = $name; + } + if ($icon !== null) { + $patch['icon'] = $icon; + } + if ($color !== null) { + $patch['color'] = $color; + } + if ($sortOrder !== null) { + $patch['sortOrder'] = $sortOrder; + } + $updated = $this->categories->update($categoryId, $patch); + return new DataResponse($updated->jsonSerialize()); + }); + } + + /** + * Delete a category + * + * Detaches any items that reference it. + * + * @param int $houseId House id. + * @param int $categoryId Category id. + * + * @return DataResponse + * + * 200: Category deleted + */ + #[ApiRoute(verb: 'DELETE', url: '/api/houses/{houseId}/categories/{categoryId}')] + #[NoAdminRequired] + public function destroy(int $houseId, int $categoryId): DataResponse { + return $this->runAction(function () use ($houseId, $categoryId): DataResponse { + $this->auth->requireMember($houseId, $this->requireUid()); + $this->categories->assertInHouse($categoryId, $houseId); + $this->categories->delete($categoryId); + return new DataResponse(['success' => true]); + }); + } + + private function requireUid(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ForbiddenException('Not authenticated'); + } + return $user->getUID(); + } +} diff --git a/lib/Controller/ShoppingListController.php b/lib/Controller/ShoppingListController.php index 60c10ac..86104f8 100644 --- a/lib/Controller/ShoppingListController.php +++ b/lib/Controller/ShoppingListController.php @@ -10,6 +10,7 @@ namespace OCA\Pantry\Controller; use OCA\Pantry\Exception\ForbiddenException; use OCA\Pantry\Exception\NotFoundException; use OCA\Pantry\ResponseDefinitions; +use OCA\Pantry\Service\CategoryService; use OCA\Pantry\Service\HouseAuthService; use OCA\Pantry\Service\ShoppingListService; use OCP\AppFramework\Http; @@ -32,6 +33,7 @@ final class ShoppingListController extends OCSController { string $appName, IRequest $request, private ShoppingListService $lists, + private CategoryService $categories, private HouseAuthService $auth, private IUserSession $userSession, ) { @@ -194,9 +196,10 @@ final class ShoppingListController extends OCSController { * @param int $houseId House id. * @param int $listId List id. * @param string $name Item name. - * @param string|null $category Optional category label. + * @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 int|null $sortOrder Optional sort order. * * @return DataResponse @@ -209,20 +212,25 @@ final class ShoppingListController extends OCSController { int $houseId, int $listId, string $name, - ?string $category = null, + ?int $categoryId = null, ?string $quantity = null, ?string $rrule = null, + bool $repeatFromCompletion = false, ?int $sortOrder = null, ): DataResponse { - return $this->runAction(function () use ($houseId, $listId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); $list = $this->lists->getList($listId); $this->assertListInHouse($list->getHouseId(), $houseId); + if ($categoryId !== null) { + $this->categories->assertInHouse($categoryId, $houseId); + } $item = $this->lists->addItem($listId, [ 'name' => $name, - 'category' => $category, + 'categoryId' => $categoryId, 'quantity' => $quantity, 'rrule' => $rrule, + 'repeatFromCompletion' => $repeatFromCompletion, 'sortOrder' => $sortOrder ?? 0, ]); return new DataResponse($item->jsonSerialize()); @@ -236,9 +244,10 @@ final class ShoppingListController extends OCSController { * @param int $listId List id. * @param int $itemId Item id. * @param string|null $name New name. - * @param string|null $category New category (empty string clears). + * @param int|null $categoryId New category id (0 or negative clears). * @param string|null $quantity New quantity (empty string clears). * @param string|null $rrule New RRULE (empty string clears). + * @param bool|null $repeatFromCompletion New recurrence anchor mode. * @param int|null $sortOrder New sort order. * * @return DataResponse @@ -252,12 +261,13 @@ final class ShoppingListController extends OCSController { int $listId, int $itemId, ?string $name = null, - ?string $category = null, + ?int $categoryId = null, ?string $quantity = null, ?string $rrule = null, + ?bool $repeatFromCompletion = null, ?int $sortOrder = null, ): DataResponse { - return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $category, $quantity, $rrule, $sortOrder): DataResponse { + return $this->runAction(function () use ($houseId, $listId, $itemId, $name, $categoryId, $quantity, $rrule, $repeatFromCompletion, $sortOrder): DataResponse { $this->auth->requireMember($houseId, $this->requireUid()); $item = $this->lists->getItem($itemId); $list = $this->lists->getList($item->getListId()); @@ -269,8 +279,13 @@ final class ShoppingListController extends OCSController { if ($name !== null) { $patch['name'] = $name; } - if ($category !== null) { - $patch['category'] = $category; + if ($categoryId !== null) { + if ($categoryId > 0) { + $this->categories->assertInHouse($categoryId, $houseId); + $patch['categoryId'] = $categoryId; + } else { + $patch['categoryId'] = null; + } } if ($quantity !== null) { $patch['quantity'] = $quantity; @@ -278,6 +293,9 @@ final class ShoppingListController extends OCSController { if ($rrule !== null) { $patch['rrule'] = $rrule; } + if ($repeatFromCompletion !== null) { + $patch['repeatFromCompletion'] = $repeatFromCompletion; + } if ($sortOrder !== null) { $patch['sortOrder'] = $sortOrder; } diff --git a/lib/Db/Category.php b/lib/Db/Category.php new file mode 100644 index 0000000..27e7836 --- /dev/null +++ b/lib/Db/Category.php @@ -0,0 +1,56 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Db; + +use OCP\AppFramework\Db\Entity; + +/** + * @method int getHouseId() + * @method void setHouseId(int $houseId) + * @method string getName() + * @method void setName(string $name) + * @method string getIcon() + * @method void setIcon(string $icon) + * @method string getColor() + * @method void setColor(string $color) + * @method int getSortOrder() + * @method void setSortOrder(int $sortOrder) + * @method int getCreatedAt() + * @method void setCreatedAt(int $createdAt) + * @method int getUpdatedAt() + * @method void setUpdatedAt(int $updatedAt) + */ +class Category extends Entity implements \JsonSerializable { + protected int $houseId = 0; + protected string $name = ''; + protected string $icon = ''; + protected string $color = ''; + protected int $sortOrder = 0; + protected int $createdAt = 0; + protected int $updatedAt = 0; + + public function __construct() { + $this->addType('houseId', 'integer'); + $this->addType('sortOrder', 'integer'); + $this->addType('createdAt', 'integer'); + $this->addType('updatedAt', 'integer'); + } + + public function jsonSerialize(): array { + return [ + 'id' => $this->id, + 'houseId' => $this->houseId, + 'name' => $this->name, + 'icon' => $this->icon, + 'color' => $this->color, + 'sortOrder' => $this->sortOrder, + 'createdAt' => $this->createdAt, + 'updatedAt' => $this->updatedAt, + ]; + } +} diff --git a/lib/Db/CategoryMapper.php b/lib/Db/CategoryMapper.php new file mode 100644 index 0000000..8766278 --- /dev/null +++ b/lib/Db/CategoryMapper.php @@ -0,0 +1,78 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Db; + +use OCA\Pantry\AppInfo\Application; +use OCP\AppFramework\Db\DoesNotExistException; +use OCP\AppFramework\Db\QBMapper; +use OCP\DB\QueryBuilder\IQueryBuilder; +use OCP\IDBConnection; + +/** + * @template-extends QBMapper + */ +class CategoryMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, Application::tableName('categories'), Category::class); + } + + /** + * @return Category[] + */ + public function findByHouse(int $houseId): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) + ->orderBy('sort_order', 'ASC') + ->addOrderBy('name', 'ASC'); + return $this->findEntities($qb); + } + + /** + * @throws DoesNotExistException + */ + public function findById(int $id): Category { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + return $this->findEntity($qb); + } + + public function findByHouseAndName(int $houseId, string $name): ?Category { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))) + ->andWhere($qb->expr()->eq('name', $qb->createNamedParameter($name, IQueryBuilder::PARAM_STR))); + try { + return $this->findEntity($qb); + } catch (DoesNotExistException) { + return null; + } + } + + public function deleteByHouse(int $houseId): void { + $qb = $this->db->getQueryBuilder(); + $qb->delete($this->getTableName()) + ->where($qb->expr()->eq('house_id', $qb->createNamedParameter($houseId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } + + /** + * Clear category_id on any items that reference the given category. + */ + public function detachFromItems(int $categoryId): void { + $qb = $this->db->getQueryBuilder(); + $qb->update(Application::tableName('list_items')) + ->set('category_id', $qb->createNamedParameter(null, IQueryBuilder::PARAM_NULL)) + ->where($qb->expr()->eq('category_id', $qb->createNamedParameter($categoryId, IQueryBuilder::PARAM_INT))); + $qb->executeStatement(); + } +} diff --git a/lib/Db/ShoppingListItem.php b/lib/Db/ShoppingListItem.php index b3563fc..4a7b1e6 100644 --- a/lib/Db/ShoppingListItem.php +++ b/lib/Db/ShoppingListItem.php @@ -14,8 +14,8 @@ use OCP\AppFramework\Db\Entity; * @method void setListId(int $listId) * @method string getName() * @method void setName(string $name) - * @method string|null getCategory() - * @method void setCategory(?string $category) + * @method int|null getCategoryId() + * @method void setCategoryId(?int $categoryId) * @method string|null getQuantity() * @method void setQuantity(?string $quantity) * @method bool getBought() @@ -26,6 +26,8 @@ use OCP\AppFramework\Db\Entity; * @method void setBoughtBy(?string $boughtBy) * @method string|null getRrule() * @method void setRrule(?string $rrule) + * @method bool getRepeatFromCompletion() + * @method void setRepeatFromCompletion(bool $repeatFromCompletion) * @method int|null getNextDueAt() * @method void setNextDueAt(?int $nextDueAt) * @method int getSortOrder() @@ -38,12 +40,13 @@ use OCP\AppFramework\Db\Entity; class ShoppingListItem extends Entity implements \JsonSerializable { protected int $listId = 0; protected string $name = ''; - protected ?string $category = null; + protected ?int $categoryId = null; protected ?string $quantity = null; protected bool $bought = false; protected ?int $boughtAt = null; protected ?string $boughtBy = null; protected ?string $rrule = null; + protected bool $repeatFromCompletion = false; protected ?int $nextDueAt = null; protected int $sortOrder = 0; protected int $createdAt = 0; @@ -51,8 +54,10 @@ class ShoppingListItem 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('repeatFromCompletion', 'boolean'); $this->addType('nextDueAt', 'integer'); $this->addType('sortOrder', 'integer'); $this->addType('createdAt', 'integer'); @@ -64,12 +69,13 @@ class ShoppingListItem extends Entity implements \JsonSerializable { 'id' => $this->id, 'listId' => $this->listId, 'name' => $this->name, - 'category' => $this->category, + 'categoryId' => $this->categoryId, 'quantity' => $this->quantity, 'bought' => $this->bought, 'boughtAt' => $this->boughtAt, 'boughtBy' => $this->boughtBy, 'rrule' => $this->rrule, + 'repeatFromCompletion' => $this->repeatFromCompletion, 'nextDueAt' => $this->nextDueAt, 'sortOrder' => $this->sortOrder, 'createdAt' => $this->createdAt, diff --git a/lib/Migration/Version1Date20260405000000.php b/lib/Migration/Version1Date20260405000000.php index b747bba..cfe80ce 100644 --- a/lib/Migration/Version1Date20260405000000.php +++ b/lib/Migration/Version1Date20260405000000.php @@ -123,6 +123,47 @@ class Version1Date20260405000000 extends SimpleMigrationStep { $table->addIndex(['house_id'], 'pantry_lists_house_idx'); } + // ---- pantry_categories ---- + $categoriesTable = Application::tableName('categories'); + if (!$schema->hasTable($categoriesTable)) { + $table = $schema->createTable($categoriesTable); + $table->addColumn('id', Types::BIGINT, [ + 'autoincrement' => true, + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('house_id', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 128, + ]); + $table->addColumn('icon', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('color', Types::STRING, [ + 'notnull' => true, + 'length' => 16, + ]); + $table->addColumn('sort_order', Types::INTEGER, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('created_at', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->addColumn('updated_at', Types::BIGINT, [ + 'notnull' => true, + 'length' => 20, + ]); + $table->setPrimaryKey(['id']); + $table->addUniqueIndex(['house_id', 'name'], 'pantry_cat_house_name_uq'); + } + // ---- pantry_list_items ---- $itemsTable = Application::tableName('list_items'); if (!$schema->hasTable($itemsTable)) { @@ -140,17 +181,16 @@ class Version1Date20260405000000 extends SimpleMigrationStep { 'notnull' => true, 'length' => 255, ]); - $table->addColumn('category', Types::STRING, [ + $table->addColumn('category_id', Types::BIGINT, [ 'notnull' => false, - 'length' => 128, + 'length' => 20, ]); $table->addColumn('quantity', Types::STRING, [ 'notnull' => false, 'length' => 64, ]); $table->addColumn('bought', Types::BOOLEAN, [ - 'notnull' => true, - 'default' => 0, + 'notnull' => false, ]); $table->addColumn('bought_at', Types::BIGINT, [ 'notnull' => false, @@ -164,6 +204,9 @@ class Version1Date20260405000000 extends SimpleMigrationStep { 'notnull' => false, 'length' => 512, ]); + $table->addColumn('repeat_from_completion', Types::BOOLEAN, [ + 'notnull' => false, + ]); $table->addColumn('next_due_at', Types::BIGINT, [ 'notnull' => false, 'length' => 20, @@ -182,6 +225,7 @@ class Version1Date20260405000000 extends SimpleMigrationStep { ]); $table->setPrimaryKey(['id']); $table->addIndex(['list_id'], 'pantry_items_list_idx'); + $table->addIndex(['category_id'], 'pantry_items_cat_idx'); } return $schema; diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index bb2340f..74ccf21 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -41,18 +41,30 @@ namespace OCA\Pantry; * id: int, * listId: int, * name: string, - * category: string|null, + * categoryId: int|null, * quantity: string|null, * bought: bool, * boughtAt: int|null, * boughtBy: string|null, * rrule: string|null, + * repeatFromCompletion: bool, * nextDueAt: int|null, * sortOrder: int, * createdAt: int, * updatedAt: int, * } * + * @psalm-type PantryCategory = array{ + * id: int, + * houseId: int, + * name: string, + * icon: string, + * color: string, + * sortOrder: int, + * createdAt: int, + * updatedAt: int, + * } + * * @psalm-type PantrySuccess = array{success: true} * * @psalm-type PantryLastHouse = array{houseId: int|null} diff --git a/lib/Service/CategoryService.php b/lib/Service/CategoryService.php new file mode 100644 index 0000000..1d064d0 --- /dev/null +++ b/lib/Service/CategoryService.php @@ -0,0 +1,149 @@ + +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace OCA\Pantry\Service; + +use OCA\Pantry\Db\Category; +use OCA\Pantry\Db\CategoryMapper; +use OCA\Pantry\Exception\NotFoundException; +use OCP\AppFramework\Db\DoesNotExistException; + +class CategoryService { + /** Palette of supported icon keys, mirrored on the frontend. */ + private const ICON_KEYS = [ + 'tag', + 'food', + 'fruit', + 'vegetable', + 'bakery', + 'dairy', + 'meat', + 'fish', + 'snacks', + 'cookie', + 'drinks', + 'coffee', + 'frozen', + 'household', + 'pets', + 'baby', + 'home', + 'leaf', + 'pizza', + ]; + + public function __construct( + private CategoryMapper $mapper, + ) { + } + + /** + * @return Category[] + */ + public function listForHouse(int $houseId): array { + return $this->mapper->findByHouse($houseId); + } + + public function get(int $categoryId): Category { + try { + return $this->mapper->findById($categoryId); + } catch (DoesNotExistException) { + throw new NotFoundException('Category not found'); + } + } + + public function create(int $houseId, string $name, string $icon, string $color): Category { + $name = trim($name); + if ($name === '') { + throw new \InvalidArgumentException('Category name cannot be empty'); + } + $icon = $this->normalizeIcon($icon); + $color = $this->normalizeColor($color); + + if ($this->mapper->findByHouseAndName($houseId, $name) !== null) { + throw new \InvalidArgumentException('A category with this name already exists'); + } + + $now = time(); + $cat = new Category(); + $cat->setHouseId($houseId); + $cat->setName($name); + $cat->setIcon($icon); + $cat->setColor($color); + $cat->setSortOrder(0); + $cat->setCreatedAt($now); + $cat->setUpdatedAt($now); + /** @var Category $saved */ + $saved = $this->mapper->insert($cat); + return $saved; + } + + public function update(int $categoryId, array $patch): Category { + $cat = $this->get($categoryId); + if (isset($patch['name'])) { + $name = trim((string)$patch['name']); + if ($name === '') { + throw new \InvalidArgumentException('Category name cannot be empty'); + } + if ($name !== $cat->getName()) { + $existing = $this->mapper->findByHouseAndName($cat->getHouseId(), $name); + if ($existing !== null && (int)$existing->getId() !== $categoryId) { + throw new \InvalidArgumentException('A category with this name already exists'); + } + } + $cat->setName($name); + } + if (isset($patch['icon'])) { + $cat->setIcon($this->normalizeIcon((string)$patch['icon'])); + } + if (isset($patch['color'])) { + $cat->setColor($this->normalizeColor((string)$patch['color'])); + } + if (isset($patch['sortOrder'])) { + $cat->setSortOrder((int)$patch['sortOrder']); + } + $cat->setUpdatedAt(time()); + $this->mapper->update($cat); + return $cat; + } + + public function delete(int $categoryId): void { + $cat = $this->get($categoryId); + // Detach from any items first, then delete the row. + $this->mapper->detachFromItems((int)$cat->getId()); + $this->mapper->delete($cat); + } + + /** + * Asserts that the given category belongs to the given house. Returns the loaded entity. + * + * @throws NotFoundException when missing or mismatched. + */ + public function assertInHouse(int $categoryId, int $houseId): Category { + $cat = $this->get($categoryId); + if ($cat->getHouseId() !== $houseId) { + throw new NotFoundException('Category does not belong to this house'); + } + return $cat; + } + + private function normalizeIcon(string $icon): string { + $icon = strtolower(trim($icon)); + if (!in_array($icon, self::ICON_KEYS, true)) { + throw new \InvalidArgumentException('Unsupported category icon: ' . $icon); + } + return $icon; + } + + private function normalizeColor(string $color): string { + $color = trim($color); + if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { + throw new \InvalidArgumentException('Color must be a 6-digit hex string like "#4caf50"'); + } + return strtolower($color); + } +} diff --git a/lib/Service/HouseService.php b/lib/Service/HouseService.php index ff4a20a..35dc89d 100644 --- a/lib/Service/HouseService.php +++ b/lib/Service/HouseService.php @@ -7,6 +7,7 @@ declare(strict_types=1); namespace OCA\Pantry\Service; +use OCA\Pantry\Db\CategoryMapper; use OCA\Pantry\Db\House; use OCA\Pantry\Db\HouseMapper; use OCA\Pantry\Db\HouseMember; @@ -25,6 +26,7 @@ class HouseService { private HouseMemberMapper $memberMapper, private ShoppingListMapper $listMapper, private ShoppingListItemMapper $itemMapper, + private CategoryMapper $categoryMapper, private IDBConnection $db, private IUserManager $userManager, ) { @@ -107,6 +109,7 @@ class HouseService { $this->itemMapper->deleteByList((int)$list->getId()); } $this->listMapper->deleteByHouse($houseId); + $this->categoryMapper->deleteByHouse($houseId); $this->memberMapper->deleteByHouse($houseId); $this->houseMapper->delete($house); $this->db->commit(); diff --git a/lib/Service/RecurrenceService.php b/lib/Service/RecurrenceService.php index 9bc50ff..26188cf 100644 --- a/lib/Service/RecurrenceService.php +++ b/lib/Service/RecurrenceService.php @@ -21,8 +21,10 @@ class RecurrenceService { * @throws \InvalidArgumentException if the rule is malformed. */ public function validate(string $rrule): void { + $normalized = $this->normalize($rrule); + $this->preflight($normalized); try { - new RRuleIterator($this->normalize($rrule), new \DateTimeImmutable('2000-01-01T00:00:00Z')); + new RRuleIterator($normalized, new \DateTimeImmutable('2000-01-01T00:00:00Z')); } catch (InvalidDataException $e) { throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e); } @@ -35,8 +37,10 @@ class RecurrenceService { * successive occurrences per the rule. We seed with DTSTART = $from and advance once. */ public function computeNextOccurrence(string $rrule, \DateTimeImmutable $from): ?\DateTimeImmutable { + $normalized = $this->normalize($rrule); + $this->preflight($normalized); try { - $iter = new RRuleIterator($this->normalize($rrule), $from); + $iter = new RRuleIterator($normalized, $from); } catch (InvalidDataException $e) { throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e); } @@ -53,6 +57,42 @@ class RecurrenceService { return \DateTimeImmutable::createFromInterface($current); } + /** + * Compute the next occurrence of a rule strictly after $after, using $dtStart as the + * schedule anchor. + * + * This is the "fixed schedule" semantics: the series of occurrences is determined by + * $dtStart (e.g. the item creation time), and we skip forward until we find the first + * one that is still in the future. Caps iteration to avoid runaway loops on malformed + * rules. + */ + public function nextOccurrenceAfter( + string $rrule, + \DateTimeImmutable $dtStart, + \DateTimeImmutable $after, + ): ?\DateTimeImmutable { + $normalized = $this->normalize($rrule); + $this->preflight($normalized); + try { + $iter = new RRuleIterator($normalized, $dtStart); + } catch (InvalidDataException $e) { + throw new \InvalidArgumentException('Invalid RRULE: ' . $e->getMessage(), 0, $e); + } + + $guard = 0; + while ($iter->valid() && $guard < 10_000) { + $current = $iter->current(); + if ($current instanceof \DateTimeInterface) { + if ($current->getTimestamp() > $after->getTimestamp()) { + return \DateTimeImmutable::createFromInterface($current); + } + } + $iter->next(); + $guard++; + } + return null; + } + private function normalize(string $rrule): string { $trim = trim($rrule); if (stripos($trim, 'RRULE:') === 0) { @@ -60,4 +100,29 @@ class RecurrenceService { } return $trim; } + + /** + * Shallow structural check before the string ever reaches sabre/vobject. + * + * Why this exists: sabre/vobject 4.5.x on some PHP 8.2 / doctrine combinations raises a + * PHP deprecation while parsing malformed input, which bubbles up to PHPUnit as a + * "deprecation" and fails CI under `--fail-on-warning`. Rejecting obvious garbage here + * keeps the error path within our own code. + * + * @throws \InvalidArgumentException if the rule is not a well-formed list of KEY=VALUE + * parts or lacks a supported FREQ= clause. + */ + private function preflight(string $rrule): void { + if ($rrule === '') { + throw new \InvalidArgumentException('Invalid RRULE: empty rule'); + } + // Whole rule must be KEY=VALUE(;KEY=VALUE)* + if (!preg_match('/^[A-Z][A-Z0-9-]*=[^;]*(?:;[A-Z][A-Z0-9-]*=[^;]*)*$/i', $rrule)) { + throw new \InvalidArgumentException('Invalid RRULE: expected KEY=VALUE parts separated by ";"'); + } + // Must contain a supported FREQ clause. + if (!preg_match('/(?:^|;)FREQ=(SECONDLY|MINUTELY|HOURLY|DAILY|WEEKLY|MONTHLY|YEARLY)(?:;|$)/i', $rrule)) { + throw new \InvalidArgumentException('Invalid RRULE: missing or unsupported FREQ clause'); + } + } } diff --git a/lib/Service/ShoppingListService.php b/lib/Service/ShoppingListService.php index 84081fd..5ae6f17 100644 --- a/lib/Service/ShoppingListService.php +++ b/lib/Service/ShoppingListService.php @@ -137,12 +137,13 @@ class ShoppingListService { $item = new ShoppingListItem(); $item->setListId($listId); $item->setName($name); - $item->setCategory($this->strOrNull($data['category'] ?? null)); + $item->setCategoryId($this->intOrNull($data['categoryId'] ?? null)); $item->setQuantity($this->strOrNull($data['quantity'] ?? null)); $item->setBought(false); $item->setBoughtAt(null); $item->setBoughtBy(null); $item->setRrule($rrule); + $item->setRepeatFromCompletion(!empty($data['repeatFromCompletion'])); $item->setNextDueAt(null); $item->setSortOrder(isset($data['sortOrder']) ? (int)$data['sortOrder'] : 0); $item->setCreatedAt($now); @@ -162,8 +163,8 @@ class ShoppingListService { } $item->setName($name); } - if (array_key_exists('category', $patch)) { - $item->setCategory($this->strOrNull($patch['category'])); + if (array_key_exists('categoryId', $patch)) { + $item->setCategoryId($this->intOrNull($patch['categoryId'])); } if (array_key_exists('quantity', $patch)) { $item->setQuantity($this->strOrNull($patch['quantity'])); @@ -180,13 +181,16 @@ class ShoppingListService { $rrule = trim((string)$rrule); $this->recurrence->validate($rrule); $item->setRrule($rrule); - // If already bought, recompute next due from now. - if ($item->getBought()) { - $next = $this->recurrence->computeNextOccurrence($rrule, new \DateTimeImmutable('@' . time())); - $item->setNextDueAt($next?->getTimestamp()); - } } } + if (array_key_exists('repeatFromCompletion', $patch)) { + $item->setRepeatFromCompletion((bool)$patch['repeatFromCompletion']); + } + // If already bought and rrule or mode changed, recompute next due. + if ($item->getBought() && $item->getRrule() !== null + && (array_key_exists('rrule', $patch) || array_key_exists('repeatFromCompletion', $patch))) { + $item->setNextDueAt($this->computeNextDueAt($item, time())?->getTimestamp()); + } if (isset($patch['sortOrder'])) { $item->setSortOrder((int)$patch['sortOrder']); } @@ -205,11 +209,7 @@ class ShoppingListService { $item->setBoughtAt($now); $item->setBoughtBy($uid); if ($item->getRrule() !== null) { - $next = $this->recurrence->computeNextOccurrence( - $item->getRrule(), - (new \DateTimeImmutable())->setTimestamp($now), - ); - $item->setNextDueAt($next?->getTimestamp()); + $item->setNextDueAt($this->computeNextDueAt($item, $now)?->getTimestamp()); } } else { $item->setBought(false); @@ -222,6 +222,26 @@ class ShoppingListService { return $item; } + /** + * Compute the next due time for an item that was just marked bought. + * + * - "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 + * occurrence is the first one strictly after now on that anchored series. + */ + private function computeNextDueAt(ShoppingListItem $item, int $now): ?\DateTimeImmutable { + $rrule = $item->getRrule(); + if ($rrule === null) { + return null; + } + $nowDt = (new \DateTimeImmutable())->setTimestamp($now); + if ($item->getRepeatFromCompletion()) { + return $this->recurrence->computeNextOccurrence($rrule, $nowDt); + } + $anchor = (new \DateTimeImmutable())->setTimestamp($item->getCreatedAt() ?: $now); + return $this->recurrence->nextOccurrenceAfter($rrule, $anchor, $nowDt); + } + public function deleteItem(int $itemId): void { $item = $this->getItem($itemId); $this->itemMapper->delete($item); @@ -234,4 +254,20 @@ class ShoppingListService { $t = trim($v); return $t === '' ? null : $t; } + + private function intOrNull(mixed $v): ?int { + if ($v === null || $v === '' || $v === false) { + return null; + } + if (is_int($v)) { + return $v; + } + if (is_string($v) && ctype_digit($v)) { + return (int)$v; + } + if (is_numeric($v)) { + return (int)$v; + } + return null; + } } diff --git a/openapi.json b/openapi.json index 7d7061b..82c7edf 100644 --- a/openapi.json +++ b/openapi.json @@ -20,6 +20,50 @@ } }, "schemas": { + "Category": { + "type": "object", + "required": [ + "id", + "houseId", + "name", + "icon", + "color", + "sortOrder", + "createdAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "houseId": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "color": { + "type": "string" + }, + "sortOrder": { + "type": "integer", + "format": "int64" + }, + "createdAt": { + "type": "integer", + "format": "int64" + }, + "updatedAt": { + "type": "integer", + "format": "int64" + } + } + }, "House": { "type": "object", "required": [ @@ -119,12 +163,13 @@ "id", "listId", "name", - "category", + "categoryId", "quantity", "bought", "boughtAt", "boughtBy", "rrule", + "repeatFromCompletion", "nextDueAt", "sortOrder", "createdAt", @@ -142,8 +187,9 @@ "name": { "type": "string" }, - "category": { - "type": "string", + "categoryId": { + "type": "integer", + "format": "int64", "nullable": true }, "quantity": { @@ -166,6 +212,9 @@ "type": "string", "nullable": true }, + "repeatFromCompletion": { + "type": "boolean" + }, "nextDueAt": { "type": "integer", "format": "int64", @@ -260,6 +309,511 @@ } }, "paths": { + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/categories": { + "get": { + "operationId": "category-index", + "summary": "List all categories in a house", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of categories to return.", + "schema": { + "type": "integer", + "format": "int64", + "default": 100, + "minimum": 1, + "maximum": 500 + } + }, + { + "name": "offset", + "in": "query", + "description": "Number of categories to skip.", + "schema": { + "type": "integer", + "format": "int64", + "default": 0, + "minimum": 0 + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Categories returned", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Category" + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "category-create", + "summary": "Create a category", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "name", + "icon", + "color" + ], + "properties": { + "name": { + "type": "string", + "description": "Category name." + }, + "icon": { + "type": "string", + "description": "Icon key from the palette." + }, + "color": { + "type": "string", + "description": "Hex color (e.g. \"#4caf50\")." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Category created", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Category" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/pantry/api/houses/{houseId}/categories/{categoryId}": { + "patch": { + "operationId": "category-update", + "summary": "Update a category", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "nullable": true, + "default": null, + "description": "New name." + }, + "icon": { + "type": "string", + "nullable": true, + "default": null, + "description": "New icon key." + }, + "color": { + "type": "string", + "nullable": true, + "default": null, + "description": "New hex color." + }, + "sortOrder": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": null, + "description": "New sort order." + } + } + } + } + } + }, + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "categoryId", + "in": "path", + "description": "Category id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Category updated", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Category" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "category-destroy", + "summary": "Delete a category", + "description": "Detaches any items that reference it.", + "tags": [ + "category" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "houseId", + "in": "path", + "description": "House id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "categoryId", + "in": "path", + "description": "Category id.", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "Category deleted", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/Success" + } + } + } + } + } + } + } + }, + "401": { + "description": "Current user is not logged in", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/pantry/api/houses": { "get": { "operationId": "house-index", @@ -2350,11 +2904,12 @@ "type": "string", "description": "Item name." }, - "category": { - "type": "string", + "categoryId": { + "type": "integer", + "format": "int64", "nullable": true, "default": null, - "description": "Optional category label." + "description": "Optional category id (must belong to the same house)." }, "quantity": { "type": "string", @@ -2368,6 +2923,11 @@ "default": null, "description": "Optional RFC 5545 RRULE for recurrence." }, + "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." + }, "sortOrder": { "type": "integer", "format": "int64", @@ -2502,11 +3062,12 @@ "default": null, "description": "New name." }, - "category": { - "type": "string", + "categoryId": { + "type": "integer", + "format": "int64", "nullable": true, "default": null, - "description": "New category (empty string clears)." + "description": "New category id (0 or negative clears)." }, "quantity": { "type": "string", @@ -2520,6 +3081,12 @@ "default": null, "description": "New RRULE (empty string clears)." }, + "repeatFromCompletion": { + "type": "boolean", + "nullable": true, + "default": null, + "description": "New recurrence anchor mode." + }, "sortOrder": { "type": "integer", "format": "int64", diff --git a/src/api/categories.ts b/src/api/categories.ts new file mode 100644 index 0000000..01ae7ea --- /dev/null +++ b/src/api/categories.ts @@ -0,0 +1,28 @@ +import { ocs } from '@/axios' +import type { Category } from './types' + +export async function listCategories(houseId: number): Promise { + const resp = await ocs.get(`/houses/${houseId}/categories`) + return resp.data ?? [] +} + +export async function createCategory( + houseId: number, + input: { name: string; icon: string; color: string }, +): Promise { + const resp = await ocs.post(`/houses/${houseId}/categories`, input) + return resp.data +} + +export async function updateCategory( + houseId: number, + categoryId: number, + patch: { name?: string; icon?: string; color?: string; sortOrder?: number }, +): Promise { + const resp = await ocs.patch(`/houses/${houseId}/categories/${categoryId}`, patch) + return resp.data +} + +export async function deleteCategory(houseId: number, categoryId: number): Promise { + await ocs.delete(`/houses/${houseId}/categories/${categoryId}`) +} diff --git a/src/api/lists.ts b/src/api/lists.ts index 5df09b4..7cf47cb 100644 --- a/src/api/lists.ts +++ b/src/api/lists.ts @@ -43,9 +43,10 @@ export async function listItems(houseId: number, listId: number): Promise +
+ + + + + + + + +
+ + +
+ +
+ +
+
+ +
+ +
+
+
+ +

{{ createError }}

+ + +
+
+ + + + + diff --git a/src/components/RecurrenceEditor.vue b/src/components/RecurrenceEditor.vue index 7d5d0e4..7a45730 100644 --- a/src/components/RecurrenceEditor.vue +++ b/src/components/RecurrenceEditor.vue @@ -118,6 +118,16 @@
+ +
+ + {{ strings.fromCompletionLabel }} + +

{{ fromCompletionHint }}

+
+ +
+

@@ -144,6 +154,7 @@ import { t } from '@nextcloud/l10n' import NcDialog from '@nextcloud/vue/components/NcDialog' import NcButton from '@nextcloud/vue/components/NcButton' import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' import RepeatIcon from '@icons/Repeat.vue' import { Frequency, RRule, Weekday } from 'rrule' @@ -158,12 +169,19 @@ interface FreqOption { } // ---------- Props / emits ---------- -const props = defineProps<{ open: boolean; modelValue: string | null }>() +const props = defineProps<{ + open: boolean + modelValue: string | null + fromCompletion?: boolean +}>() const emit = defineEmits<{ (e: 'update:open', v: boolean): void (e: 'update:modelValue', v: string | null): void + (e: 'update:fromCompletion', v: boolean): void }>() +const fromCompletionLocal = ref(!!props.fromCompletion) + // ---------- Form state ---------- const frequencyOptions = computed(() => [ { label: t('pantry', 'days'), value: 'DAILY' }, @@ -400,16 +418,32 @@ const summaryText = computed(() => { watch( () => props.open, (isOpen) => { - if (isOpen) loadFromRrule(props.modelValue) + if (isOpen) { + loadFromRrule(props.modelValue) + fromCompletionLocal.value = !!props.fromCompletion + } }, { immediate: true }, ) +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.', + ) + : t( + 'pantry', + 'The schedule is fixed: the item reappears on its next scheduled occurrence, regardless of when you tick it off.', + ), +) + // ---------- Submit / clear ---------- function submit(): void { try { const raw = buildRrule() emit('update:modelValue', raw) + emit('update:fromCompletion', fromCompletionLocal.value) emit('update:open', false) } catch (e) { error.value = (e as Error).message || t('pantry', 'Invalid recurrence rule.') @@ -418,6 +452,7 @@ function submit(): void { function clear(): void { emit('update:modelValue', null) + emit('update:fromCompletion', false) emit('update:open', false) } @@ -434,6 +469,7 @@ const strings = { endAfter: t('pantry', 'After'), endAfterSuffix: t('pantry', 'occurrences'), endOn: t('pantry', 'On date'), + fromCompletionLabel: t('pantry', 'Count interval from when the item is ticked off'), summaryLabel: t('pantry', 'Summary:'), cancel: t('pantry', 'Cancel'), save: t('pantry', 'Save'), diff --git a/src/components/categoryIcons.ts b/src/components/categoryIcons.ts new file mode 100644 index 0000000..a910063 --- /dev/null +++ b/src/components/categoryIcons.ts @@ -0,0 +1,76 @@ +// Curated palette of category icons. The key is what we persist in the DB (kept in sync +// with CategoryService::ICON_KEYS on the backend); the component is resolved at render time. + +import type { Component } from 'vue' +import TagIcon from '@icons/Tag.vue' +import FoodIcon from '@icons/Food.vue' +import FruitIcon from '@icons/FoodApple.vue' +import VegetableIcon from '@icons/Carrot.vue' +import BakeryIcon from '@icons/BreadSlice.vue' +import DairyIcon from '@icons/Cheese.vue' +import MeatIcon from '@icons/FoodDrumstick.vue' +import FishIcon from '@icons/Fish.vue' +import SnacksIcon from '@icons/FoodCroissant.vue' +import CookieIcon from '@icons/Cookie.vue' +import DrinksIcon from '@icons/BottleWine.vue' +import CoffeeIcon from '@icons/Coffee.vue' +import FrozenIcon from '@icons/Snowflake.vue' +import HouseholdIcon from '@icons/Broom.vue' +import PetsIcon from '@icons/Dog.vue' +import BabyIcon from '@icons/Baby.vue' +import HomeIcon from '@icons/Home.vue' +import LeafIcon from '@icons/Leaf.vue' +import PizzaIcon from '@icons/Pizza.vue' + +export interface CategoryIconOption { + key: string + label: string + component: Component +} + +/** The default fallback icon used for unknown keys. */ +export const DEFAULT_CATEGORY_ICON_KEY = 'tag' + +export const CATEGORY_ICONS: CategoryIconOption[] = [ + { key: 'tag', label: 'Tag', component: TagIcon }, + { key: 'food', label: 'Food', component: FoodIcon }, + { key: 'fruit', label: 'Fruit', component: FruitIcon }, + { key: 'vegetable', label: 'Vegetable', component: VegetableIcon }, + { key: 'bakery', label: 'Bakery', component: BakeryIcon }, + { key: 'dairy', label: 'Dairy', component: DairyIcon }, + { key: 'meat', label: 'Meat', component: MeatIcon }, + { key: 'fish', label: 'Fish', component: FishIcon }, + { key: 'snacks', label: 'Snacks', component: SnacksIcon }, + { key: 'cookie', label: 'Sweets', component: CookieIcon }, + { key: 'drinks', label: 'Drinks', component: DrinksIcon }, + { key: 'coffee', label: 'Coffee', component: CoffeeIcon }, + { key: 'frozen', label: 'Frozen', component: FrozenIcon }, + { key: 'household', label: 'Household', component: HouseholdIcon }, + { key: 'pets', label: 'Pets', component: PetsIcon }, + { key: 'baby', label: 'Baby', component: BabyIcon }, + { key: 'home', label: 'Home', component: HomeIcon }, + { key: 'leaf', label: 'Leaf', component: LeafIcon }, + { key: 'pizza', label: 'Pizza', component: PizzaIcon }, +] + +const byKey: Record = Object.fromEntries( + CATEGORY_ICONS.map((o) => [o.key, o]), +) + +export function categoryIconComponent(key: string | null | undefined): Component { + return byKey[key ?? '']?.component ?? TagIcon +} + +/** Default palette of colors shown in the inline create dialog. */ +export const CATEGORY_COLORS: string[] = [ + '#ef4444', // red + '#f97316', // orange + '#eab308', // yellow + '#22c55e', // green + '#14b8a6', // teal + '#0ea5e9', // sky + '#6366f1', // indigo + '#a855f7', // purple + '#ec4899', // pink + '#78716c', // stone +] diff --git a/src/composables/useCategories.ts b/src/composables/useCategories.ts new file mode 100644 index 0000000..ff13110 --- /dev/null +++ b/src/composables/useCategories.ts @@ -0,0 +1,63 @@ +import { ref } from 'vue' +import * as api from '@/api/categories' +import type { Category } from '@/api/types' + +// Cache per house id so multiple components sharing the same house stay in sync. +const cache = new Map>() + +function build(houseId: number) { + const items = ref([]) + const loading = ref(false) + const error = ref(null) + const loaded = ref(false) + + async function load(force = false): Promise { + if (loaded.value && !force) return + loading.value = true + error.value = null + try { + items.value = await api.listCategories(houseId) + loaded.value = true + } catch (e) { + error.value = (e as Error).message + } finally { + loading.value = false + } + } + + async function create(input: { name: string; icon: string; color: string }): Promise { + const created = await api.createCategory(houseId, input) + items.value = [...items.value, created].sort((a, b) => a.name.localeCompare(b.name)) + return created + } + + async function update( + id: number, + patch: Parameters[2], + ): Promise { + const updated = await api.updateCategory(houseId, id, patch) + items.value = items.value.map((c) => (c.id === id ? updated : c)) + return updated + } + + async function remove(id: number): Promise { + await api.deleteCategory(houseId, id) + items.value = items.value.filter((c) => c.id !== id) + } + + function findById(id: number | null | undefined): Category | undefined { + if (id == null) return undefined + return items.value.find((c) => c.id === id) + } + + return { items, loading, error, loaded, load, create, update, remove, findById } +} + +export function useCategories(houseId: number) { + let entry = cache.get(houseId) + if (!entry) { + entry = build(houseId) + cache.set(houseId, entry) + } + return entry +} diff --git a/src/views/ShoppingListDetail.vue b/src/views/ShoppingListDetail.vue index 910ddbe..4c49366 100644 --- a/src/views/ShoppingListDetail.vue +++ b/src/views/ShoppingListDetail.vue @@ -25,10 +25,10 @@ :label="strings.quantityLabel" :placeholder="strings.quantityPlaceholder" /> - @@ -111,7 +122,10 @@ import DeleteIcon from '@icons/Delete.vue' import RepeatIcon from '@icons/Repeat.vue' import CartIcon from '@icons/Cart.vue' import RecurrenceEditor from '@/components/RecurrenceEditor.vue' +import CategoryPicker from '@/components/CategoryPicker.vue' +import { categoryIconComponent } from '@/components/categoryIcons' import { useShoppingListItems } from '@/composables/useShoppingList' +import { useCategories } from '@/composables/useCategories' import { getList } from '@/api/lists' import type { ShoppingList } from '@/api/types' import { RRule } from 'rrule' @@ -126,11 +140,17 @@ const { items, loading, load, add, toggle, remove } = useShoppingListItems( houseIdNum.value, listIdNum.value, ) +const categories = useCategories(houseIdNum.value) + +function categoryFor(id: number | null) { + return categories.findById(id) ?? null +} const newName = ref('') const newQuantity = ref('') -const newCategory = ref('') +const newCategoryId = ref(null) const newRrule = ref(null) +const newRepeatFromCompletion = ref(false) const adding = ref(false) const showRecurrenceEditor = ref(false) @@ -139,7 +159,7 @@ async function loadList() { } onMounted(async () => { - await Promise.all([loadList(), load()]) + await Promise.all([loadList(), load(), categories.load()]) }) watch( @@ -165,13 +185,15 @@ async function submitAdd() { await add({ name, quantity: newQuantity.value.trim() || null, - category: newCategory.value.trim() || null, + categoryId: newCategoryId.value, rrule: newRrule.value, + repeatFromCompletion: newRepeatFromCompletion.value, }) newName.value = '' newQuantity.value = '' - newCategory.value = '' + newCategoryId.value = null newRrule.value = null + newRepeatFromCompletion.value = false } finally { adding.value = false } @@ -197,12 +219,11 @@ function formatRrule(rrule: string): string { const strings = { back: t('pantry', 'Back to lists'), add: t('pantry', 'Add'), - newItemLabel: t('pantry', 'Item:'), + newItemLabel: t('pantry', 'Item name'), newItemPlaceholder: t('pantry', 'e.g. Milk'), - quantityLabel: t('pantry', 'Quantity:'), + quantityLabel: t('pantry', 'Quantity'), quantityPlaceholder: t('pantry', 'e.g. 2 L'), - categoryLabel: t('pantry', 'Category:'), - categoryPlaceholder: t('pantry', 'e.g. Dairy'), + categoryLabel: t('pantry', 'Category'), recurrenceButton: t('pantry', 'Repeat …'), recurrenceSet: t('pantry', 'Repeat: set'), removeItem: t('pantry', 'Remove item'), diff --git a/src/views/SideNavigation.vue b/src/views/SideNavigation.vue index 24198f4..70e6da1 100644 --- a/src/views/SideNavigation.vue +++ b/src/views/SideNavigation.vue @@ -9,6 +9,7 @@