Commit 958e7b3 on main. All six acceptance criteria met.
- Tabs: Expense | Income | Debt/Loan (
Tabsadded to@repo/ui). "Create Category" entry point renders only under Expense/Income; Debt/Loan tab renders exactly the four locked system rows (Debt, Repayment, Loan, Debt Collection) read-only — no create/edit/delete affordances anywhere on them. - Typed create:
createCategorystamps root type from the active tab (expense/incomeonly —debt_loanrejected), children inherit their parent's type; UI never offers a type input. Shared rule lives inapps/web/lib/categories/category-type.ts(client lib throws; upload route rejects the op). - No drag: dnd-kit removed from the page. Display sort = Alphabetical | Most Used | Last Updated, computed client-side from local data (Most Used counts live transactions per category), persisted per-user in localStorage (
expense-tracker:category-sort:<userId>). - Budgets picker:
ORDER BY name— no dependency on the droppedsort_ordercolumn. - Upload route as authoritative write gate: category PUTs validate the claimed type against the tab/inheritance rule + parent ownership/aliveness; PATCHes reject
type/is_systemoutright.
299/299 pass (pnpm --filter web exec vitest run), typecheck + lint clean. New coverage: type stamped on create, inheritance + mismatch rejection, debt_loan root rejection, sort preference ordering (alphabetical / most-used with tie-breaks / last-updated), Debt/Loan tab contents (exactly 4 rows, all is_system), localStorage read/fallback, shared rule unit tests.
Two-axis code review done (Standards + Spec). Findings addressed: shared type rule deduplicated client/server, comparator naming, private storage key, diff churn removed. Known non-blockers: same-type enforcement on transaction category-move (server) not extended — beyond this ticket's ACs; "Most Used" counts include shared-wallet transactions (matches "computed from local data").