Skip to content

Editable, per-list item categories - #220

Merged
brianorwhatever merged 1 commit into
mainfrom
feat/editable-item-categories
Jul 31, 2026
Merged

Editable, per-list item categories#220
brianorwhatever merged 1 commit into
mainfrom
feat/editable-item-categories

Conversation

@brianorwhatever

@brianorwhatever brianorwhatever commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Every list was offered the same 16 hardcoded grocery aisles. On "Rachel's 40th 14th" that meant all 12 items piled into Other — no grocery keyword matches "Travel pillow" or "Sunglasses" — while the categories on offer were Produce, Deli and Meat & Seafood. And categories could only be added: removeCustomAisle had shipped with no UI, parked in ListView.tsx as _removeCustomAisle // available for future delete-aisle UI.

A list now owns its categories

Absent itemCategories means the list is still on the grocery defaults. The first edit materialises the full set onto the list (folding in any existing customAisles), and from then on that set is the whole truth for that list — grocery aisles stop being offered.

Grocery auto-classification is kept

This was the subtle part. Naively, materialising on first edit would switch auto-filing off — so a grocery list that added one category would stop guessing. Instead the guess is gated on reachability: a keyword guess is used only if the list's set still contains that category.

List Behaviour
Grocery, default set bananas → Produce
Grocery + a custom category bananas → Produce ✓ (the regression a test guards)
Packing list, grocery aisles removed stops guessing → Other, instead of filing "Sunglasses" into Produce

Explicit assignments always win, and an assignment to a deleted category degrades to Other rather than vanishing the item into a group nothing renders.

Invariants

  • Every set keeps a permanent Other bucket — it cannot be renamed, deleted or reordered, so items always have somewhere to land.
  • Deleting a category reassigns its items to Other rather than leaving a dangling id that would resurrect the group if the name were ever reused.
  • order is renumbered on every mutation, so it can't drift or collide.
  • Ids are slugged and de-duplicated (carry-on, carry-on-2), so "Carry On" and "Carry-On" can coexist.

Structure

Rules live in convex/lib/itemCategories and are imported by both the mutations and the UI, so the server enforces exactly what the client shows instead of a second, drifting copy. Only the grocery keyword map stays client-side — nothing on the server needs it.

Editing is inline on the section header via a menu; rename and emoji edit in place, so a one-word fix costs one tap and no navigation.

Named itemCategories, not categories, because lists.categoryId already means the user's folder for the list — a different concept on the same row.

Tests

32 new (148 total, 0 fail). Rules and mutations both covered, including: the Other bucket resists deletion at both layers, a rejected edit writes nothing, a non-editor is refused, deleting reassigns only its own items, and groupByCategory keeps every item exactly once.

Both typechecks clean. Lint 51 problems vs 52 on main — this removes one as any.

Not included

lists.addCustomAisle / removeCustomAisle are left in place. Nothing calls them now, but deleting a Convex mutation breaks any cached or TestFlight client still holding the old bundle. Worth removing as a follow-up once those have rolled over — leaving them long-term is a silent-write trap, since customAisles is only read at materialisation.

🤖 Generated with Claude Code

Note

Add editable per-list item categories with inline management UI

  • Introduces a per-list itemCategories field in the database schema, storing ordered categories with id, name, emoji, and order.
  • Adds Convex mutations in convex/itemCategories.ts for adding, renaming, reordering, changing emoji, and deleting categories, with permission checks and first-edit materialisation from legacy customAisles.
  • Core category rules (uniqueness, ordering, 'Other' protection, slug id generation) live in convex/lib/itemCategories.ts and are shared between server mutations and client-side grouping.
  • src/pages/ListView.tsx replaces the old aisle grouping with groupByCategory, and surfaces a new CategoryHeaderMenu on each section header for inline category editing.
  • Behavioral Change: items.removeItem and lists.updateItemViewMode are now called without legacyDid; legacy customAisles are no longer folded client-side.

Macroscope summarized e6d08c0.

The categorised view offered 16 hardcoded grocery aisles to every list. On a
packing list that meant every item piled into "Other" (no grocery keyword
matches "Travel pillow") while the only categories on offer were Produce, Deli
and Meat & Seafood. Categories could be added but never renamed, reordered or
deleted — removeCustomAisle had existed for a while with no UI at all.

A list now owns its categories. Absent `itemCategories` means it is still on the
grocery defaults; the first edit MATERIALISES the full set onto the list, folding
in any existing customAisles, and from then on that set is the whole truth for
the list and grocery aisles stop being offered.

Auto-classification is kept and gated on reachability: a keyword guess is used
only if the list's set still contains that category. So a grocery list keeps
auto-filing bananas → Produce even after adding a category of its own, while a
packing list that has dropped the grocery aisles stops guessing rather than
filing "Sunglasses" into Produce. An explicit assignment always wins, and an
assignment to a deleted category degrades to Other instead of vanishing the item
into a group nothing renders.

Every set keeps a permanent Other bucket: it cannot be renamed, deleted or
reordered, so items always have somewhere to land. Deleting a category reassigns
its items to Other rather than leaving a dangling id that would resurrect the
group if the name were reused.

The rules live in convex/lib/itemCategories so the mutations enforce exactly what
the UI shows instead of a second, drifting copy; only the grocery keyword map
stays client-side, since nothing on the server needs it.

Editing is inline on the section header via a ⋯ menu — rename and emoji edit in
place, so a one-word fix costs one tap and no navigation.

Named itemCategories, not categories: `lists.categoryId` already means the user's
folder for the LIST, a different concept on the same row.

32 new tests cover the rules and the mutations, including the regression that
materialising must not silently switch grocery auto-filing off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brianorwhatever
brianorwhatever merged commit 07ac807 into main Jul 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant