#80 — Categories for Shopping Lists and Todo Lists #80

Closed
opened 2026-08-18 13:13:39 +02:00 by lena · 1 comment
lena commented 2026-08-18 13:13:39 +02:00 (Migrated from git.butzei.de)

#80 — Categories for Shopping Lists and Todo Lists

Problem

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

  • A Category belongs to exactly one list and has: a name (required) and an emoji icon (optional, chosen via an emoji picker).
  • Every list has exactly one default category, flagged as such. New items are placed into the default category when no explicit category is chosen.
  • The default category is reassignable — any existing category can be promoted to default.
  • The default category cannot be deleted. The user must reassign the default to another category first.
  • A list must always have at least one category (the last remaining category cannot be deleted).

Create

  • The user can create a new category by providing a name and optionally picking an emoji.
  • When a list is first created, one default category is created automatically (e.g., named after the list or a locale-appropriate default name).
  • New categories are appended at the bottom of the category list.

Edit

  • The user can rename a category and change its emoji at any time.
  • The user can mark any non-default category as the new default. The previous default is demoted (but otherwise unchanged).

Delete

  • Attempting to delete the default category shows an error or a blocked state: "Reassign the default category before deleting this one."
  • Deleting a non-default category shows a confirmation dialog with two choices:
    1. Move items to [default category name] — items keep their place in the list, assigned to the default.
    2. Delete items too — items are permanently removed from the list.

Reorder Categories (Drag-and-Drop)

  • Categories can be reordered by drag-and-drop within the list.

Reorder and Reassign Items (Drag-and-Drop)

  • Items within a category are manually sorted by drag-and-drop.
  • Dragging an item into a different category's drop zone reassigns it to that category and inserts it at the drop position.

Shopping List

  • Shopping List categories represent supermarket sections or aisles (e.g. Produce, Dairy, Frozen).
  • Implemented as a dedicated ShoppingCategoryEntity to allow future extensions independent of todo categories.

Todo List

  • Todo List categories represent task groups or phases (e.g. Planning, In Progress, ✅ Done).
  • Implemented as a dedicated TodoCategoryEntity.
  • Existing todos without an explicit category are migrated into the list's (auto-created) default category.

Out of Scope

  • Nested categories (categories within categories).
  • Sharing or reusing categories across lists.
  • Category-based filtering or search.
  • Category color coding (emoji covers visual differentiation).
  • Category templates or presets.

Priority

