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
4 changes: 4 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type * as didResources from "../didResources.js";
import type * as didResourcesHttp from "../didResourcesHttp.js";
import type * as feedback from "../feedback.js";
import type * as http from "../http.js";
import type * as itemCategories from "../itemCategories.js";
import type * as items from "../items.js";
import type * as itemsHttp from "../itemsHttp.js";
import type * as lib_actor from "../lib/actor.js";
Expand All @@ -45,6 +46,7 @@ import type * as lib_auth from "../lib/auth.js";
import type * as lib_authUser from "../lib/authUser.js";
import type * as lib_bucket from "../lib/bucket.js";
import type * as lib_httpResponses from "../lib/httpResponses.js";
import type * as lib_itemCategories from "../lib/itemCategories.js";
import type * as lib_jwt from "../lib/jwt.js";
import type * as lib_listEnvelope from "../lib/listEnvelope.js";
import type * as lib_observability from "../lib/observability.js";
Expand Down Expand Up @@ -112,6 +114,7 @@ declare const fullApi: ApiFromModules<{
didResourcesHttp: typeof didResourcesHttp;
feedback: typeof feedback;
http: typeof http;
itemCategories: typeof itemCategories;
items: typeof items;
itemsHttp: typeof itemsHttp;
"lib/actor": typeof lib_actor;
Expand All @@ -121,6 +124,7 @@ declare const fullApi: ApiFromModules<{
"lib/authUser": typeof lib_authUser;
"lib/bucket": typeof lib_bucket;
"lib/httpResponses": typeof lib_httpResponses;
"lib/itemCategories": typeof lib_itemCategories;
"lib/jwt": typeof lib_jwt;
"lib/listEnvelope": typeof lib_listEnvelope;
"lib/observability": typeof lib_observability;
Expand Down
117 changes: 117 additions & 0 deletions convex/itemCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Mutations for a list's item categories.
*
* Every edit follows the same shape: materialise the list's set (a no-op once it
* owns one), apply a pure operation from lib/itemCategories, and persist. The
* rules live in that shared module so the server enforces exactly what the UI
* shows rather than a second, drifting copy.
*/

import { v } from "convex/values";
import { mutation } from "./_generated/server";
import type { MutationCtx } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import { canUserEditList } from "./lib/permissions";
import {
type Category,
OTHER_CATEGORY_ID,
addCategory,
deleteCategory,
materialiseCategories,
moveCategory,
renameCategory,
setCategoryEmoji,
} from "./lib/itemCategories";

const editorArgs = {
listId: v.id("lists"),
userDid: v.string(),
};

/**
* Loads the list, checks edit rights, and returns its materialised set. Anyone
* who can edit the list can edit its categories — they can already add and
* reclassify items, so restricting this to the owner would be inconsistent.
*/
async function loadEditableSet(
ctx: MutationCtx,
listId: Id<"lists">,
userDid: string
): Promise<Category[]> {
const list = await ctx.db.get(listId);
if (!list) throw new Error("List not found");
if (!(await canUserEditList(ctx, listId, userDid))) {
throw new Error("You do not have permission to edit this list");
}
return materialiseCategories(list.itemCategories, list.customAisles);
}

async function persist(
ctx: MutationCtx,
listId: Id<"lists">,
categories: Category[]
): Promise<void> {
await ctx.db.patch(listId, { itemCategories: categories });
}

export const addListCategory = mutation({
args: { ...editorArgs, name: v.string(), emoji: v.string() },
handler: async (ctx, args) => {
const set = await loadEditableSet(ctx, args.listId, args.userDid);
await persist(ctx, args.listId, addCategory(set, args.name, args.emoji));
},
});

export const renameListCategory = mutation({
args: { ...editorArgs, categoryId: v.string(), name: v.string() },
handler: async (ctx, args) => {
const set = await loadEditableSet(ctx, args.listId, args.userDid);
await persist(ctx, args.listId, renameCategory(set, args.categoryId, args.name));
},
});

export const setListCategoryEmoji = mutation({
args: { ...editorArgs, categoryId: v.string(), emoji: v.string() },
handler: async (ctx, args) => {
const set = await loadEditableSet(ctx, args.listId, args.userDid);
await persist(ctx, args.listId, setCategoryEmoji(set, args.categoryId, args.emoji));
},
});

export const moveListCategory = mutation({
args: {
...editorArgs,
categoryId: v.string(),
direction: v.union(v.literal("up"), v.literal("down")),
},
handler: async (ctx, args) => {
const set = await loadEditableSet(ctx, args.listId, args.userDid);
await persist(ctx, args.listId, moveCategory(set, args.categoryId, args.direction));
},
});

export const deleteListCategory = mutation({
args: { ...editorArgs, categoryId: v.string() },
handler: async (ctx, args) => {
const set = await loadEditableSet(ctx, args.listId, args.userDid);
const next = deleteCategory(set, args.categoryId);

// Items explicitly filed here would otherwise point at a category that no
// longer exists. The client degrades those to Other on read, but leaving the
// stale id behind would resurrect the group if the name were ever reused.
const items = await ctx.db
.query("items")
.withIndex("by_list", (q) => q.eq("listId", args.listId))
.collect();
let reassigned = 0;
for (const item of items) {
if (item.groceryAisle === args.categoryId) {
await ctx.db.patch(item._id, { groceryAisle: OTHER_CATEGORY_ID });
reassigned += 1;
}
}

await persist(ctx, args.listId, next);
return { reassigned };
},
});
181 changes: 181 additions & 0 deletions convex/lib/itemCategories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Per-list item categories.
*
* A list either uses the built-in grocery set (the default, and what every list
* started as) or owns an explicit set of its own. The moment someone edits
* categories on a list, the set is MATERIALISED onto that list — from then on it
* is the complete truth for that list and grocery aisles are no longer offered.
* That is what lets a packing list stop being asked about Produce and Deli.
*
* Keyword auto-classification is kept, but gated on the guess being reachable:
* a guessed category is used only if the list's set still contains it. So a
* grocery list keeps auto-filing even after adding a category of its own, while
* a packing list that has removed the grocery aisles stops guessing rather than
* filing "Sunglasses" into Produce.
*
* Pure module — no Convex, no DOM, no keyword map — so both the client and the
* mutations share one implementation of the rules.
*/



export interface Category {
id: string;
name: string;
emoji: string;
order: number;
}

/** Every set ends with this bucket. It cannot be renamed away or deleted. */
export const OTHER_CATEGORY_ID = "other";

export const MAX_CATEGORY_NAME_LENGTH = 40;

/** The built-in grocery set, used until a list materialises its own. */
export const DEFAULT_CATEGORIES: Category[] = [
{ id: "produce", name: "Produce", emoji: "🥬", order: 0 },
{ id: "bakery", name: "Bakery", emoji: "🍞", order: 1 },
{ id: "deli", name: "Deli", emoji: "🥪", order: 2 },
{ id: "meat", name: "Meat & Seafood", emoji: "🥩", order: 3 },
{ id: "dairy", name: "Dairy & Eggs", emoji: "🥛", order: 4 },
{ id: "frozen", name: "Frozen", emoji: "🧊", order: 5 },
{ id: "beverages", name: "Beverages", emoji: "🥤", order: 6 },
{ id: "snacks", name: "Snacks", emoji: "🍿", order: 7 },
{ id: "canned", name: "Canned & Jarred", emoji: "🥫", order: 8 },
{ id: "pasta", name: "Pasta, Rice & Grains", emoji: "🍝", order: 9 },
{ id: "condiments", name: "Condiments & Sauces", emoji: "🫙", order: 10 },
{ id: "baking", name: "Baking", emoji: "🧁", order: 11 },
{ id: "breakfast", name: "Breakfast & Cereal", emoji: "🥣", order: 12 },
{ id: "household", name: "Household", emoji: "🧹", order: 13 },
{ id: "health", name: "Health & Personal Care", emoji: "🧴", order: 14 },
{ id: "other", name: "Other", emoji: "🛒", order: 99 },
];

/** 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;
}

/** The set in effect for a list, always in display order. */
export function resolveCategories(categories: Category[] | undefined | null): Category[] {
const set = hasOwnCategories(categories) ? categories! : DEFAULT_CATEGORIES;
return [...set].sort((a, b) => a.order - b.order);
}

/**
* The set a list should be given the first time its categories are edited.
* Folds any legacy per-list aisles in after the defaults, preserving what the
* list already displayed.
*/
export function materialiseCategories(
categories: Category[] | undefined | null,
legacyCustomAisles?: Category[] | null
): Category[] {
if (hasOwnCategories(categories)) return resolveCategories(categories);

const merged = [...DEFAULT_CATEGORIES];
for (const aisle of legacyCustomAisles ?? []) {
if (!merged.some((c) => c.id === aisle.id)) merged.push(aisle);
}
return normaliseOrder(withOtherLast(merged));
}

/** Renumbers `order` to 0..n-1 so it never drifts or collides. */
export function normaliseOrder(categories: Category[]): Category[] {
return categories.map((category, index) => ({ ...category, order: index }));
}

/** Other always sorts last, wherever it ended up. */
function withOtherLast(categories: Category[]): Category[] {
const rest = categories.filter((c) => c.id !== OTHER_CATEGORY_ID);
const other = categories.find((c) => c.id === OTHER_CATEGORY_ID);
return other ? [...rest, other] : [...rest, { id: OTHER_CATEGORY_ID, name: "Other", emoji: "🛒", order: rest.length }];
}

export class CategoryError extends Error {}

function assertEditable(id: string, action: string): void {
if (id === OTHER_CATEGORY_ID) {
throw new CategoryError(`The Other category cannot be ${action} — items need somewhere to land.`);
}
}

function cleanName(name: string): string {
const trimmed = name.trim();
if (trimmed.length === 0) throw new CategoryError("Category name cannot be empty");
if (trimmed.length > MAX_CATEGORY_NAME_LENGTH) {
throw new CategoryError(`Category name cannot exceed ${MAX_CATEGORY_NAME_LENGTH} characters`);
}
return trimmed;
}

/** Slug derived from the name, kept unique within the set. */
export function categoryIdFor(name: string, existing: Category[]): string {
const base =
name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 32) || "category";

let candidate = base;
let n = 2;
while (existing.some((c) => c.id === candidate)) candidate = `${base}-${n++}`;
return candidate;
}

