Add support for categories and tags for each item.
Each item should now support to new elements.
Category -> Rules that dictate how the app automatically organizes items into a folder like architecture. This is not a physical storage architecture. It only metadata for organizing the items into a tree-like structure.
Tags -> flexible labels used to categorize, color-code, and filter documents. Unlike rigid categories, a single item can have multiple tags, allowing for multi-dimensional organization without duplicating files. Tags should be a multi-select item for each item, with the ability to add tags on the fly.
The main UI should provide filter options for both of these new elements, and another new filter option should be added for price range.
# Add support for categories and tags for each item.
Each item should now support to new elements.
1. Category -> Rules that dictate how the app automatically organizes items into a folder like architecture. This is not a physical storage architecture. It only metadata for organizing the items into a tree-like structure.
2. Tags -> flexible labels used to categorize, color-code, and filter documents. Unlike rigid categories, a single item can have multiple tags, allowing for multi-dimensional organization without duplicating files. Tags should be a multi-select item for each item, with the ability to add tags on the fly.
---
The main UI should provide filter options for both of these new elements, and another new filter option should be added for price range.
bermudalamb
added this to the Initial Build project 2026-08-17 08:38:46 -05:00
bermudalamb
self-assigned this 2026-08-17 08:38:54 -05:00
Working through this issue's requirements before implementation. Q&A recorded here for traceability.
Q1 — Categories: what does "rules that dictate how the app automatically organizes items" mean in practice?
The issue's wording could describe two very different systems, so this needed pinning down:
Option
Meaning
A. Manual tree assignment
Admin defines a hierarchical category tree. Each item is assigned to a node in the admin item form. The tree is the organization — no rule engine.
B. Rule engine, auto-assign
Admin defines predicates (name contains 'table' AND price > $200 → Furniture/Tables); the app evaluates them and files items automatically.
C. Manual tree now, rules later
Ship A, design the schema so a rule engine can be layered on without migration pain.
Answer: A — Manual tree assignment.
Matches the issue's own clarification that categories are "only metadata for organizing the items into a tree-like structure." No rule engine will be built.
Q2 — Category tree: depth, and how many categories per item?
Option
Meaning
A. Arbitrary depth, one per item
Self-referencing parent_id; nest to any depth. Each item assigned to exactly one node. Uncategorized items permitted.
B. Exactly two levels
Fixed parent > child only. Simpler, but a third level needs a schema/UI change.
C. Arbitrary depth, many per item
Many-to-many join. More flexible, but blurs the line with Tags.
Answer: A — Arbitrary depth, one item per category.
Filtering on a parent node will include all descendants.
Option C was explicitly rejected because the issue contrasts "rigid categories" against multi-tag flexibility — keeping categories single-valued preserves that distinction.
Q3 — Tags: how should creation and colors work?
Option
Meaning
A. Admin-only, auto color
Tags typed inline in the item form's multi-select, created on the fly. Color auto-assigned deterministically from the tag name.
B. Admin-only, manual color
Same, but the admin explicitly picks each color.
C. No tag registry
Free text on the item, no central table. No rename/recolor/delete; typos become permanent duplicates.
Answer: A, plus B's override — auto color on creation, overridable from a palette.
A new tag gets a deterministic color derived from its name (stable and distinct with zero extra steps), and the admin can override that color from a palette in a Tags admin tab. That tab also handles rename and delete. Option C was rejected outright since the issue explicitly asks for color-coding.
Still open
Storefront filter layout (category / tags / price range) — being reviewed as side-by-side mockups. Outcome will be posted here.
## Design clarifications (round 1)
Working through this issue's requirements before implementation. Q&A recorded here for traceability.
---
### Q1 — Categories: what does "rules that dictate how the app automatically organizes items" mean in practice?
The issue's wording could describe two very different systems, so this needed pinning down:
| Option | Meaning |
| --- | --- |
| **A. Manual tree assignment** | Admin defines a hierarchical category tree. Each item is assigned to a node in the admin item form. The tree *is* the organization — no rule engine. |
| B. Rule engine, auto-assign | Admin defines predicates (`name contains 'table' AND price > $200 → Furniture/Tables`); the app evaluates them and files items automatically. |
| C. Manual tree now, rules later | Ship A, design the schema so a rule engine can be layered on without migration pain. |
**Answer: A — Manual tree assignment.**
Matches the issue's own clarification that categories are "only metadata for organizing the items into a tree-like structure." No rule engine will be built.
---
### Q2 — Category tree: depth, and how many categories per item?
| Option | Meaning |
| --- | --- |
| **A. Arbitrary depth, one per item** | Self-referencing `parent_id`; nest to any depth. Each item assigned to exactly one node. Uncategorized items permitted. |
| B. Exactly two levels | Fixed parent > child only. Simpler, but a third level needs a schema/UI change. |
| C. Arbitrary depth, many per item | Many-to-many join. More flexible, but blurs the line with Tags. |
**Answer: A — Arbitrary depth, one item per category.**
```
categories(id, name, parent_id -> categories.id, sort_order)
items.category_id -> categories.id NULL allowed (Uncategorized)
```
Filtering on a parent node will include all descendants.
Option C was explicitly rejected because the issue contrasts "rigid categories" against multi-tag flexibility — keeping categories single-valued preserves that distinction.
---
### Q3 — Tags: how should creation and colors work?
| Option | Meaning |
| --- | --- |
| **A. Admin-only, auto color** | Tags typed inline in the item form's multi-select, created on the fly. Color auto-assigned deterministically from the tag name. |
| B. Admin-only, manual color | Same, but the admin explicitly picks each color. |
| C. No tag registry | Free text on the item, no central table. No rename/recolor/delete; typos become permanent duplicates. |
**Answer: A, plus B's override — auto color on creation, overridable from a palette.**
A new tag gets a deterministic color derived from its name (stable and distinct with zero extra steps), and the admin can override that color from a palette in a Tags admin tab. That tab also handles rename and delete. Option C was rejected outright since the issue explicitly asks for color-coding.
---
### Still open
Storefront filter layout (category / tags / price range) — being reviewed as side-by-side mockups. Outcome will be posted here.
Q4 — When several tags are selected at once, how do they combine?
Option
Meaning
A. AND — has all of these tags
An item must carry every selected tag to appear. Selecting more tags narrows the result set.
B. OR — has any of these tags
An item matching any selected tag appears. Selecting more tags widens the result set.
Answer: A — AND, "has all of these tags".
Selecting vintage + oak returns only items carrying both. The filter panel will label this explicitly ("Tags — must have all") so that selecting a second tag and watching the grid shrink reads as intentional rather than broken.
Category, tags, and price range also combine with AND across the three filters.
Q5 — Storefront filter layout
Three layouts were mocked up side by side over a realistic item grid:
Option
Shape
Trade-off
A. Sidebar filter rail
Tree, tag pills, and price slider permanently in a left column
Most discoverable, but costs ~25% of grid width (4 cards/row → 3) and needs a separate mobile treatment
B. Horizontal filter bar
Three compact dropdowns in a row above the grid
Full grid width, wraps on mobile, but the category tree is hidden until the cascader is opened
C. Drawer + active-filter chips
A "Filters" button opens a slide-over panel; applied filters shown as removable chips above the grid
Full grid width and identical behaviour at every screen size; filtering is a two-step interaction
Answer: C — Drawer with active-filter chips.
Mobile rendering is under review before this is locked; the sub-choice is whether the panel arrives as a full-height side drawer or a bottom sheet. Both are a single antd Drawer differing only in placement, so no second implementation is involved either way. Outcome will be posted here.
Settled so far
Categories: admin-managed tree, arbitrary depth, one category per item, NULL allowed
Tags: central registry, many per item, auto-assigned colour overridable from a palette
## Design clarifications (round 2)
### Q4 — When several tags are selected at once, how do they combine?
| Option | Meaning |
| --- | --- |
| **A. AND — has all of these tags** | An item must carry *every* selected tag to appear. Selecting more tags narrows the result set. |
| B. OR — has any of these tags | An item matching *any* selected tag appears. Selecting more tags widens the result set. |
**Answer: A — AND, "has all of these tags".**
Selecting `vintage` + `oak` returns only items carrying both. The filter panel will label this explicitly ("Tags — must have all") so that selecting a second tag and watching the grid shrink reads as intentional rather than broken.
Category, tags, and price range also combine with AND across the three filters.
---
### Q5 — Storefront filter layout
Three layouts were mocked up side by side over a realistic item grid:
| Option | Shape | Trade-off |
| --- | --- | --- |
| A. Sidebar filter rail | Tree, tag pills, and price slider permanently in a left column | Most discoverable, but costs ~25% of grid width (4 cards/row → 3) and needs a separate mobile treatment |
| B. Horizontal filter bar | Three compact dropdowns in a row above the grid | Full grid width, wraps on mobile, but the category tree is hidden until the cascader is opened |
| **C. Drawer + active-filter chips** | A "Filters" button opens a slide-over panel; applied filters shown as removable chips above the grid | Full grid width and identical behaviour at every screen size; filtering is a two-step interaction |
**Answer: C — Drawer with active-filter chips.**
Mobile rendering is under review before this is locked; the sub-choice is whether the panel arrives as a full-height side drawer or a bottom sheet. Both are a single antd `Drawer` differing only in `placement`, so no second implementation is involved either way. Outcome will be posted here.
---
### Settled so far
- Categories: admin-managed tree, arbitrary depth, one category per item, `NULL` allowed
- Tags: central registry, many per item, auto-assigned colour overridable from a palette
- Multi-tag filtering: AND
- Storefront filters: category + tags + price range, combined with AND, applied server-side
- Layout: drawer + chips (mobile drawer style pending)
Q6 — On mobile, how should the filter panel arrive?
Follow-up to Q5. Both options are the same antd Drawer with a different placement, so neither adds a second implementation.
Option
Shape
Trade-off
C1. Side drawer
Slides in from the right at near full-screen, sticky footer with "Clear all" / "Show N items"
Reads as a dedicated page of filters; the category tree gets full vertical room for deep nesting
C2. Bottom sheet
Rises from the bottom at ~78% height with a grab handle
Controls sit in thumb reach and the grid stays partly visible, but a deep tree gets cramped
Answer: C1 — side drawer.
Q5 outcome — closed
Storefront filter layout is now fully settled: drawer + active-filter chips, with the drawer entering from the right at every screen size. The closed state is a "Filters (N)" button with applied filters rendered as removable chips beside it on desktop, wrapping to their own line on mobile.
The drawer's tag section is labelled "Tags — must have all" so the AND semantics from Q4 are visible at the point of use.
Requirements now fully settled
Area
Decision
Categories
Admin-managed tree, arbitrary depth via self-referencing parent_id, exactly one category per item, NULL allowed (Uncategorized)
Category filtering
Selecting a node matches that node and all its descendants
Tags
Central registry, many per item, created on the fly from the item form
Tag colours
Auto-assigned deterministically from the tag name, overridable from a palette in an admin Tags tab
Drawer + removable chips; drawer enters from the right at all screen sizes
Next step is the written design spec, which will be committed to the repo and linked here before any implementation starts.
## Design clarifications (round 3)
### Q6 — On mobile, how should the filter panel arrive?
Follow-up to Q5. Both options are the same antd `Drawer` with a different `placement`, so neither adds a second implementation.
| Option | Shape | Trade-off |
| --- | --- | --- |
| **C1. Side drawer** | Slides in from the right at near full-screen, sticky footer with "Clear all" / "Show N items" | Reads as a dedicated page of filters; the category tree gets full vertical room for deep nesting |
| C2. Bottom sheet | Rises from the bottom at ~78% height with a grab handle | Controls sit in thumb reach and the grid stays partly visible, but a deep tree gets cramped |
**Answer: C1 — side drawer.**
---
### Q5 outcome — closed
Storefront filter layout is now fully settled: **drawer + active-filter chips**, with the drawer entering from the right at every screen size. The closed state is a "Filters (N)" button with applied filters rendered as removable chips beside it on desktop, wrapping to their own line on mobile.
The drawer's tag section is labelled "Tags — must have all" so the AND semantics from Q4 are visible at the point of use.
---
### Requirements now fully settled
| Area | Decision |
| --- | --- |
| Categories | Admin-managed tree, arbitrary depth via self-referencing `parent_id`, exactly one category per item, `NULL` allowed (Uncategorized) |
| Category filtering | Selecting a node matches that node and all its descendants |
| Tags | Central registry, many per item, created on the fly from the item form |
| Tag colours | Auto-assigned deterministically from the tag name, overridable from a palette in an admin Tags tab |
| Tag filtering | AND — an item must carry every selected tag |
| Storefront filters | Category + tags + price range, combined with AND, applied server-side |
| Layout | Drawer + removable chips; drawer enters from the right at all screen sizes |
Next step is the written design spec, which will be committed to the repo and linked here before any implementation starts.
Branch: feature/categories-and-tags — three commits, not yet pushed.
Design spec committed to docs/superpowers/specs/2026-08-17-categories-and-tags-design.md.
Schema
New migration 1786974336000_add-categories-and-tags, with a real down (unlike the baseline):
categories(id, name, parent_id -> categories ON DELETE CASCADE, sort_order)
tags(id, name, color)
item_tags(item_id, tag_id) PK both
items.category_id -> categories ON DELETE SET NULL
Two partial unique indexes stop siblings sharing a name — a single plain constraint would let duplicate root categories through, since NULL parent_id compares unequal to every other NULL.
Deleting a category cascades to its subcategories and leaves their items in place, merely uncategorized.
API
GET /api/items accepts category, tags, min_price, max_price, combined with AND.
Category matching walks the subtree with a recursive CTE, so selecting a parent includes everything filed beneath it. Chosen over a materialized path column because reparenting stays a single UPDATE with no stored paths to rewrite and drift.
Tag matching uses a count check rather than ANY() alone, which would have returned items carrying only one of the selected tags.
Malformed params return 400 instead of being ignored, so a broken filter link shows itself rather than quietly listing the whole catalogue.
GET /api/filters serves the drawer its tree, tags, and price bounds in one request. Admin CRUD lives under /api/admin/categories and /api/admin/tags, inheriting the existing authentik boundary with no nginx change.
Fix required by the feature
SELECT_WITH_IMAGES aggregated images through LEFT JOIN + GROUP BY. Adding a second one-to-many join for tags to that shape multiplies rows together — an item with 2 images and 3 tags would have repeated every image three times. Both copies now use scalar subqueries, which drops the GROUP BY entirely. There is a regression test for exactly this case.
UI
Storefront: a "Filters (N)" button opens a right-hand drawer holding the category tree, colour-coded tag pills labelled "Tags — must have all", and a price range. Applied filters appear as removable chips. Filter state lives in the URL query string, so filtered views are shareable and the back button works. Item cards show their category and tags.
Admin: new Categories and Tags tabs; the item form gains a category TreeSelect and a tags input that creates new tags inline.
Bug caught during verification
The admin category tree used defaultExpandAll, which antd evaluates once at mount. Any branch created afterwards rendered collapsed, making its children unreachable — you could add a subcategory and it would vanish. Expansion is now controlled state, and creating or moving a node expands its parent. The storefront drawer was unaffected because it remounts each time it opens. Covered by a regression test in both specs.
Verification
Suite
Result
Backend unit
43 passed (3 suites)
Backend integration
55 passed (3 suites)
Frontend e2e
21 passed, stable over 4 consecutive runs
tsc --noEmit backend / frontend
clean
npm run build
clean
41 of the integration tests are new, covering tree CRUD, sibling name collisions, cycle rejection on reparent, cascade-delete fallout, descendant matching, tag AND semantics, price bounds, the filters combined, and on-the-fly tag creation.
Deploy note
This release needs a migration against the live database:
sudo docker exec redefined-designs-syn node migrate.js up
## Implemented
Branch: `feature/categories-and-tags` — three commits, not yet pushed.
Design spec committed to `docs/superpowers/specs/2026-08-17-categories-and-tags-design.md`.
### Schema
New migration `1786974336000_add-categories-and-tags`, with a real `down` (unlike the baseline):
```
categories(id, name, parent_id -> categories ON DELETE CASCADE, sort_order)
tags(id, name, color)
item_tags(item_id, tag_id) PK both
items.category_id -> categories ON DELETE SET NULL
```
Two partial unique indexes stop siblings sharing a name — a single plain constraint would let duplicate root categories through, since `NULL` parent_id compares unequal to every other `NULL`.
Deleting a category cascades to its subcategories and leaves their items in place, merely uncategorized.
### API
`GET /api/items` accepts `category`, `tags`, `min_price`, `max_price`, combined with AND.
- Category matching walks the subtree with a recursive CTE, so selecting a parent includes everything filed beneath it. Chosen over a materialized path column because reparenting stays a single `UPDATE` with no stored paths to rewrite and drift.
- Tag matching uses a count check rather than `ANY()` alone, which would have returned items carrying only one of the selected tags.
- Malformed params return `400` instead of being ignored, so a broken filter link shows itself rather than quietly listing the whole catalogue.
`GET /api/filters` serves the drawer its tree, tags, and price bounds in one request. Admin CRUD lives under `/api/admin/categories` and `/api/admin/tags`, inheriting the existing authentik boundary with no nginx change.
### Fix required by the feature
`SELECT_WITH_IMAGES` aggregated images through `LEFT JOIN` + `GROUP BY`. Adding a second one-to-many join for tags to that shape multiplies rows together — an item with 2 images and 3 tags would have repeated every image three times. Both copies now use scalar subqueries, which drops the `GROUP BY` entirely. There is a regression test for exactly this case.
### UI
Storefront: a "Filters (N)" button opens a right-hand drawer holding the category tree, colour-coded tag pills labelled "Tags — must have all", and a price range. Applied filters appear as removable chips. Filter state lives in the URL query string, so filtered views are shareable and the back button works. Item cards show their category and tags.
Admin: new Categories and Tags tabs; the item form gains a category `TreeSelect` and a tags input that creates new tags inline.
### Bug caught during verification
The admin category tree used `defaultExpandAll`, which antd evaluates once at mount. Any branch created afterwards rendered collapsed, making its children unreachable — you could add a subcategory and it would vanish. Expansion is now controlled state, and creating or moving a node expands its parent. The storefront drawer was unaffected because it remounts each time it opens. Covered by a regression test in both specs.
### Verification
| Suite | Result |
| --- | --- |
| Backend unit | 43 passed (3 suites) |
| Backend integration | 55 passed (3 suites) |
| Frontend e2e | 21 passed, stable over 4 consecutive runs |
| `tsc --noEmit` backend / frontend | clean |
| `npm run build` | clean |
41 of the integration tests are new, covering tree CRUD, sibling name collisions, cycle rejection on reparent, cascade-delete fallout, descendant matching, tag AND semantics, price bounds, the filters combined, and on-the-fly tag creation.
### Deploy note
This release needs a migration against the live database:
```bash
sudo docker exec redefined-designs-syn node migrate.js up
```
Three defects surfaced when this was deployed and tested. Branch fix/deploy-migration-safety, two commits, not yet pushed.
1. The storefront showed no inventory after deploy
No data was lost. The migration only runs CREATE TABLE IF NOT EXISTS and ALTER TABLE items ADD COLUMN IF NOT EXISTS — nothing in it removes rows.
The cause was code deployed ahead of its schema. The new item query references categories, item_tags, and items.category_id; against a database where the migration hadn't run, it failed. That failure was then invisible at every layer:
Layer
Why it stayed silent
Express 4
Does not forward a rejected async handler, and no error middleware was mounted — so the request never responded at all
Frontend
No .catch, and Empty rendered "No items yet — check back soon" — a hung request was indistinguishable from an empty catalogue
Deploy
node migrate.js up was a separate manual step, easy to forget
Migrations run at container start.CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]. Code can no longer be ahead of the schema, and the manual step is gone from the deploy sequence. migrate.js now waits for Postgres to accept connections first (the NAS brings the DB container up slower than the app) and still exits non-zero, so a bad migration stops the container instead of serving against a half-migrated schema.
Async routes are wrapped and an error middleware is mounted, so a failing query returns 500 instead of hanging. Regression test asserts a query error answers rather than hangs — verified by removing the wrapper and watching the request hang.
The storefront separates "request failed" from "no items", with a retry. fetchItems/fetchFilterOptions now throw on a non-OK response instead of returning the parsed error body, which would otherwise have been set as the item list and crashed the grid on .map.
The general lesson, recorded in .claude/project-context.md: any deploy where new code needs something the environment doesn't have yet surfaces as silence, not an error, unless something is written to make it loud.
2. "Item added" was shown for a save that never happened
Confirmed and fixed. saveItem returned res.json() with no res.ok check, so a 4xx/5xx resolved normally and the caller reported success unconditionally. The same held for delete, image removal, and mark sold/available — the last two gave no feedback at all on failure.
All admin calls now throw on a non-OK response. The item form reports the error, keeps the form open so entered values aren't lost, and only claims success once the server has accepted the write. Covered by e2e tests that intercept the request with a 500 and assert the success toast never appears, plus one that confirms a successful save actually reaches the database rather than just firing a toast.
This was almost certainly the same root cause as #1 — the save was failing server-side against the un-migrated schema — but the false success claim was a genuine defect in its own right and is fixed independently.
3. Categories could not be created inline from the item form
Correct, and a real gap: tags could be invented from the item form but categories could not, so needing a new category meant abandoning a half-filled form for the Categories tab.
The category picker now has a "New category name" field. New categories are created at the top level; nesting stays in the Categories tab, which keeps the control to a single decision.
Two related problems found while building it:
The picker expanded the entire tree on open, which doesn't scale past a screenful and made the popup reflow while measuring. It now has search instead.
Typing in the new-category field re-rendered the whole Inventory component and rebuilt the category tree on every keystroke, visibly jittering the open popup. The control is now its own component with memoized tree data — the interaction went from ~35s to ~1.5s in tests.
The app also now honours prefers-reduced-motion by disabling antd transitions, which is an accessibility improvement and removes a class of animation-related test flake.
The docs: bring project context up to date commit was not included in the merge — it ended up dangling, reachable only through the reflog. git log on the branch looked normal because the commit simply wasn't on it. It has been recovered and is included in this branch. Worth running git branch --contains <sha> on any commit made after a push, before assuming it shipped.
Still open
A separate on-demand QA environment stack for reviewing changes online, requested during this round. Being scoped separately — it's an infrastructure change rather than part of this issue.
## Post-deploy findings and corrections
Three defects surfaced when this was deployed and tested. Branch `fix/deploy-migration-safety`, two commits, not yet pushed.
---
### 1. The storefront showed no inventory after deploy
**No data was lost.** The migration only runs `CREATE TABLE IF NOT EXISTS` and `ALTER TABLE items ADD COLUMN IF NOT EXISTS` — nothing in it removes rows.
The cause was code deployed ahead of its schema. The new item query references `categories`, `item_tags`, and `items.category_id`; against a database where the migration hadn't run, it failed. That failure was then invisible at every layer:
| Layer | Why it stayed silent |
| --- | --- |
| Express 4 | Does not forward a rejected async handler, and no error middleware was mounted — so the request **never responded at all** |
| Frontend | No `.catch`, and `Empty` rendered "No items yet — check back soon" — a hung request was indistinguishable from an empty catalogue |
| Deploy | `node migrate.js up` was a separate manual step, easy to forget |
To confirm on the NAS, if it hasn't been already:
```bash
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT count(*) FROM items;"
```
**Fixes — one per layer:**
- **Migrations run at container start.** `CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]`. Code can no longer be ahead of the schema, and the manual step is gone from the deploy sequence. `migrate.js` now waits for Postgres to accept connections first (the NAS brings the DB container up slower than the app) and still exits non-zero, so a bad migration stops the container instead of serving against a half-migrated schema.
- **Async routes are wrapped and an error middleware is mounted**, so a failing query returns 500 instead of hanging. Regression test asserts a query error answers rather than hangs — verified by removing the wrapper and watching the request hang.
- **The storefront separates "request failed" from "no items"**, with a retry. `fetchItems`/`fetchFilterOptions` now throw on a non-OK response instead of returning the parsed error body, which would otherwise have been set as the item list and crashed the grid on `.map`.
The general lesson, recorded in `.claude/project-context.md`: **any deploy where new code needs something the environment doesn't have yet surfaces as silence, not an error, unless something is written to make it loud.**
---
### 2. "Item added" was shown for a save that never happened
Confirmed and fixed. `saveItem` returned `res.json()` with no `res.ok` check, so a 4xx/5xx resolved normally and the caller reported success unconditionally. The same held for delete, image removal, and mark sold/available — the last two gave no feedback at all on failure.
All admin calls now throw on a non-OK response. The item form reports the error, **keeps the form open so entered values aren't lost**, and only claims success once the server has accepted the write. Covered by e2e tests that intercept the request with a 500 and assert the success toast never appears, plus one that confirms a successful save actually reaches the database rather than just firing a toast.
This was almost certainly the same root cause as #1 — the save was failing server-side against the un-migrated schema — but the false success claim was a genuine defect in its own right and is fixed independently.
---
### 3. Categories could not be created inline from the item form
Correct, and a real gap: tags could be invented from the item form but categories could not, so needing a new category meant abandoning a half-filled form for the Categories tab.
The category picker now has a "New category name" field. New categories are created at the **top level**; nesting stays in the Categories tab, which keeps the control to a single decision.
Two related problems found while building it:
- The picker expanded the entire tree on open, which doesn't scale past a screenful and made the popup reflow while measuring. It now has search instead.
- Typing in the new-category field re-rendered the whole Inventory component and rebuilt the category tree on every keystroke, visibly jittering the open popup. The control is now its own component with memoized tree data — the interaction went from ~35s to ~1.5s in tests.
The app also now honours `prefers-reduced-motion` by disabling antd transitions, which is an accessibility improvement and removes a class of animation-related test flake.
---
### Verification
| Suite | Result |
| --- | --- |
| Backend unit | 47 passed |
| Backend integration | 57 passed |
| Frontend e2e | 29 passed, stable over 3 consecutive runs |
| `tsc --noEmit` backend / frontend | clean |
| `npm run build` | clean |
---
### Note on PR #24
The `docs: bring project context up to date` commit was **not included in the merge** — it ended up dangling, reachable only through the reflog. `git log` on the branch looked normal because the commit simply wasn't on it. It has been recovered and is included in this branch. Worth running `git branch --contains <sha>` on any commit made after a push, before assuming it shipped.
---
### Still open
A separate on-demand QA environment stack for reviewing changes online, requested during this round. Being scoped separately — it's an infrastructure change rather than part of this issue.
Flagged in review: the wireframes behind the layout decisions were never committed.
Correct — they lived only in .superpowers/brainstorm/, which I had gitignored. That call was half wrong. The directory itself should stay ignored, because alongside the mockups it holds a brainstorming-session token, PID files, and absolute local paths:
.superpowers/brainstorm/.last-token <- session key
.superpowers/brainstorm/<id>/state/server-info <- same key, plus local paths
.superpowers/brainstorm/<id>/state/server.pid
What was wrong was letting the mockups die with it. They are now archived at docs/superpowers/specs/2026-08-17-categories-and-tags-mockups/, next to the design spec and linked from it:
File
Question
Outcome
filter-layout.html
Where do the storefront filters live?
C — drawer plus chips. A was a permanent sidebar rail; B a row of dropdowns
filter-layout-mobile.html
How does the drawer arrive on a phone?
C1 — side drawer. C2 was a bottom sheet
The rejected options are kept deliberately — the comparison is the part worth preserving, and it's what makes the recorded decision meaningful later.
The tool serves these as fragments inside its own frame, so committing them raw would have produced files that render as unstyled markup. Each is wrapped as a standalone page with the frame's style tokens and its toggleSelect helper inlined; they open directly in a browser with no server and no network access. Verified rendering with no console errors.
Convention recorded in .claude/project-context.md: .superpowers/ stays ignored, but design artifacts inside it get lifted into the specs directory before the session's scratch state is cleaned up.
## Design mockups archived
Flagged in review: the wireframes behind the layout decisions were never committed.
Correct — they lived only in `.superpowers/brainstorm/`, which I had gitignored. That call was half wrong. The directory itself should stay ignored, because alongside the mockups it holds a brainstorming-session token, PID files, and absolute local paths:
```
.superpowers/brainstorm/.last-token <- session key
.superpowers/brainstorm/<id>/state/server-info <- same key, plus local paths
.superpowers/brainstorm/<id>/state/server.pid
```
What was wrong was letting the mockups die with it. They are now archived at `docs/superpowers/specs/2026-08-17-categories-and-tags-mockups/`, next to the design spec and linked from it:
| File | Question | Outcome |
| --- | --- | --- |
| `filter-layout.html` | Where do the storefront filters live? | **C — drawer plus chips.** A was a permanent sidebar rail; B a row of dropdowns |
| `filter-layout-mobile.html` | How does the drawer arrive on a phone? | **C1 — side drawer.** C2 was a bottom sheet |
The rejected options are kept deliberately — the comparison is the part worth preserving, and it's what makes the recorded decision meaningful later.
The tool serves these as fragments inside its own frame, so committing them raw would have produced files that render as unstyled markup. Each is wrapped as a standalone page with the frame's style tokens and its `toggleSelect` helper inlined; they open directly in a browser with no server and no network access. Verified rendering with no console errors.
Convention recorded in `.claude/project-context.md`: `.superpowers/` stays ignored, but design artifacts inside it get lifted into the specs directory before the session's scratch state is cleaned up.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Add support for categories and tags for each item.
Each item should now support to new elements.
The main UI should provide filter options for both of these new elements, and another new filter option should be added for price range.
Design clarifications (round 1)
Working through this issue's requirements before implementation. Q&A recorded here for traceability.
Q1 — Categories: what does "rules that dictate how the app automatically organizes items" mean in practice?
The issue's wording could describe two very different systems, so this needed pinning down:
name contains 'table' AND price > $200 → Furniture/Tables); the app evaluates them and files items automatically.Answer: A — Manual tree assignment.
Matches the issue's own clarification that categories are "only metadata for organizing the items into a tree-like structure." No rule engine will be built.
Q2 — Category tree: depth, and how many categories per item?
parent_id; nest to any depth. Each item assigned to exactly one node. Uncategorized items permitted.Answer: A — Arbitrary depth, one item per category.
Filtering on a parent node will include all descendants.
Option C was explicitly rejected because the issue contrasts "rigid categories" against multi-tag flexibility — keeping categories single-valued preserves that distinction.
Q3 — Tags: how should creation and colors work?
Answer: A, plus B's override — auto color on creation, overridable from a palette.
A new tag gets a deterministic color derived from its name (stable and distinct with zero extra steps), and the admin can override that color from a palette in a Tags admin tab. That tab also handles rename and delete. Option C was rejected outright since the issue explicitly asks for color-coding.
Still open
Storefront filter layout (category / tags / price range) — being reviewed as side-by-side mockups. Outcome will be posted here.
Design clarifications (round 2)
Q4 — When several tags are selected at once, how do they combine?
Answer: A — AND, "has all of these tags".
Selecting
vintage+oakreturns only items carrying both. The filter panel will label this explicitly ("Tags — must have all") so that selecting a second tag and watching the grid shrink reads as intentional rather than broken.Category, tags, and price range also combine with AND across the three filters.
Q5 — Storefront filter layout
Three layouts were mocked up side by side over a realistic item grid:
Answer: C — Drawer with active-filter chips.
Mobile rendering is under review before this is locked; the sub-choice is whether the panel arrives as a full-height side drawer or a bottom sheet. Both are a single antd
Drawerdiffering only inplacement, so no second implementation is involved either way. Outcome will be posted here.Settled so far
NULLallowedDesign clarifications (round 3)
Q6 — On mobile, how should the filter panel arrive?
Follow-up to Q5. Both options are the same antd
Drawerwith a differentplacement, so neither adds a second implementation.Answer: C1 — side drawer.
Q5 outcome — closed
Storefront filter layout is now fully settled: drawer + active-filter chips, with the drawer entering from the right at every screen size. The closed state is a "Filters (N)" button with applied filters rendered as removable chips beside it on desktop, wrapping to their own line on mobile.
The drawer's tag section is labelled "Tags — must have all" so the AND semantics from Q4 are visible at the point of use.
Requirements now fully settled
parent_id, exactly one category per item,NULLallowed (Uncategorized)Next step is the written design spec, which will be committed to the repo and linked here before any implementation starts.
Implemented
Branch:
feature/categories-and-tags— three commits, not yet pushed.Design spec committed to
docs/superpowers/specs/2026-08-17-categories-and-tags-design.md.Schema
New migration
1786974336000_add-categories-and-tags, with a realdown(unlike the baseline):Two partial unique indexes stop siblings sharing a name — a single plain constraint would let duplicate root categories through, since
NULLparent_id compares unequal to every otherNULL.Deleting a category cascades to its subcategories and leaves their items in place, merely uncategorized.
API
GET /api/itemsacceptscategory,tags,min_price,max_price, combined with AND.UPDATEwith no stored paths to rewrite and drift.ANY()alone, which would have returned items carrying only one of the selected tags.400instead of being ignored, so a broken filter link shows itself rather than quietly listing the whole catalogue.GET /api/filtersserves the drawer its tree, tags, and price bounds in one request. Admin CRUD lives under/api/admin/categoriesand/api/admin/tags, inheriting the existing authentik boundary with no nginx change.Fix required by the feature
SELECT_WITH_IMAGESaggregated images throughLEFT JOIN+GROUP BY. Adding a second one-to-many join for tags to that shape multiplies rows together — an item with 2 images and 3 tags would have repeated every image three times. Both copies now use scalar subqueries, which drops theGROUP BYentirely. There is a regression test for exactly this case.UI
Storefront: a "Filters (N)" button opens a right-hand drawer holding the category tree, colour-coded tag pills labelled "Tags — must have all", and a price range. Applied filters appear as removable chips. Filter state lives in the URL query string, so filtered views are shareable and the back button works. Item cards show their category and tags.
Admin: new Categories and Tags tabs; the item form gains a category
TreeSelectand a tags input that creates new tags inline.Bug caught during verification
The admin category tree used
defaultExpandAll, which antd evaluates once at mount. Any branch created afterwards rendered collapsed, making its children unreachable — you could add a subcategory and it would vanish. Expansion is now controlled state, and creating or moving a node expands its parent. The storefront drawer was unaffected because it remounts each time it opens. Covered by a regression test in both specs.Verification
tsc --noEmitbackend / frontendnpm run build41 of the integration tests are new, covering tree CRUD, sibling name collisions, cycle rejection on reparent, cascade-delete fallout, descendant matching, tag AND semantics, price bounds, the filters combined, and on-the-fly tag creation.
Deploy note
This release needs a migration against the live database:
Post-deploy findings and corrections
Three defects surfaced when this was deployed and tested. Branch
fix/deploy-migration-safety, two commits, not yet pushed.1. The storefront showed no inventory after deploy
No data was lost. The migration only runs
CREATE TABLE IF NOT EXISTSandALTER TABLE items ADD COLUMN IF NOT EXISTS— nothing in it removes rows.The cause was code deployed ahead of its schema. The new item query references
categories,item_tags, anditems.category_id; against a database where the migration hadn't run, it failed. That failure was then invisible at every layer:.catch, andEmptyrendered "No items yet — check back soon" — a hung request was indistinguishable from an empty cataloguenode migrate.js upwas a separate manual step, easy to forgetTo confirm on the NAS, if it hasn't been already:
Fixes — one per layer:
CMD ["sh", "-c", "node migrate.js up && node dist/server.js"]. Code can no longer be ahead of the schema, and the manual step is gone from the deploy sequence.migrate.jsnow waits for Postgres to accept connections first (the NAS brings the DB container up slower than the app) and still exits non-zero, so a bad migration stops the container instead of serving against a half-migrated schema.fetchItems/fetchFilterOptionsnow throw on a non-OK response instead of returning the parsed error body, which would otherwise have been set as the item list and crashed the grid on.map.The general lesson, recorded in
.claude/project-context.md: any deploy where new code needs something the environment doesn't have yet surfaces as silence, not an error, unless something is written to make it loud.2. "Item added" was shown for a save that never happened
Confirmed and fixed.
saveItemreturnedres.json()with nores.okcheck, so a 4xx/5xx resolved normally and the caller reported success unconditionally. The same held for delete, image removal, and mark sold/available — the last two gave no feedback at all on failure.All admin calls now throw on a non-OK response. The item form reports the error, keeps the form open so entered values aren't lost, and only claims success once the server has accepted the write. Covered by e2e tests that intercept the request with a 500 and assert the success toast never appears, plus one that confirms a successful save actually reaches the database rather than just firing a toast.
This was almost certainly the same root cause as #1 — the save was failing server-side against the un-migrated schema — but the false success claim was a genuine defect in its own right and is fixed independently.
3. Categories could not be created inline from the item form
Correct, and a real gap: tags could be invented from the item form but categories could not, so needing a new category meant abandoning a half-filled form for the Categories tab.
The category picker now has a "New category name" field. New categories are created at the top level; nesting stays in the Categories tab, which keeps the control to a single decision.
Two related problems found while building it:
The app also now honours
prefers-reduced-motionby disabling antd transitions, which is an accessibility improvement and removes a class of animation-related test flake.Verification
tsc --noEmitbackend / frontendnpm run buildNote on PR #24
The
docs: bring project context up to datecommit was not included in the merge — it ended up dangling, reachable only through the reflog.git logon the branch looked normal because the commit simply wasn't on it. It has been recovered and is included in this branch. Worth runninggit branch --contains <sha>on any commit made after a push, before assuming it shipped.Still open
A separate on-demand QA environment stack for reviewing changes online, requested during this round. Being scoped separately — it's an infrastructure change rather than part of this issue.
Design mockups archived
Flagged in review: the wireframes behind the layout decisions were never committed.
Correct — they lived only in
.superpowers/brainstorm/, which I had gitignored. That call was half wrong. The directory itself should stay ignored, because alongside the mockups it holds a brainstorming-session token, PID files, and absolute local paths:What was wrong was letting the mockups die with it. They are now archived at
docs/superpowers/specs/2026-08-17-categories-and-tags-mockups/, next to the design spec and linked from it:filter-layout.htmlfilter-layout-mobile.htmlThe rejected options are kept deliberately — the comparison is the part worth preserving, and it's what makes the recorded decision meaningful later.
The tool serves these as fragments inside its own frame, so committing them raw would have produced files that render as unstyled markup. Each is wrapped as a standalone page with the frame's style tokens and its
toggleSelecthelper inlined; they open directly in a browser with no server and no network access. Verified rendering with no console errors.Convention recorded in
.claude/project-context.md:.superpowers/stays ignored, but design artifacts inside it get lifted into the specs directory before the session's scratch state is cleaned up.