Editable, per-list item categories - #220
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
removeCustomAislehad shipped with no UI, parked inListView.tsxas_removeCustomAisle // available for future delete-aisle UI.A list now owns its categories
Absent
itemCategoriesmeans the list is still on the grocery defaults. The first edit materialises the full set onto the list (folding in any existingcustomAisles), 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.
bananas → Produce✓bananas → Produce✓ (the regression a test guards)Other, instead of filing "Sunglasses" into ProduceExplicit assignments always win, and an assignment to a deleted category degrades to
Otherrather than vanishing the item into a group nothing renders.Invariants
Otherrather than leaving a dangling id that would resurrect the group if the name were ever reused.orderis renumbered on every mutation, so it can't drift or collide.carry-on,carry-on-2), so "Carry On" and "Carry-On" can coexist.Structure
Rules live in
convex/lib/itemCategoriesand 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, notcategories, becauselists.categoryIdalready 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
groupByCategorykeeps every item exactly once.Both typechecks clean. Lint 51 problems vs 52 on main — this removes one
as any.Not included
lists.addCustomAisle/removeCustomAisleare 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, sincecustomAislesis only read at materialisation.🤖 Generated with Claude Code
Note
Add editable per-list item categories with inline management UI
itemCategoriesfield in the database schema, storing ordered categories with id, name, emoji, and order.convex/itemCategories.tsfor adding, renaming, reordering, changing emoji, and deleting categories, with permission checks and first-edit materialisation from legacycustomAisles.convex/lib/itemCategories.tsand are shared between server mutations and client-side grouping.src/pages/ListView.tsxreplaces the old aisle grouping withgroupByCategory, and surfaces a newCategoryHeaderMenuon each section header for inline category editing.items.removeItemandlists.updateItemViewModeare now called withoutlegacyDid; legacycustomAislesare no longer folded client-side.Macroscope summarized e6d08c0.