export function addCategory(categories: Category[], name: string, emoji: string): Category[] {
const clean = cleanName(name);
if (categories.some((c) => c.name.toLowerCase() === clean.toLowerCase())) {
throw new CategoryError(`A category named "${clean}" already exists`);
}
const added: Category = {
id: categoryIdFor(clean, categories),
name: clean,
emoji: emoji.trim() || "🏷️",
order: categories.length,
};
return normaliseOrder(withOtherLast([...categories, added]));
}

export function renameCategory(categories: Category[], id: string, name: string): Category[] {
assertEditable(id, "renamed");
const clean = cleanName(name);
if (!categories.some((c) => c.id === id)) throw new CategoryError(`No category ${id}`);
if (categories.some((c) => c.id !== id && c.name.toLowerCase() === clean.toLowerCase())) {
throw new CategoryError(`A category named "${clean}" already exists`);
}
return categories.map((c) => (c.id === id ? { ...c, name: clean } : c));
}

export function setCategoryEmoji(categories: Category[], id: string, emoji: string): Category[] {
if (!categories.some((c) => c.id === id)) throw new CategoryError(`No category ${id}`);
const clean = emoji.trim() || "🏷️";
return categories.map((c) => (c.id === id ? { ...c, emoji: clean } : c));
}

/**
* Removes a category. Callers must reassign its items to OTHER_CATEGORY_ID —
* this module only owns the category list.
*/
export function deleteCategory(categories: Category[], id: string): Category[] {
assertEditable(id, "deleted");
if (!categories.some((c) => c.id === id)) throw new CategoryError(`No category ${id}`);
return normaliseOrder(withOtherLast(categories.filter((c) => c.id !== id)));
}

