mirror of
https://github.com/chenasraf/nextcloud-pantry.git
synced 2026-05-17 17:28:01 +00:00
feat: repeat from completion, categories table, navigation fixes
This commit is contained in:
158
lib/Controller/CategoryController.php
Normal file
158
lib/Controller/CategoryController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
56
lib/Db/Category.php
Normal 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
78
lib/Db/CategoryMapper.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
149
lib/Service/CategoryService.php
Normal file
149
lib/Service/CategoryService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
585
openapi.json
585
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",
|
||||
|
||||
28
src/api/categories.ts
Normal file
28
src/api/categories.ts
Normal 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}`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
358
src/components/CategoryPicker.vue
Normal file
358
src/components/CategoryPicker.vue
Normal 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>
|
||||
@@ -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'),
|
||||
|
||||
76
src/components/categoryIcons.ts
Normal file
76
src/components/categoryIcons.ts
Normal 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
|
||||
]
|
||||
63
src/composables/useCategories.ts
Normal file
63
src/composables/useCategories.ts
Normal 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
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'
|
||||
|
||||
0
tests/integration/.gitkeep
Normal file
0
tests/integration/.gitkeep
Normal file
21
tests/phpunit.integration.xml
Normal file
21
tests/phpunit.integration.xml
Normal 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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user