Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions convex/lib/itemCategories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 36 additions & 3 deletions scripts/categories.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
18 changes: 13 additions & 5 deletions src/lib/categories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { classifyItem } from "./groceryAisles";
import {
type Category,
OTHER_CATEGORY_ID,
isPristineDefault,
resolveCategories,
} from "../../convex/lib/itemCategories";

Expand Down Expand Up @@ -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<T extends CategorisableItem>(
items: T[],
Expand All @@ -59,7 +64,10 @@ export function groupByCategory<T extends CategorisableItem>(
}

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

Loading