/** Moves a category one slot up or down. Other stays pinned last. */
export function moveCategory(categories: Category[], id: string, direction: "up" | "down"): Category[] {
assertEditable(id, "reordered");
const ordered = resolveCategories(categories);
const movable = ordered.filter((c) => c.id !== OTHER_CATEGORY_ID);
const index = movable.findIndex((c) => c.id === id);
if (index === -1) throw new CategoryError(`No category ${id}`);

const target = direction === "up" ? index - 1 : index + 1;
if (target < 0 || target >= movable.length) return ordered; // already at the edge

const next = [...movable];
[next[index], next[target]] = [next[target], next[index]];
return normaliseOrder(withOtherLast(next));
}
14 changes: 13 additions & 1 deletion convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,25 @@ export default defineSchema({
}),
proof: v.optional(v.string()), // JWT or linked data proof
})),
// Custom grocery aisles created by users for this list
// Custom grocery aisles created by users for this list.
// Superseded by itemCategories; read only when materialising a list's set.
customAisles: v.optional(v.array(v.object({
id: v.string(),
name: v.string(),
emoji: v.string(),
order: v.number(),
}))),
// This list's own item categories. Absent means it still uses the built-in
// grocery set; the first category edit materialises the full set here, after
// which it is the complete truth for the list. Named itemCategories, not
// categories, because `categoryId` above is the user's folder for the LIST —
// a different concept on the same row.
itemCategories: v.optional(v.array(v.object({
id: v.string(),
name: v.string(),
emoji: v.string(),
order: v.number(),
}))),
// Item view mode preference: "alphabetical" (flat A-Z) or "categorized" (grouped by category)
itemViewMode: v.optional(v.union(v.literal("alphabetical"), v.literal("categorized"))),
})
Expand Down
Loading
Loading