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) ?? [] })); }