Skip to content

Category ordering (source of truth)

How the managed attribute-category set is ordered, where that order is set, and which surfaces follow it. There is exactly one ordering source — categories.sort_order — and one bucketer that consumes it, so no two screens can drift into different category orders.

Introduced by #471 (part of the admin-console-usability capability, #466). Builds on the managed category set (#340) and shares the category ordering ↔ factor ordering reorder control with #470.

The single source

categories.sort_order (an integer column, default 0) is the only place a category's rank lives. Everything else derives from it:

StepWhereBehaviour
ReadlistCategories (src/lib/server/admin/categories.ts)Orders by sortOrder then name (case-insensitive). This is the one ordered read every consumer uses.
ProjectcategoryOrdercats.map((c) => c.name) in the attributes list and system-edit loadersThe managed names in sortOrder sequence, handed to the bucketer.
GroupgroupByCategory(items, UNCATEGORIZED, order) (src/lib/admin/ui/category-groups.ts)Category headings sort by their index in order; the null/Uncategorized bucket is always pinned last.

Because the grouped views read the name through the FK (attributes.category_id → categories.name) and the order through sort_order, a rename or a reorder is a single-table operation — never a bulk rewrite of attribute rows.

Category intros — categories.description

categories.description (nullable text, added by #832) is the managed one-line intro shown above a category's group on grouped surfaces — currently the /compare matrix sub-header. NULL means no intro; the group still renders, just without a description line.

StepWhereBehaviour
EditsetCategoryDescription (src/lib/server/admin/categories.ts), Categories tab on /admin/attributesTrims the input; a blank result stores NULL so "no intro" is one canonical state. Audited like the other category edits.
Read (public)listCategoryIntros (src/lib/server/queries.ts)Lean (name, description) in sort_order-then-name order — no attribute counts — for the /compare load.
Read (admin)listCategoriesNow also selects description so the editor can show the current value.

Intros are managed copy, not hardcoded per surface — a new grouped surface reads the same column rather than shipping its own strings.

Default is alphabetical (not insertion order)

Until an admin sets an explicit order, every category sits at sort_order = 0. listCategories breaks that 0/0 tie by name, so the resting order is deterministic alphabetical — not the order categories happened to be created in. This is the contract the grouped surfaces rely on before any arrangement.

Setting the order

The Display order card on /admin/categories (the shared ReorderableList component) lets an admin rearrange the set with ↑/↓ controls and save. On save, the reorder action assigns a dense 1..N sort_order from the submitted id sequence (sortOrderPlan), which:

  • honours only ids that are in the managed set (a stale form cannot inject a phantom row),
  • collapses duplicate ids,
  • appends any managed id the submission omitted, in set order, so a category can never lose its place from a partial submission.

Once saved, all rows are positive, so the "alphabetical until arranged" fallback switches off and the surfaces follow the admin's arrangement. The reorder is audited once (set-level, entityId = category-order).

New categories slot in sensibly

createCategory sets a new row's sort_order via nextSortOrder:

  • Set still unarranged (all 0) → new row is 0 too, so it lands in its alphabetical position.
  • Set explicitly arranged (positives exist) → new row is max + 1, so it slots at the end and never jumps the admin's arrangement.

It can then be reordered like any other row.

Decoupled from per-track factor order

Reordering the global category set touches only categories.sort_order. No attribute row and no track row is mutated. Per-track factor order (#144, #470) is a separate concern and stays independent of category display order.

Relationship to factor ordering (#470)

Category ordering and factor ordering share the one reorder control — ReorderableList + the pure move / idsEqual helpers in src/lib/admin/ui (admin UI kit). #471 built that component; #470 reuses it for the factors list. They must not fork into two reorder behaviours — the shared helper is unit-covered (src/lib/admin/ui/reorder.spec.ts) as the regression guard.

Files

ConcernFile
Ordering source columncategories.sort_order (src/lib/server/db/schema.ts)
Ordered read + reorder + slottingsrc/lib/server/admin/categories.ts (listCategories, reorderCategories, sortOrderPlan, nextSortOrder)
Reorder actionsrc/routes/admin/categories/+page.server.ts (reorder)
Display-order UIsrc/routes/admin/categories/+page.svelte + src/lib/admin/ui/ReorderableList.svelte
Grouping consumersrc/lib/admin/ui/category-groups.ts (groupByCategory)