From c62f59d49a018c4acbec63afe78d614a1e4a58c2 Mon Sep 17 00:00:00 2001 From: Brian Richter Date: Fri, 31 Jul 2026 02:06:00 -0700 Subject: [PATCH] fix(categories): keep a user-created category visible while it is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a category appeared to do nothing — it saved to the list and then rendered nowhere, because groupByCategory dropped every category with no items. Verified against prod: the list had "Food" and "test" stored in itemCategories while neither showed in the UI. That filter was correct while categories were implicit — a short grocery list should not be buried under 15 unused aisle headers — but wrong the moment users create categories by hand. It also left no drop target: an item cannot be dragged into a category that is not rendered. Visibility now keys off isPristineDefault: an empty category is hidden only when it is a built-in the user has never touched. Anything added, renamed or re-emoji'd stays visible with no items. An empty Other is still hidden — it is noise until something lands in it. The previous behaviour had a test asserting it, which is why this shipped. That test encoded the old assumption rather than the requirement; replaced with cases covering an added category, a renamed built-in, and a re-emoji'd built-in. Co-Authored-By: Claude Opus 5 (1M context) --- convex/lib/itemCategories.ts | 13 ++++++++++++ scripts/categories.test.mjs | 39 +++++++++++++++++++++++++++++++++--- src/lib/categories.ts | 18 ++++++++++++----- 3 files changed, 62 insertions(+), 8 deletions(-) diff --git a/convex/lib/itemCategories.ts b/convex/lib/itemCategories.ts index b7a8cc3..8209663 100644 --- a/convex/lib/itemCategories.ts +++ b/convex/lib/itemCategories.ts @@ -51,6 +51,19 @@ export const DEFAULT_CATEGORIES: Category[] = [ { id: "other", name: "Other", emoji: "🛒", order: 99 }, ]; +/** + * True for a built-in category the user has never touched. + * + * Drives visibility: an untouched default is hidden while empty (so a short + * grocery list is not buried under 15 unused aisle headers), but anything the + * user added, renamed or re-emoji'd stays visible even with no items — you + * cannot drag an item into a category you cannot see. + */ +export function isPristineDefault(category: Category): boolean { + const original = DEFAULT_CATEGORIES.find((d) => d.id === category.id); + return !!original && original.name === category.name && original.emoji === category.emoji; +} + /** True once a list owns its categories and should not be offered grocery aisles. */ export function hasOwnCategories(categories: Category[] | undefined | null): boolean { return Array.isArray(categories) && categories.length > 0; diff --git a/scripts/categories.test.mjs b/scripts/categories.test.mjs index bc6b95a..6a94094 100644 --- a/scripts/categories.test.mjs +++ b/scripts/categories.test.mjs @@ -225,9 +225,42 @@ test("groupByCategory returns non-empty groups in display order", () => { ]); }); -test("groupByCategory omits empty categories, including Other", () => { - const groups = c.groupByCategory([{ name: "Passport", groceryAisle: "luggage" }], packingSet()); - assert.deepEqual(groups.map((g) => g.category.id), ["luggage"]); +test("groupByCategory hides untouched built-ins that are empty", () => { + // A short grocery list should not be buried under 15 unused aisle headers. + const groups = c.groupByCategory([{ name: "bananas" }], c.resolveCategories(undefined)); + assert.deepEqual(groups.map((g) => g.category.id), ["produce"]); +}); + +test("a user-created category stays visible while empty", () => { + // The bug this guards: an added category rendered nowhere, so it looked like + // it had failed to save — and there was no target to drag items into. + const set = c.addCategory(c.materialiseCategories(undefined, []), "Luggage", "\u{1F9F3}"); + const groups = c.groupByCategory([{ name: "bananas" }], set); + + const ids = groups.map((g) => g.category.id); + assert.ok(ids.includes("luggage"), "a category you just made must appear"); + assert.deepEqual(groups.find((g) => g.category.id === "luggage").items, []); + assert.equal(ids.includes("bakery"), false, "untouched empty built-ins stay hidden"); +}); + +test("a renamed or re-emoji'd built-in stays visible while empty", () => { + const renamed = c.renameCategory(c.materialiseCategories(undefined, []), "produce", "Fruit"); + assert.ok( + c.groupByCategory([], renamed).some((g) => g.category.id === "produce"), + "renaming signals intent to use it" + ); + + const re_emoji = c.setCategoryEmoji(c.materialiseCategories(undefined, []), "bakery", "\u{1F950}"); + assert.ok(c.groupByCategory([], re_emoji).some((g) => g.category.id === "bakery")); +}); + +test("every user-made category is visible before anything is filed; empty Other is not", () => { + const groups = c.groupByCategory([], packingSet()); + assert.deepEqual( + groups.map((g) => g.category.id), + ["luggage", "clothes", "electronics"], + "an empty Other bucket is noise — it appears only once something lands in it" + ); }); test("groupByCategory keeps every item exactly once", () => { diff --git a/src/lib/categories.ts b/src/lib/categories.ts index 47230f8..e2247f3 100644 --- a/src/lib/categories.ts +++ b/src/lib/categories.ts @@ -10,6 +10,7 @@ import { classifyItem } from "./groceryAisles"; import { type Category, OTHER_CATEGORY_ID, + isPristineDefault, resolveCategories, } from "../../convex/lib/itemCategories"; @@ -40,9 +41,13 @@ export function resolveItemCategory(item: CategorisableItem, categories: Categor } /** - * Groups items into the list's categories, in display order. Empty categories - * are dropped so a list is not padded with headers it never uses — except Other, - * which is also dropped when empty. + * Groups items into the list's categories, in display order. + * + * An empty category is still shown when the user made it theirs — added, + * renamed or re-emoji'd. Only untouched built-ins are hidden while empty, so a + * short grocery list is not buried under unused aisle headers. Hiding a + * user-created category would make it look like it failed to save, and leave no + * target to drag items into. */ export function groupByCategory( items: T[], @@ -59,7 +64,10 @@ export function groupByCategory( } return resolved - .filter((category) => (buckets.get(category.id)?.length ?? 0) > 0) - .map((category) => ({ category, items: buckets.get(category.id)! })); + .filter( + (category) => + (buckets.get(category.id)?.length ?? 0) > 0 || !isPristineDefault(category) + ) + .map((category) => ({ category, items: buckets.get(category.id) ?? [] })); }