#80 — Categories for Shopping Lists and Todo Lists #80
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#80
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
#80— Categories for Shopping Lists and Todo ListsProblem
Items in Shopping Lists and Todo Lists form a flat, unstructured sequence. For a shopping list there is no way to group items by supermarket section (produce, dairy, frozen). For a todo list there is no way to group related tasks under a named heading. Long lists become hard to scan.
Acceptance Criteria
Category Model
Create
Edit
Delete
Reorder Categories (Drag-and-Drop)
Reorder and Reassign Items (Drag-and-Drop)
Shopping List
ShoppingCategoryEntityto allow future extensions independent of todo categories.Todo List
TodoCategoryEntity.Out of Scope
Priority
Should — Foundational grouping feature; required before Shopping List (
#78) can include full item organization.Decisions
ShoppingCategoryEntity/TodoCategoryEntity): Shopping List categories will gain advanced future features; a shared entity would constrain that evolution.design (
80_categories_for_lists_design.md)Design:
#80Categories for Todo ListsArchitect note, 2026-07-24
Scope adjustment from the drafted story
The drafted story text (from the 2026-07-24 PO session) describes categories for both Todo
Lists and Shopping Lists, including a dedicated
ShoppingCategoryEntity. But no Shopping Listdomain exists yet — it's introduced by
#81, which the roadmap itself lists as blocked by#80. ImplementingShoppingCategoryEntityhere would mean building it against aShoppingListEntitythat doesn't exist.Decision: this cycle implements
TodoCategoryEntityonly.ShoppingCategoryEntityis deferredto
#81, where the Shopping List domain is introduced anyway — at that point it's a naturalsibling to add, following the exact pattern established here.
Backend
TodoCategoryEntity(new):Id,TodoListId(FK, cascade),Name(CategoryName— newVogen string type, 40-char max, mirrors
LabelName),Icon(plainstring?, not Vogen — noexisting "emoji" value-object precedent to extend; length-guarded at 8 chars in
CategoryIconValidationinstead, since Vogen's automatic validation isn't available for a plainstring field),
IsDefault(bool),SortOrder(int, list-scoped).TodoEntity.CategoryId(new): nullableTodoCategoryId?FK,OnDelete(Restrict)— acategory can only be removed via
DeleteCategoryCommand, which explicitly reassigns or deletesaffected todos first inside one transaction; a stray direct delete of the category row must fail
loudly rather than silently orphaning todos.
TodoEntity.SortOrderis rescoped from list-wide to per-category. This is the one existingfeature (
#17todo reordering) this story reopens:ReorderTodosCommandnow takes aTodoCategoryIdand only reorders within that category; a newAssignTodoCategoryCommandhandles moving a todo to a different category (closing the gap in the old category, opening
one at the requested position in the new one, in a single pass over each category's rows).
Without this change, a list with more than one category would have broken drag-reordering — two
categories both starting their own
SortOrderat 0 makes a single list-wideORDER BY SortOrdermeaningless. The migration's trigger rewrite and backfill (below) account for this.
AddTodoCategoryEntity): createsTodoCategoryEntity, addsTodoEntity.CategoryId, then a data backfill — every existing list gets one'General'defaultcategory, every existing todo in that list is assigned to it. Existing todos' current SortOrder
values need no separate renumbering: they're already a valid 0..N-1 sequence per list, and since
every one of them lands in the same single new category, that sequence is also already valid
as a per-category sequence. The
set_todo_nr_per_listtrigger function is rewritten to seed newtodos'
SortOrderfromMAX(SortOrder) WHERE CategoryId = NEW.CategoryIdinstead ofNr - 1.CreateTodoCommandgains an optionalCategoryId; when omitted, the handler resolves it tothe list's default category.
CreateTodoListCommandnow also inserts that default categoryrow directly (not through
CreateCategoryCommand, which always setsIsDefault: false— onlylist-creation is allowed to seed the very first default, keeping "exactly one default per list"
enforced in exactly one place:
SetDefaultCategoryCommand).CheckTodoCommandHandlercarriesCategoryIdforward onto a recurring todo's nextoccurrence, alongside the priority/assignee/labels/checklist carry-forward it already does.
LabelChangeBroadcast(rename/delete re-broadcastingaffected todos'
TodoDto) was an exact duplicate of whatDeleteCategoryCommand's reassign pathalso needed. Generalized into
Features/Todos/TodoChangeBroadcast.cs;RenameLabelCommandHandlerand
DeleteLabelCommandHandlernow call the shared helper instead of a Labels-only copy.CategoryDto. Categories are list-scoped, fetchedonce via
GetCategoriesForListQuery— matching Labels' existing precedent (no dedicatedChange<...>subject either). The frontend refetches after any mutation instead.Frontend
TodoList.tsx), each with its ownDndContext/SortableContext— dragging reorders within that category only.and drag-and-drop cross-category todo reassignment. This was built in a sandbox with no live
browser and no Docker (so no Playwright e2e run) — meaning a drag gesture can be written but
never actually watched working. Rather than ship unverifiable pointer-event wiring:
CategoryManager.tsx) uses up/down buttons — afireEvent-testableequivalent of the same
ReorderCategoriesCommanda drag would call.CategoryPicker.tsx) is a dropdown (opened from thetodo's existing overflow menu), calling the same
AssignTodoCategoryCommanda cross-sectiondrag would call, with
sortOrder: Number.MAX_SAFE_INTEGER(the backend clamps toappend-at-end).
Both are real, working, fully-tested alternate paths to the same backend commands — not stubs.
True drag-and-drop for both is a good follow-up once this can be verified in a real browser.
CategoryManager.tsx(create/rename/delete-with-reassign-or-delete-items/set-default/reorder),reachable from
TodoListHeader's existing "⋯ Options" menu (new "Categories" item), matching theEmailNotificationDialogprecedent already in that same menu.TodoItem.tsxgains a "Category: {name}" overflow-menu item (hidden when no categories exist,e.g. a list not yet migrated in an old snapshot) opening
CategoryPicker.CategoryManager'sonChangecallbacknotifies
TodoList.tsx(the source of truth for the grouped view +CategoryPicker's options)to refetch after every mutation — otherwise the grouped view/picker would show stale data after
a rename/delete/reorder performed through the manager dialog.
"uncategorized" when
categoryIdwas literallynull. A todo whosecategoryIdreferences acategory the (separately-fetched) categories list doesn't currently know about — e.g. a stale
snapshot racing a concurrent delete — would have silently vanished from the UI instead of falling
back to the uncategorized bucket. Fixed before this shipped, not caught by review afterward.
Security
CategoryName/Iconare plain user text rendered via JSX (auto-escaped, nodangerouslySetInnerHTML) — no XSS surface.Iconhad no length bound at all in the first pass (not a Vogen type, unlike every otheruser-text field in this codebase) — added
CategoryIconValidation(8-char cap) before thisshipped.
DeleteCategoryCommand'sRestrictFK behavior is the safety net against a category row beingremoved while todos still reference it, even if application logic has a bug — the database
itself refuses.
AuthorizeTodoListAccessForCurrentUserQuery+AuthorizeTodoListIsNotArchivedQuerypair on every Category handler, identical to every existingTodo/Label handler.
Blockers
None (this is the wave-7 unblocking story;
#81is blocked by this one).