Should — Foundational grouping feature; required before Shopping List (#78) can include full item organization.

Decisions

  • Emoji icon (optional): Universal, no extra icon library dependency, works across all platforms. A name alone is sufficient if the user skips the emoji.
  • Default category (not an implicit "none" group): Gives the user full control over where uncategorized items sit in the layout. Avoids a ghost "Uncategorized" section at a fixed position. Items always belong to a real, named, reorderable category.
  • Default is reassignable but deletion-protected: Ensures there is always a valid landing zone for new items and for the "move items" path in the delete dialog, without extra null-handling in the backend.
  • Per-list categories: Different lists (groceries vs. hardware store) need independent organizational schemes; sharing categories across lists adds coupling without proportionate benefit.
  • Separate entity types (ShoppingCategoryEntity / TodoCategoryEntity): Shopping List categories will gain advanced future features; a shared entity would constrain that evolution.
  • Drag between categories reassigns: The drag operation is the primary category-change mechanism — cleaner than a "move to…" dropdown for the common case.
# `#80` — Categories for Shopping Lists and Todo Lists ## Problem 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 - A **Category** belongs to exactly one list and has: a name (required) and an emoji icon (optional, chosen via an emoji picker). - Every list has exactly one **default category**, flagged as such. New items are placed into the default category when no explicit category is chosen. - The default category is reassignable — any existing category can be promoted to default. - The default category cannot be deleted. The user must reassign the default to another category first. - A list must always have at least one category (the last remaining category cannot be deleted). ### Create - The user can create a new category by providing a name and optionally picking an emoji. - When a list is first created, one default category is created automatically (e.g., named after the list or a locale-appropriate default name). - New categories are appended at the bottom of the category list. ### Edit - The user can rename a category and change its emoji at any time. - The user can mark any non-default category as the new default. The previous default is demoted (but otherwise unchanged). ### Delete - Attempting to delete the default category shows an error or a blocked state: "Reassign the default category before deleting this one." - Deleting a non-default category shows a confirmation dialog with two choices: 1. **Move items to [default category name]** — items keep their place in the list, assigned to the default. 2. **Delete items too** — items are permanently removed from the list. ### Reorder Categories (Drag-and-Drop) - Categories can be reordered by drag-and-drop within the list. ### Reorder and Reassign Items (Drag-and-Drop) - Items within a category are manually sorted by drag-and-drop. - Dragging an item into a different category's drop zone reassigns it to that category and inserts it at the drop position. ### Shopping List - Shopping List categories represent supermarket sections or aisles (e.g. Produce, Dairy, Frozen). - Implemented as a dedicated `ShoppingCategoryEntity` to allow future extensions independent of todo categories. ### Todo List - Todo List categories represent task groups or phases (e.g. Planning, In Progress, ✅ Done). - Implemented as a dedicated `TodoCategoryEntity`. - Existing todos without an explicit category are migrated into the list's (auto-created) default category. ## Out of Scope - Nested categories (categories within categories). - Sharing or reusing categories across lists. - Category-based filtering or search. - Category color coding (emoji covers visual differentiation). - Category templates or presets. ## Priority **Should** — Foundational grouping feature; required before Shopping List (`#78`) can include full item organization. ## Decisions - **Emoji icon (optional)**: Universal, no extra icon library dependency, works across all platforms. A name alone is sufficient if the user skips the emoji. - **Default category (not an implicit "none" group)**: Gives the user full control over where uncategorized items sit in the layout. Avoids a ghost "Uncategorized" section at a fixed position. Items always belong to a real, named, reorderable category. - **Default is reassignable but deletion-protected**: Ensures there is always a valid landing zone for new items and for the "move items" path in the delete dialog, without extra null-handling in the backend. - **Per-list categories**: Different lists (groceries vs. hardware store) need independent organizational schemes; sharing categories across lists adds coupling without proportionate benefit. - **Separate entity types** (`ShoppingCategoryEntity` / `TodoCategoryEntity`): Shopping List categories will gain advanced future features; a shared entity would constrain that evolution. - **Drag between categories reassigns**: The drag operation is the primary category-change mechanism — cleaner than a "move to…" dropdown for the common case.
lena commented 2026-08-18 13:13:40 +02:00 (Migrated from git.butzei.de)

design (80_categories_for_lists_design.md)

Design: #80 Categories for Todo Lists

Architect 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 List
domain exists yet — it's introduced by #81, which the roadmap itself lists as blocked by
#80
. Implementing ShoppingCategoryEntity here would mean building it against a
ShoppingListEntity that doesn't exist.

Decision: this cycle implements TodoCategoryEntity only. ShoppingCategoryEntity is deferred
to #81, where the Shopping List domain is introduced anyway — at that point it's a natural
sibling to add, following the exact pattern established here.

Backend

  • TodoCategoryEntity (new): Id, TodoListId (FK, cascade), Name (CategoryName — new
    Vogen string type, 40-char max, mirrors LabelName), Icon (plain string?, not Vogen — no
    existing "emoji" value-object precedent to extend; length-guarded at 8 chars in
    CategoryIconValidation instead, since Vogen's automatic validation isn't available for a plain
    string field), IsDefault (bool), SortOrder (int, list-scoped).
  • TodoEntity.CategoryId (new): nullable TodoCategoryId? FK, OnDelete(Restrict) — a
    category can only be removed via DeleteCategoryCommand, which explicitly reassigns or deletes
    affected todos first inside one transaction; a stray direct delete of the category row must fail
    loudly rather than silently orphaning todos.
  • TodoEntity.SortOrder is rescoped from list-wide to per-category. This is the one existing
    feature (#17 todo reordering) this story reopens: ReorderTodosCommand now takes a
    TodoCategoryId and only reorders within that category; a new AssignTodoCategoryCommand
    handles 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 SortOrder at 0 makes a single list-wide ORDER BY SortOrder
    meaningless. The migration's trigger rewrite and backfill (below) account for this.
  • Migration (AddTodoCategoryEntity): creates TodoCategoryEntity, adds
    TodoEntity.CategoryId, then a data backfill — every existing list gets one 'General' default
    category, 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_list trigger function is rewritten to seed new
    todos' SortOrder from MAX(SortOrder) WHERE CategoryId = NEW.CategoryId instead of Nr - 1.
  • CreateTodoCommand gains an optional CategoryId; when omitted, the handler resolves it to
    the list's default category. CreateTodoListCommand now also inserts that default category
    row directly (not through CreateCategoryCommand, which always sets IsDefault: false — only
    list-creation is allowed to seed the very first default, keeping "exactly one default per list"
    enforced in exactly one place: SetDefaultCategoryCommand).
  • CheckTodoCommandHandler carries CategoryId forward onto a recurring todo's next
    occurrence, alongside the priority/assignee/labels/checklist carry-forward it already does.
  • Reuse fix found while building this: LabelChangeBroadcast (rename/delete re-broadcasting
    affected todos' TodoDto) was an exact duplicate of what DeleteCategoryCommand's reassign path
    also needed. Generalized into Features/Todos/TodoChangeBroadcast.cs; RenameLabelCommandHandler
    and DeleteLabelCommandHandler now call the shared helper instead of a Labels-only copy.
  • No new WebSocket broadcast channel for CategoryDto. Categories are list-scoped, fetched
    once via GetCategoriesForListQuery — matching Labels' existing precedent (no dedicated
    Change<...> subject either). The frontend refetches after any mutation instead.

Frontend

  • Todos are grouped into one section per category (TodoList.tsx), each with its own
    DndContext/SortableContext — dragging reorders within that category only.
  • Deliberate simplification, and why: the story's ACs ask for drag-and-drop category reorder
    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:
    • Category reordering (CategoryManager.tsx) uses up/down buttons — a fireEvent-testable
      equivalent of the same ReorderCategoriesCommand a drag would call.
    • Moving a todo to a different category (CategoryPicker.tsx) is a dropdown (opened from the
      todo's existing overflow menu), calling the same AssignTodoCategoryCommand a cross-section
      drag would call, with sortOrder: Number.MAX_SAFE_INTEGER (the backend clamps to
      append-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 the
    EmailNotificationDialog precedent already in that same menu.
  • TodoItem.tsx gains a "Category: {name}" overflow-menu item (hidden when no categories exist,
    e.g. a list not yet migrated in an old snapshot) opening CategoryPicker.
  • Categories aren't pushed over the WS change stream, so CategoryManager's onChange callback
    notifies 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.
  • Real bug caught while building this: the initial grouping only bucketed a todo as
    "uncategorized" when categoryId was literally null. A todo whose categoryId references a
    category 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/Icon are plain user text rendered via JSX (auto-escaped, no
    dangerouslySetInnerHTML) — no XSS surface.
  • Icon had no length bound at all in the first pass (not a Vogen type, unlike every other
    user-text field in this codebase) — added CategoryIconValidation (8-char cap) before this
    shipped.
  • DeleteCategoryCommand's Restrict FK behavior is the safety net against a category row being
    removed while todos still reference it, even if application logic has a bug — the database
    itself refuses.
  • Authorization is the standard AuthorizeTodoListAccessForCurrentUserQuery +
    AuthorizeTodoListIsNotArchivedQuery pair on every Category handler, identical to every existing
    Todo/Label handler.

Blockers

None (this is the wave-7 unblocking story; #81 is blocked by this one).

**design** (`80_categories_for_lists_design.md`) # Design: `#80` Categories for Todo Lists **Architect 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 List domain exists yet — it's introduced by `#81`, which the roadmap itself lists as **blocked by `#80`**. Implementing `ShoppingCategoryEntity` here would mean building it against a `ShoppingListEntity` that doesn't exist. **Decision:** this cycle implements `TodoCategoryEntity` only. `ShoppingCategoryEntity` is deferred to `#81`, where the Shopping List domain is introduced anyway — at that point it's a natural sibling to add, following the exact pattern established here. ## Backend - **`TodoCategoryEntity`** (new): `Id`, `TodoListId` (FK, cascade), `Name` (`CategoryName` — new Vogen string type, 40-char max, mirrors `LabelName`), `Icon` (plain `string?`, not Vogen — no existing "emoji" value-object precedent to extend; length-guarded at 8 chars in `CategoryIconValidation` instead, since Vogen's automatic validation isn't available for a plain string field), `IsDefault` (bool), `SortOrder` (int, list-scoped). - **`TodoEntity.CategoryId`** (new): nullable `TodoCategoryId?` FK, `OnDelete(Restrict)` — a category can only be removed via `DeleteCategoryCommand`, which explicitly reassigns or deletes affected todos first inside one transaction; a stray direct delete of the category row must fail loudly rather than silently orphaning todos. - **`TodoEntity.SortOrder` is rescoped from list-wide to per-category.** This is the one existing feature (`#17` todo reordering) this story reopens: `ReorderTodosCommand` now takes a `TodoCategoryId` and only reorders within that category; a new `AssignTodoCategoryCommand` handles 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 `SortOrder` at 0 makes a single list-wide `ORDER BY SortOrder` meaningless. The migration's trigger rewrite and backfill (below) account for this. - **Migration (`AddTodoCategoryEntity`)**: creates `TodoCategoryEntity`, adds `TodoEntity.CategoryId`, then a data backfill — every existing list gets one `'General'` default category, 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_list` trigger function is rewritten to seed new todos' `SortOrder` from `MAX(SortOrder) WHERE CategoryId = NEW.CategoryId` instead of `Nr - 1`. - **`CreateTodoCommand`** gains an optional `CategoryId`; when omitted, the handler resolves it to the list's default category. **`CreateTodoListCommand`** now also inserts that default category row directly (not through `CreateCategoryCommand`, which always sets `IsDefault: false` — only list-creation is allowed to seed the very first default, keeping "exactly one default per list" enforced in exactly one place: `SetDefaultCategoryCommand`). - **`CheckTodoCommandHandler`** carries `CategoryId` forward onto a recurring todo's next occurrence, alongside the priority/assignee/labels/checklist carry-forward it already does. - **Reuse fix found while building this**: `LabelChangeBroadcast` (rename/delete re-broadcasting affected todos' `TodoDto`) was an exact duplicate of what `DeleteCategoryCommand`'s reassign path also needed. Generalized into `Features/Todos/TodoChangeBroadcast.cs`; `RenameLabelCommandHandler` and `DeleteLabelCommandHandler` now call the shared helper instead of a Labels-only copy. - **No new WebSocket broadcast channel for `CategoryDto`.** Categories are list-scoped, fetched once via `GetCategoriesForListQuery` — matching Labels' existing precedent (no dedicated `Change<...>` subject either). The frontend refetches after any mutation instead. ## Frontend - Todos are grouped into one section per category (`TodoList.tsx`), each with its own `DndContext`/`SortableContext` — dragging reorders within that category only. - **Deliberate simplification, and why:** the story's ACs ask for drag-and-drop category reorder 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: - **Category reordering** (`CategoryManager.tsx`) uses up/down buttons — a `fireEvent`-testable equivalent of the same `ReorderCategoriesCommand` a drag would call. - **Moving a todo to a different category** (`CategoryPicker.tsx`) is a dropdown (opened from the todo's existing overflow menu), calling the same `AssignTodoCategoryCommand` a cross-section drag would call, with `sortOrder: Number.MAX_SAFE_INTEGER` (the backend clamps to append-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 the `EmailNotificationDialog` precedent already in that same menu. - `TodoItem.tsx` gains a "Category: {name}" overflow-menu item (hidden when no categories exist, e.g. a list not yet migrated in an old snapshot) opening `CategoryPicker`. - Categories aren't pushed over the WS change stream, so `CategoryManager`'s `onChange` callback notifies `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. - **Real bug caught while building this**: the initial grouping only bucketed a todo as "uncategorized" when `categoryId` was literally `null`. A todo whose `categoryId` references a category 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`/`Icon` are plain user text rendered via JSX (auto-escaped, no `dangerouslySetInnerHTML`) — no XSS surface. - `Icon` had no length bound at all in the first pass (not a Vogen type, unlike every other user-text field in this codebase) — added `CategoryIconValidation` (8-char cap) before this shipped. - `DeleteCategoryCommand`'s `Restrict` FK behavior is the safety net against a category row being removed while todos still reference it, even if application logic has a bug — the database itself refuses. - Authorization is the standard `AuthorizeTodoListAccessForCurrentUserQuery` + `AuthorizeTodoListIsNotArchivedQuery` pair on every Category handler, identical to every existing Todo/Label handler. ## Blockers None (this is the wave-7 unblocking story; `#81` is blocked by this one).
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
robert/todo#80
No description provided.