feat: repeat from completion, categories table, navigation fixes

This commit is contained in:
2026-04-05 23:13:12 +03:00
parent 6ba2999857
commit cac1159588
24 changed files with 1910 additions and 63 deletions

View File

@@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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<Http::STATUS_OK, list<PantryCategory>, 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<Http::STATUS_OK, PantryCategory, array{}>
*
* 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<Http::STATUS_OK, PantryCategory, array{}>
*
* 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<Http::STATUS_OK, PantrySuccess, array{}>
*
* 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();
}
}

View File

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

56
lib/Db/Category.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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,
];
}
}

78
lib/Db/CategoryMapper.php Normal file
View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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<Category>
*/
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();
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// 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);
}
}

View File

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

View File

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

View File

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

View File

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

28
src/api/categories.ts Normal file
View File

@@ -0,0 +1,28 @@
import { ocs } from '@/axios'
import type { Category } from './types'
export async function listCategories(houseId: number): Promise<Category[]> {
const resp = await ocs.get<Category[]>(`/houses/${houseId}/categories`)
return resp.data ?? []
}
export async function createCategory(
houseId: number,
input: { name: string; icon: string; color: string },
): Promise<Category> {
const resp = await ocs.post<Category>(`/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<Category> {
const resp = await ocs.patch<Category>(`/houses/${houseId}/categories/${categoryId}`, patch)
return resp.data
}
export async function deleteCategory(houseId: number, categoryId: number): Promise<void> {
await ocs.delete(`/houses/${houseId}/categories/${categoryId}`)
}

View File

@@ -43,9 +43,10 @@ export async function listItems(houseId: number, listId: number): Promise<Shoppi
export interface ItemInput {
name: string
category?: string | null
categoryId?: number | null
quantity?: string | null
rrule?: string | null
repeatFromCompletion?: boolean
sortOrder?: number
}

View File

@@ -29,16 +29,28 @@ export interface ShoppingList {
updatedAt: number
}
export interface Category {
id: number
houseId: number
name: string
icon: string
color: string
sortOrder: number
createdAt: number
updatedAt: number
}
export interface ShoppingListItem {
id: number
listId: number
name: string
category: string | null
categoryId: number | null
quantity: string | null
bought: boolean
boughtAt: number | null
boughtBy: string | null
rrule: string | null
repeatFromCompletion: boolean
nextDueAt: number | null
sortOrder: number
createdAt: number

View File

@@ -0,0 +1,358 @@
<template>
<div class="pantry-category-picker">
<label v-if="label" class="pantry-category-picker__label">{{ label }}</label>
<NcSelect
v-model="selected"
:options="options"
:clearable="true"
:placeholder="placeholder ?? strings.placeholder"
:input-label="''"
label="label"
@option:selected="onSelect"
>
<template #option="option">
<div class="pantry-category-option">
<template v-if="option.create">
<PlusIcon
:size="18"
class="pantry-category-option__icon pantry-category-option__icon--create"
/>
<span class="pantry-category-option__name">{{ option.label }}</span>
</template>
<template v-else-if="option.category">
<span class="pantry-category-option__icon" :style="{ color: option.category.color }">
<component :is="iconFor(option.category.icon)" :size="18" />
</span>
<span class="pantry-category-option__name">{{ option.category.name }}</span>
</template>
<span v-else>{{ option.label }}</span>
</div>
</template>
<template #selected-option="option">
<div class="pantry-category-option">
<span
v-if="option.category"
class="pantry-category-option__icon"
:style="{ color: option.category.color }"
>
<component :is="iconFor(option.category.icon)" :size="18" />
</span>
<span>{{ option.label }}</span>
</div>
</template>
</NcSelect>
<NcDialog
v-if="showCreate"
:name="strings.createTitle"
:open="showCreate"
@update:open="showCreate = $event"
>
<form class="pantry-create-cat" @submit.prevent="submitCreate">
<NcTextField
v-model="newName"
:label="strings.nameLabel"
:placeholder="strings.namePlaceholder"
/>
<div>
<label class="pantry-create-cat__sub">{{ strings.iconLabel }}</label>
<div class="pantry-create-cat__icon-grid">
<button
v-for="opt in CATEGORY_ICONS"
:key="opt.key"
type="button"
class="pantry-create-cat__icon-button"
:class="{ 'pantry-create-cat__icon-button--active': newIcon === opt.key }"
:title="opt.label"
:style="{ color: newColor }"
@click="newIcon = opt.key"
>
<component :is="opt.component" :size="20" />
</button>
</div>
</div>
<div>
<label class="pantry-create-cat__sub">{{ strings.colorLabel }}</label>
<div class="pantry-create-cat__color-grid">
<button
v-for="c in CATEGORY_COLORS"
:key="c"
type="button"
class="pantry-create-cat__color-swatch"
:class="{ 'pantry-create-cat__color-swatch--active': newColor === c }"
:style="{ backgroundColor: c }"
:aria-label="c"
@click="newColor = c"
/>
</div>
</div>
<p v-if="createError" class="pantry-create-cat__error">{{ createError }}</p>
</form>
<template #actions>
<NcButton @click="showCreate = false">{{ strings.cancel }}</NcButton>
<NcButton variant="primary" :disabled="saving || !newName.trim()" @click="submitCreate">
{{ saving ? strings.saving : strings.create }}
</NcButton>
</template>
</NcDialog>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { t } from '@nextcloud/l10n'
import NcSelect from '@nextcloud/vue/components/NcSelect'
import NcDialog from '@nextcloud/vue/components/NcDialog'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcButton from '@nextcloud/vue/components/NcButton'
import PlusIcon from '@icons/Plus.vue'
import { useCategories } from '@/composables/useCategories'
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
DEFAULT_CATEGORY_ICON_KEY,
categoryIconComponent,
} from '@/components/categoryIcons'
import type { Category } from '@/api/types'
const props = defineProps<{
houseId: number
modelValue: number | null
label?: string
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', v: number | null): void
}>()
const { items, load, create } = useCategories(props.houseId)
interface SelectOption {
label: string
id?: number
category?: Category
create?: boolean
}
onMounted(() => {
void load()
})
watch(
() => props.houseId,
() => {
// different house's composable; trigger a load on the fresh one
void useCategories(props.houseId).load()
},
)
const options = computed<SelectOption[]>(() => {
const categoryOptions: SelectOption[] = items.value.map((c) => ({
label: c.name,
id: c.id,
category: c,
}))
return [...categoryOptions, { label: t('pantry', 'Create new category …'), create: true }]
})
const selected = computed<SelectOption | null>({
get() {
if (props.modelValue == null) return null
const cat = items.value.find((c) => c.id === props.modelValue)
if (!cat) return null
return { label: cat.name, id: cat.id, category: cat }
},
set(v) {
if (!v) {
emit('update:modelValue', null)
return
}
if (v.create) {
// Handled in onSelect (v-model would flash the "create" label).
return
}
emit('update:modelValue', v.id ?? null)
},
})
function onSelect(opt: SelectOption | SelectOption[] | null): void {
const picked = Array.isArray(opt) ? opt[0] : opt
if (picked && picked.create) {
openCreate()
}
}
// ----- create dialog -----
const showCreate = ref(false)
const newName = ref('')
const newIcon = ref<string>(DEFAULT_CATEGORY_ICON_KEY)
const newColor = ref<string>(CATEGORY_COLORS[3]!)
const saving = ref(false)
const createError = ref<string | null>(null)
function openCreate() {
// Reset the NcSelect so it doesn't stay on the "Create new …" ghost option.
selected.value = selected.value?.category ? selected.value : null
newName.value = ''
newIcon.value = DEFAULT_CATEGORY_ICON_KEY
newColor.value = CATEGORY_COLORS[3]!
createError.value = null
showCreate.value = true
}
async function submitCreate() {
const name = newName.value.trim()
if (!name) return
saving.value = true
createError.value = null
try {
const created = await create({
name,
icon: newIcon.value,
color: newColor.value,
})
emit('update:modelValue', created.id)
showCreate.value = false
} catch (e) {
createError.value = (e as Error).message || t('pantry', 'Could not create category.')
} finally {
saving.value = false
}
}
function iconFor(key: string) {
return categoryIconComponent(key)
}
const strings = {
placeholder: t('pantry', 'Pick a category'),
createTitle: t('pantry', 'New category'),
nameLabel: t('pantry', 'Name:'),
namePlaceholder: t('pantry', 'e.g. Produce, Dairy'),
iconLabel: t('pantry', 'Icon:'),
colorLabel: t('pantry', 'Color:'),
create: t('pantry', 'Create'),
saving: t('pantry', 'Saving …'),
cancel: t('pantry', 'Cancel'),
}
</script>
<style scoped lang="scss">
.pantry-category-picker {
display: flex;
flex-direction: column;
gap: 0.25rem;
&__label {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
}
}
.pantry-category-option {
display: flex;
align-items: center;
gap: 0.5rem;
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--create {
color: var(--color-primary-element);
}
}
&__name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.pantry-create-cat {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.5rem 0;
min-width: 340px;
&__sub {
display: block;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text-maxcontrast);
margin-bottom: 0.35rem;
}
&__icon-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(42px, 1fr));
gap: 0.35rem;
}
&__icon-button {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--color-border);
border-radius: var(--border-radius, 8px);
background: var(--color-main-background);
cursor: pointer;
transition: all 0.15s ease;
&:hover {
background: var(--color-background-hover);
}
&--active {
border-color: currentColor;
box-shadow: 0 0 0 2px currentColor;
}
}
&__color-grid {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
&__color-swatch {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s ease;
&:hover {
transform: scale(1.08);
}
&--active {
border-color: var(--color-main-text);
transform: scale(1.1);
}
}
&__error {
margin: 0;
color: var(--color-error);
}
}
@media (max-width: 500px) {
.pantry-create-cat {
min-width: 0;
}
}
</style>

View File

@@ -118,6 +118,16 @@
<hr class="pantry-recurrence__divider" />
<!-- Anchor mode toggle -->
<section class="pantry-recurrence__section">
<NcCheckboxRadioSwitch v-model="fromCompletionLocal" type="switch">
{{ strings.fromCompletionLabel }}
</NcCheckboxRadioSwitch>
<p class="pantry-recurrence__hint">{{ fromCompletionHint }}</p>
</section>
<hr class="pantry-recurrence__divider" />
<section class="pantry-recurrence__section">
<p class="pantry-recurrence__summary">
<RepeatIcon :size="16" />
@@ -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<boolean>(!!props.fromCompletion)
// ---------- Form state ----------
const frequencyOptions = computed<FreqOption[]>(() => [
{ label: t('pantry', 'days'), value: 'DAILY' },
@@ -400,16 +418,32 @@ const summaryText = computed<string>(() => {
watch(
() => props.open,
(isOpen) => {
if (isOpen) loadFromRrule(props.modelValue)
if (isOpen) {
loadFromRrule(props.modelValue)
fromCompletionLocal.value = !!props.fromCompletion
}
},
{ immediate: true },
)
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.',
)
: 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'),

View File

@@ -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<string, CategoryIconOption> = 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
]

View File

@@ -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<number, ReturnType<typeof build>>()
function build(houseId: number) {
const items = ref<Category[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
const loaded = ref(false)
async function load(force = false): Promise<void> {
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<Category> {
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<typeof api.updateCategory>[2],
): Promise<Category> {
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<void> {
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
}

View File

@@ -25,10 +25,10 @@
:label="strings.quantityLabel"
:placeholder="strings.quantityPlaceholder"
/>
<NcTextField
v-model="newCategory"
<CategoryPicker
v-model="newCategoryId"
:house-id="houseIdNum"
:label="strings.categoryLabel"
:placeholder="strings.categoryPlaceholder"
/>
<NcButton variant="tertiary" @click="showRecurrenceEditor = true">
<template #icon>
@@ -73,7 +73,14 @@
</NcCheckboxRadioSwitch>
<div class="pantry-item__meta">
<span v-if="item.quantity" class="pantry-item__quantity">{{ item.quantity }}</span>
<span v-if="item.category" class="pantry-item__category">{{ item.category }}</span>
<span
v-if="categoryFor(item.categoryId)"
class="pantry-item__category"
:style="{ color: categoryFor(item.categoryId)!.color }"
>
<component :is="categoryIconComponent(categoryFor(item.categoryId)!.icon)" :size="14" />
{{ categoryFor(item.categoryId)!.name }}
</span>
<span v-if="item.rrule" class="pantry-item__recurrence" :title="item.rrule">
<RepeatIcon :size="14" />
{{ formatRrule(item.rrule) }}
@@ -93,7 +100,11 @@
</li>
</ul>
<RecurrenceEditor v-model:open="showRecurrenceEditor" v-model="newRrule" />
<RecurrenceEditor
v-model:open="showRecurrenceEditor"
v-model="newRrule"
v-model:from-completion="newRepeatFromCompletion"
/>
</div>
</template>
@@ -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<number | null>(null)
const newRrule = ref<string | null>(null)
const newRepeatFromCompletion = ref<boolean>(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'),

View File

@@ -9,6 +9,7 @@
<NcAppNavigationItem
:name="strings.lists"
:to="{ name: 'lists', params: { houseId: String(currentHouseId) } }"
:active="isNavActive(['lists', 'list-detail', 'list-'])"
>
<template #icon>
<CartIcon :size="20" />
@@ -18,6 +19,7 @@
<NcAppNavigationItem
:name="strings.photos"
:to="{ name: 'photos', params: { houseId: String(currentHouseId) } }"
:active="isNavActive(['photos', 'photo-'])"
>
<template #icon>
<ImageIcon :size="20" />
@@ -27,6 +29,7 @@
<NcAppNavigationItem
:name="strings.notes"
:to="{ name: 'notes', params: { houseId: String(currentHouseId) } }"
:active="isNavActive(['notes', 'note-'])"
>
<template #icon>
<NoteIcon :size="20" />
@@ -36,6 +39,7 @@
<NcAppNavigationItem
:name="strings.members"
:to="{ name: 'members', params: { houseId: String(currentHouseId) } }"
:active="isNavActive(['members', 'member-'])"
>
<template #icon>
<AccountGroupIcon :size="20" />
@@ -46,6 +50,7 @@
v-if="canAdmin"
:name="strings.houseSettings"
:to="{ name: 'house-settings', params: { houseId: String(currentHouseId) } }"
:active="isNavActive(['house-settings'])"
>
<template #icon>
<CogIcon :size="20" />
@@ -168,6 +173,17 @@ const currentHouseId = computed<number | null>(() => {
const house = computed(() =>
currentHouseId.value !== null ? findById(currentHouseId.value) : undefined,
)
/**
* Prefix-based route matcher for sidebar items. An item is active when the
* current route name equals any of the given prefixes, or starts with one of
* them (so sub-pages like `list-detail` highlight their parent `lists` item).
*/
function isNavActive(prefixes: string[]): boolean {
const name = typeof route.name === 'string' ? route.name : ''
if (!name) return false
return prefixes.some((p) => name === p || name.startsWith(p))
}
const canAdmin = computed(() => {
const role = house.value?.role
return role === 'owner' || role === 'admin'

View File

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
bootstrap="bootstrap.php"
timeoutForSmallTests="900"
timeoutForMediumTests="900"
timeoutForLargeTests="900"
cacheDirectory=".phpunit.cache"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
colors="true">
<testsuites>
<testsuite name="Oantry Integration Tests">
<directory suffix="Test.php">integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">../appinfo</directory>
<directory suffix=".php">../lib</directory>
</include>
</source>
</phpunit>

View File

@@ -37,12 +37,13 @@ class ShoppingListServiceTest extends TestCase {
$item = new ShoppingListItem();
$item->setListId($overrides['listId'] ?? 1);
$item->setName($overrides['name'] ?? 'Milk');
$item->setCategory($overrides['category'] ?? null);
$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->setRrule($overrides['rrule'] ?? null);
$item->setRepeatFromCompletion($overrides['repeatFromCompletion'] ?? false);
$item->setNextDueAt($overrides['nextDueAt'] ?? null);
$item->setSortOrder($overrides['sortOrder'] ?? 0);
$item->setCreatedAt($overrides['createdAt'] ?? 0);
@@ -95,18 +96,40 @@ class ShoppingListServiceTest extends TestCase {
$this->assertNull($toggled->getNextDueAt());
}
public function testToggleItemOnRecurringComputesNextDue(): void {
public function testToggleItemFromCompletionModeComputesNextDueFromNow(): void {
$now = 1_700_000_000; // 2023-11-14 22:13:20 UTC
$item = $this->makeItem(['rrule' => 'FREQ=WEEKLY']);
$item = $this->makeItem([
'rrule' => 'FREQ=WEEKLY',
'repeatFromCompletion' => true,
'createdAt' => $now - 86400 * 30, // irrelevant in this mode
]);
$this->itemMapper->method('findById')->willReturn($item);
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
$toggled = $this->svc->toggleItem(42, 'alice', $now);
$this->assertTrue($toggled->getBought());
$this->assertNotNull($toggled->getNextDueAt());
$this->assertSame($now + 7 * 86400, $toggled->getNextDueAt());
}
public function testToggleItemFixedScheduleModeComputesFromCreatedAtAnchor(): void {
// createdAt is a Monday at 00:00 UTC, and we tick off on the following Wednesday.
$anchor = strtotime('2026-04-06 00:00:00 UTC'); // Monday
$now = strtotime('2026-04-08 10:00:00 UTC'); // Wednesday
$expected = strtotime('2026-04-13 00:00:00 UTC'); // next Monday
$item = $this->makeItem([
'rrule' => 'FREQ=WEEKLY',
'repeatFromCompletion' => false,
'createdAt' => $anchor,
]);
$this->itemMapper->method('findById')->willReturn($item);
$this->itemMapper->expects($this->once())->method('update')->willReturn($item);
$toggled = $this->svc->toggleItem(42, 'alice', $now);
$this->assertTrue($toggled->getBought());
$this->assertSame($expected, $toggled->getNextDueAt());
}
public function testToggleItemCheckingOffClearsEverything(): void {
$item = $this->makeItem([
'bought' => true,