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.
ProjectcategoryOrder — cats.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)