feat(categories): /categories browse page with counts + nav item (bookshelf-0v44o.1) #1074

Merged
zombor merged 3 commits from bd-bookshelf-0v44o.1 into main 2026-07-09 19:00:54 +00:00
Owner

Summary

  • New internal/categories package: cursor-paginated GET /categories endpoint (HTML + JSON, content-negotiated) with ?q= search, ?sort=name|count, ?dir=asc|desc; book counts via a single GROUP BY query — no N+1
  • Library scoping: fail-closed userLibraryIDs JOIN mirrors the authors pattern (empty IDs → empty result)
  • Nav sidebar: "Categories" item with live count badge placed between Authors and Notebook
  • Templates: categories_index.html + categories_index_controls.html using canonical CSS classes and sort_dropdown_controller
  • 100% internal/ coverage (45 new specs); wire.go excluded per gate convention

Test plan

  • make test green (all 45 categories specs + middleware nav tests pass)
  • make coverage green — zero uncovered statement blocks
  • make build green
  • golangci-lint zero issues on changed packages
  • Screenshot of /categories page posted below

Closes bead bookshelf-0v44o.1 on merge.

## Summary - New `internal/categories` package: cursor-paginated `GET /categories` endpoint (HTML + JSON, content-negotiated) with `?q=` search, `?sort=name|count`, `?dir=asc|desc`; book counts via a single GROUP BY query — no N+1 - Library scoping: fail-closed `userLibraryIDs` JOIN mirrors the `authors` pattern (empty IDs → empty result) - Nav sidebar: "Categories" item with live count badge placed between Authors and Notebook - Templates: `categories_index.html` + `categories_index_controls.html` using canonical CSS classes and `sort_dropdown_controller` - 100% internal/ coverage (45 new specs); `wire.go` excluded per gate convention ## Test plan - [x] `make test` green (all 45 categories specs + middleware nav tests pass) - [x] `make coverage` green — zero uncovered statement blocks - [x] `make build` green - [x] golangci-lint zero issues on changed packages - [x] Screenshot of `/categories` page posted below Closes bead bookshelf-0v44o.1 on merge.
feat(categories): /categories browse page with counts + nav item (bookshelf-0v44o.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 43s
/ E2E API (pull_request) Successful in 2m42s
/ Lint (pull_request) Successful in 3m35s
/ Integration (pull_request) Successful in 3m36s
/ E2E Browser (pull_request) Successful in 4m36s
/ Test (pull_request) Successful in 6m46s
32420a5397
- New internal/categories package: ListCategories service, ListHandler,
  RegisterRoutes, Wire; cursor-paginated (default 50, max 200), ?q= search,
  ?sort=name|count, ?dir=asc|desc; book-count via single GROUP BY query
- Library scoping: fail-closed userLibraryIDs JOIN mirrors authors pattern
- NavCounts.Categories wired via CountCategories sqlc query + nav middleware goroutine
- Templates: categories_index.html + categories_index_controls.html using
  canonical CSS classes + sort_dropdown_controller
- Sidebar: "Categories" nav item with count badge between Authors and Notebook
- 100% internal/ coverage (wire.go excluded per gate rules); 45 specs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

UI Screenshot — /categories browse page

Populated with 8 categories (seeded data), sidebar showing category count badge:

categories browse page

## UI Screenshot — `/categories` browse page Populated with 8 categories (seeded data), sidebar showing category count badge: ![categories browse page](/attachments/4f439a35-a9a1-4056-a73a-b248838eefb6)
chore: add screenshot script for /categories page (bookshelf-0v44o.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 33s
/ E2E API (pull_request) Successful in 2m19s
/ Lint (pull_request) Successful in 2m58s
/ Integration (pull_request) Successful in 3m10s
/ E2E Browser (pull_request) Successful in 4m13s
/ Test (pull_request) Successful in 8m2s
dd80ff0e2f
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

Security Review — PR #1072 (bookshelf-vgbmo.1): admin author rename/merge/delete

Independent adversarial security review of the diff (origin/main...origin/bd-bookshelf-vgbmo.1). Read-only; did not run tests.

Authorization (HARD RULE) — PASS. All three destructive routes are gated by the admin permission in internal/authors/routes.go:26-28: PATCH /authors/{id}, POST /authors/{id}/merge, and DELETE /authors/{id} are each wrapped with manageRequired(...), bound to d.LibraryManageMetadataConfigRequired in wire.go:71-73, which is users.PermissionRequired(... PermissionManageMetadataConfig) (app.go:356). That middleware fails closed (403 on no-claims / no-perms-row / check-false) and admin-short-circuits. Defense in depth is correct: the template gates the kebab + controller behind {{if .CurrentUser.CanManageMetadata}} (authors_index.html) AND the server enforces independently. No route left ungated. SQL is fully parameterized; author IDs are ParseInt-validated; the author_id FK (fk_book_metadata_author_mapping_author ... ON DELETE CASCADE, migration 0001:286) means a merge remap to a nonexistent target rolls back rather than orphaning rows. XSS is clean — the JS controller builds all modal content via textContent/_el(...), never innerHTML, and the template auto-escapes .Name.

Two findings below.


[BLOCKER] static/js/controllers/author_manage_controller.js:172,266,341 — mutation fetches omit the X-CSRF-Token header; every rename/merge/delete returns 403
All three state-changing fetches (_patchRename L172, _postMerge L266, _deleteAuthor L341) send only Content-Type: application/json (or no headers for DELETE) and NO X-CSRF-Token header. The global CSRF middleware (internal/middleware/csrf.go:91-99) validates every unsafe method (POST/PATCH/DELETE) against the double-submit token; /authors/* is not in the exempt list. With no header and a JSON (not form) body, csrfTokenFromRequest returns "" -> tokenEqual fails -> 403 "CSRF token mismatch". Result: the feature is 100% non-functional in production — no author can be renamed, merged, or deleted. This escapes CI because the browser e2e (journey_author_manage_test.go) only opens the rename modal and asserts the input is pre-filled; it never submits a mutation. Fix: read the bookshelf_csrf cookie and add "X-CSRF-Token": <token> to all three fetches — mirror the existing _csrfToken() helper used by every other mutation controller (e.g. column_picker_controller.js:48, bookdrop_bulk_edit_controller.js:132). Add an e2e/Vitest assertion that a mutation actually returns 204 (not just that the modal opens) so this cannot regress.

[MAJOR] internal/authors/manage_service.go:216 (primaryBookIDs) + RefreshSortAuthorNameBatch — unbounded query + single-statement IN() can exceed MySQL's placeholder limit on a large author
primaryBookIDs runs SELECT book_id ... WHERE author_id = ? AND sort_order = 0 with no LIMIT, then the collected slice is passed straight to RefreshSortAuthorNameBatch, which expands to one ? placeholder per book in a single UPDATE ... WHERE bm.book_id IN (?,?,...) statement (internal/db/sqlc/metadata.sql.go:437-449). At the project's stated target scale (hundreds of thousands of books/library), a catch-all author like "Unknown"/"Various" can map to >65,535 books; the batch UPDATE then fails with "Prepared statement contains too many placeholders", and merge/delete/rename of that author breaks. It also runs synchronously inline in the request handler (conventions push expensive per-book fan-out to background/batched work). Additionally MergeHandler caps source_ids at "not empty" but has no upper bound (only MaxBytes limits it indirectly). Fix: chunk the book-ID set into fixed-size batches for the sort refresh, or cap/paginate primaryBookIDs; and add an explicit upper bound on source_ids length in the handler.

REVIEW VERDICT: 1 blocker, 1 major, 0 minor

## Security Review — PR #1072 (bookshelf-vgbmo.1): admin author rename/merge/delete Independent adversarial security review of the diff (`origin/main...origin/bd-bookshelf-vgbmo.1`). Read-only; did not run tests. **Authorization (HARD RULE) — PASS.** All three destructive routes are gated by the admin permission in `internal/authors/routes.go:26-28`: `PATCH /authors/{id}`, `POST /authors/{id}/merge`, and `DELETE /authors/{id}` are each wrapped with `manageRequired(...)`, bound to `d.LibraryManageMetadataConfigRequired` in `wire.go:71-73`, which is `users.PermissionRequired(... PermissionManageMetadataConfig)` (app.go:356). That middleware fails closed (403 on no-claims / no-perms-row / check-false) and admin-short-circuits. Defense in depth is correct: the template gates the kebab + controller behind `{{if .CurrentUser.CanManageMetadata}}` (authors_index.html) AND the server enforces independently. No route left ungated. SQL is fully parameterized; author IDs are `ParseInt`-validated; the `author_id` FK (`fk_book_metadata_author_mapping_author ... ON DELETE CASCADE`, migration 0001:286) means a merge remap to a nonexistent target rolls back rather than orphaning rows. XSS is clean — the JS controller builds all modal content via `textContent`/`_el(...)`, never `innerHTML`, and the template auto-escapes `.Name`. Two findings below. --- [BLOCKER] static/js/controllers/author_manage_controller.js:172,266,341 — mutation fetches omit the X-CSRF-Token header; every rename/merge/delete returns 403 All three state-changing fetches (`_patchRename` L172, `_postMerge` L266, `_deleteAuthor` L341) send only `Content-Type: application/json` (or no headers for DELETE) and NO `X-CSRF-Token` header. The global CSRF middleware (`internal/middleware/csrf.go:91-99`) validates every unsafe method (POST/PATCH/DELETE) against the double-submit token; `/authors/*` is not in the exempt list. With no header and a JSON (not form) body, `csrfTokenFromRequest` returns "" -> `tokenEqual` fails -> `403 "CSRF token mismatch"`. Result: the feature is 100% non-functional in production — no author can be renamed, merged, or deleted. This escapes CI because the browser e2e (`journey_author_manage_test.go`) only opens the rename modal and asserts the input is pre-filled; it never submits a mutation. Fix: read the `bookshelf_csrf` cookie and add `"X-CSRF-Token": <token>` to all three fetches — mirror the existing `_csrfToken()` helper used by every other mutation controller (e.g. `column_picker_controller.js:48`, `bookdrop_bulk_edit_controller.js:132`). Add an e2e/Vitest assertion that a mutation actually returns 204 (not just that the modal opens) so this cannot regress. [MAJOR] internal/authors/manage_service.go:216 (primaryBookIDs) + RefreshSortAuthorNameBatch — unbounded query + single-statement IN() can exceed MySQL's placeholder limit on a large author `primaryBookIDs` runs `SELECT book_id ... WHERE author_id = ? AND sort_order = 0` with no LIMIT, then the collected slice is passed straight to `RefreshSortAuthorNameBatch`, which expands to one `?` placeholder per book in a single `UPDATE ... WHERE bm.book_id IN (?,?,...)` statement (internal/db/sqlc/metadata.sql.go:437-449). At the project's stated target scale (hundreds of thousands of books/library), a catch-all author like "Unknown"/"Various" can map to >65,535 books; the batch UPDATE then fails with "Prepared statement contains too many placeholders", and merge/delete/rename of that author breaks. It also runs synchronously inline in the request handler (conventions push expensive per-book fan-out to background/batched work). Additionally `MergeHandler` caps `source_ids` at "not empty" but has no upper bound (only `MaxBytes` limits it indirectly). Fix: chunk the book-ID set into fixed-size batches for the sort refresh, or cap/paginate `primaryBookIDs`; and add an explicit upper bound on `source_ids` length in the handler. REVIEW VERDICT: 1 blocker, 1 major, 0 minor
Author
Owner

UI Review — /categories browse page (bookshelf-0v44o.1)

Screenshot reviewed: /categories page — header + Search+Sort toolbar, vertical list of 5 categories each with an inline book count, "Showing 5" footer, Categories nav item active with badge "8" between Authors and Notebook.


[MINOR] templates/pages/categories_index.html:7-16 — bespoke layout class names with no CSS backing
Five new CSS class names are introduced (.category-grid, .category-card, .category-card-link, .category-card-name, .category-card-count) but static/css/main.css has zero CSS rules for any of them (confirmed via diff — no CSS changes in this PR). The page renders entirely via browser defaults: block <article> elements stacked vertically, inline spans flowing together. The class names declare intent for a grid/card layout (mirroring the .author-grid/.author-card naming convention, which DO have CSS rules in main.css at lines 6457–6526) but deliver none. Fix: either add CSS rules for the category list layout, or rename to reflect the actual plain-list structure (e.g. .category-list / .category-item) so intent matches reality.

[MINOR] templates/pages/categories_index.html:12 — book count has no visual treatment vs design system badges
<span class="category-card-count">{{.BookCount}}</span> renders inline right after the category name span with no CSS, producing "Adventure 2", "Classic 2" etc. — count glued to name with only whitespace separation. Every other count treatment in the design system uses a badge: .nav-count in the sidebar, .author-card-count-badge on the author photo overlay, .series-card-count-badge on series cover overlays. The count is functional and correct, but inconsistent with the visual language. Fix: add a .category-card-count CSS rule that visually distinguishes the count (e.g. color: var(--fg-muted); font-size: 0.75rem;), or separate name and count with a · delimiter, matching the scale used for similar text-list items elsewhere.


Passing checks:

  • No inline style= attributes — CSP clean.
  • Controls toolbar correctly reuses .btn.btn-sm for Go/Clear buttons and mirrors authors_index_controls.html structure exactly.
  • Pagination reuses .pagination, .pagination-showing, .btn.btn-load-more, .empty-state — all canonical.
  • Sidebar "Categories" nav item placed correctly between Authors and Notebook (base.html:143–145) with .nav-count badge showing "8" — consistent with all other nav items.
  • sort-dropdown Stimulus controller reused, not re-invented.
  • Sidebar carets render correctly (prior font artifact confirmed gone).

REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## UI Review — /categories browse page (bookshelf-0v44o.1) **Screenshot reviewed:** `/categories` page — header + Search+Sort toolbar, vertical list of 5 categories each with an inline book count, "Showing 5" footer, Categories nav item active with badge "8" between Authors and Notebook. --- [MINOR] templates/pages/categories_index.html:7-16 — bespoke layout class names with no CSS backing Five new CSS class names are introduced (`.category-grid`, `.category-card`, `.category-card-link`, `.category-card-name`, `.category-card-count`) but `static/css/main.css` has **zero CSS rules** for any of them (confirmed via diff — no CSS changes in this PR). The page renders entirely via browser defaults: block `<article>` elements stacked vertically, inline spans flowing together. The class names declare intent for a grid/card layout (mirroring the `.author-grid`/`.author-card` naming convention, which DO have CSS rules in main.css at lines 6457–6526) but deliver none. Fix: either add CSS rules for the category list layout, or rename to reflect the actual plain-list structure (e.g. `.category-list` / `.category-item`) so intent matches reality. [MINOR] templates/pages/categories_index.html:12 — book count has no visual treatment vs design system badges `<span class="category-card-count">{{.BookCount}}</span>` renders inline right after the category name span with no CSS, producing "Adventure 2", "Classic 2" etc. — count glued to name with only whitespace separation. Every other count treatment in the design system uses a badge: `.nav-count` in the sidebar, `.author-card-count-badge` on the author photo overlay, `.series-card-count-badge` on series cover overlays. The count is functional and correct, but inconsistent with the visual language. Fix: add a `.category-card-count` CSS rule that visually distinguishes the count (e.g. `color: var(--fg-muted); font-size: 0.75rem;`), or separate name and count with a `·` delimiter, matching the scale used for similar text-list items elsewhere. --- **Passing checks:** - No inline `style=` attributes — CSP clean. - Controls toolbar correctly reuses `.btn.btn-sm` for Go/Clear buttons and mirrors `authors_index_controls.html` structure exactly. - Pagination reuses `.pagination`, `.pagination-showing`, `.btn.btn-load-more`, `.empty-state` — all canonical. - Sidebar "Categories" nav item placed correctly between Authors and Notebook (base.html:143–145) with `.nav-count` badge showing "8" — consistent with all other nav items. - `sort-dropdown` Stimulus controller reused, not re-invented. - Sidebar carets render correctly (prior font artifact confirmed gone). REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

Security Review — PR #1074 (bookshelf-0v44o.1) /categories browse page

Read-only adversarial review of git diff origin/main...origin/bd-bookshelf-0v44o.1, per .claude/rules/review-standard.md.

[MAJOR] internal/db/queries/nav_counts.sql:24 (+ internal/db/sqlc/nav_counts.sql.go CountCategories, internal/middleware/nav.go:325-337, internal/app/app.go:719) — nav Categories badge count is NOT library-scoped
CountCategories is SELECT COUNT(*) FROM category with no user/library filter, wired via middleware.WrapInt64(q.CountCategories, …) (the unscoped wrapper — same as CountAuthors) and called as counts.CountCategories(ctx) with no userLibraryIDs. The list page IS scoped (buildCategoryLibraryJoin joins through book + WHERE b.library_id IN (…)), so the badge counts categories across ALL libraries while the page only shows those reachable through the user's accessible libraries. This is exactly the observed screenshot discrepancy (nav badge 8 vs page 5): the badge leaks the existence/number of categories in libraries the user cannot access — a per-user-scoping HARD RULE violation (cross-library data leak). Fix: mirror series.CountDistinctSeries — give CountCategories a []int64 userLibraryIDs param that scopes via JOIN book_metadata_category_mapping m → JOIN book b WHERE b.library_id IN (…) (fail-closed on non-nil empty), wire it with middleware.WrapInt64WithLibraries + resolve GetUserLibraryIDs in the nav goroutine (as the series count already does at nav.go:288-300).

[MAJOR] internal/categories/service.go (buildCategoryLibraryJoin) — list AND count query omit b.deleted = 0
buildCategoryLibraryJoin emits JOIN book b ON b.id = m.book_id WHERE b.library_id IN (…) with no AND b.deleted = 0. Every other book-joined query filters soft-deleted rows (internal/books/store.go:606 WHERE b.deleted = 0; series list/count likewise). As written, a category reachable only through soft-deleted books is still listed, and its per-category COUNT(m.book_id) includes deleted books — so the counts shown to the user are wrong and categories that should be gone still appear. Fix: add AND b.deleted = 0 to the book join in both the list query and the (to-be-scoped) count query.

[MINOR] internal/categories/service.go (buildListCategoriesQuery, q branch) — LIKE metacharacters in ?q= not escaped
Search builds c.name LIKE ? with arg p.Query+"%". Parameterization prevents SQL injection (good), but % and _ in the term act as LIKE wildcards, so q=% matches everything and q=a_c matches abc. The books search escapes this (LIKE CONCAT(?, '%') ESCAPE '\\' + escapeLikeMetachars). Not a security hole (LIMIT-bounded, suffix-anchored so no leading-wildcard table scan), but inconsistent/surprising search semantics. Fix: escape % _ \ in the term with an ESCAPE clause as books does.

[MINOR] internal/categories/service.go (buildCategoryCursorPredicate, name-DESC branch) — cursor id tiebreaker direction mismatch under name DESC
For sort=name&dir=desc the ORDER BY is c.name DESC, c.id DESC, but the cursor predicate uses c.name < ? OR (c.name = ? AND c.id > ?) — the id tiebreaker walks ascending while the rows are ordered id-descending. This only misbehaves when two categories share a name (category names are effectively unique today, so low impact) but is a latent skip/repeat at the tie boundary. Fix: make the id comparator track the sort direction (c.id < ? for DESC), matching the ORDER BY.

Verified clean:

  • Injection: ?sort=/?dir=(?order=) validated against closed allowlists (validSortKeys/validSortDirs) before reaching the interpolated ORDER BY; only ASC/DESC and enum columns can appear — no ORDER BY injection. ?q= and cursor fields are bound as ? params. ?limit= is Atoi + clampLimit.
  • ORDER BY total-ordering (flake): both sorts carry a unique c.id tiebreaker.
  • XSS: category {{.Name}}, {{.SearchQuery}} render in html/template HTML/attribute contexts (auto-escaped); /books?category={{urlquery .Name}} uses urlquery. No inline style= (CSP OK).
  • DoS: every list query ends in LIMIT ?, clamped to [1,200]; single GROUP BY (no N+1).
  • Fail-closed scoping of the LIST query is correct: GetUserLibraryIDs normalizes nil→[]int64{}, and ListCategories returns an empty page for non-nil-empty userLibraryIDs, so an authenticated user with no library access sees nothing.

REVIEW VERDICT: 0 blocker, 2 major, 2 minor

## Security Review — PR #1074 (bookshelf-0v44o.1) `/categories` browse page Read-only adversarial review of `git diff origin/main...origin/bd-bookshelf-0v44o.1`, per `.claude/rules/review-standard.md`. [MAJOR] internal/db/queries/nav_counts.sql:24 (+ internal/db/sqlc/nav_counts.sql.go CountCategories, internal/middleware/nav.go:325-337, internal/app/app.go:719) — nav Categories badge count is NOT library-scoped `CountCategories` is `SELECT COUNT(*) FROM category` with no user/library filter, wired via `middleware.WrapInt64(q.CountCategories, …)` (the unscoped wrapper — same as CountAuthors) and called as `counts.CountCategories(ctx)` with no `userLibraryIDs`. The list page IS scoped (buildCategoryLibraryJoin joins through book + `WHERE b.library_id IN (…)`), so the badge counts categories across ALL libraries while the page only shows those reachable through the user's accessible libraries. This is exactly the observed screenshot discrepancy (nav badge 8 vs page 5): the badge leaks the existence/number of categories in libraries the user cannot access — a per-user-scoping HARD RULE violation (cross-library data leak). Fix: mirror `series.CountDistinctSeries` — give `CountCategories` a `[]int64` userLibraryIDs param that scopes via `JOIN book_metadata_category_mapping m → JOIN book b WHERE b.library_id IN (…)` (fail-closed on non-nil empty), wire it with `middleware.WrapInt64WithLibraries` + resolve `GetUserLibraryIDs` in the nav goroutine (as the series count already does at nav.go:288-300). [MAJOR] internal/categories/service.go (buildCategoryLibraryJoin) — list AND count query omit `b.deleted = 0` `buildCategoryLibraryJoin` emits `JOIN book b ON b.id = m.book_id WHERE b.library_id IN (…)` with no `AND b.deleted = 0`. Every other book-joined query filters soft-deleted rows (internal/books/store.go:606 `WHERE b.deleted = 0`; series list/count likewise). As written, a category reachable only through soft-deleted books is still listed, and its per-category `COUNT(m.book_id)` includes deleted books — so the counts shown to the user are wrong and categories that should be gone still appear. Fix: add `AND b.deleted = 0` to the book join in both the list query and the (to-be-scoped) count query. [MINOR] internal/categories/service.go (buildListCategoriesQuery, `q` branch) — LIKE metacharacters in `?q=` not escaped Search builds `c.name LIKE ?` with arg `p.Query+"%"`. Parameterization prevents SQL injection (good), but `%` and `_` in the term act as LIKE wildcards, so `q=%` matches everything and `q=a_c` matches `abc`. The books search escapes this (`LIKE CONCAT(?, '%') ESCAPE '\\'` + `escapeLikeMetachars`). Not a security hole (LIMIT-bounded, suffix-anchored so no leading-wildcard table scan), but inconsistent/surprising search semantics. Fix: escape `% _ \` in the term with an `ESCAPE` clause as books does. [MINOR] internal/categories/service.go (buildCategoryCursorPredicate, name-DESC branch) — cursor id tiebreaker direction mismatch under name DESC For `sort=name&dir=desc` the ORDER BY is `c.name DESC, c.id DESC`, but the cursor predicate uses `c.name < ? OR (c.name = ? AND c.id > ?)` — the id tiebreaker walks ascending while the rows are ordered id-descending. This only misbehaves when two categories share a name (category names are effectively unique today, so low impact) but is a latent skip/repeat at the tie boundary. Fix: make the id comparator track the sort direction (`c.id < ?` for DESC), matching the ORDER BY. Verified clean: - Injection: `?sort=`/`?dir=`(`?order=`) validated against closed allowlists (validSortKeys/validSortDirs) before reaching the interpolated ORDER BY; only `ASC`/`DESC` and enum columns can appear — no ORDER BY injection. `?q=` and cursor fields are bound as `?` params. `?limit=` is Atoi + clampLimit. - ORDER BY total-ordering (flake): both sorts carry a unique `c.id` tiebreaker. - XSS: category `{{.Name}}`, `{{.SearchQuery}}` render in html/template HTML/attribute contexts (auto-escaped); `/books?category={{urlquery .Name}}` uses urlquery. No inline `style=` (CSP OK). - DoS: every list query ends in `LIMIT ?`, clamped to [1,200]; single GROUP BY (no N+1). - Fail-closed scoping of the LIST query is correct: `GetUserLibraryIDs` normalizes nil→`[]int64{}`, and `ListCategories` returns an empty page for non-nil-empty userLibraryIDs, so an authenticated user with no library access sees nothing. REVIEW VERDICT: 0 blocker, 2 major, 2 minor
Author
Owner

UI Screenshot — /categories browse page

Populated with 8 categories (seeded data), sidebar showing category count badge:

categories browse page

## UI Screenshot — `/categories` browse page Populated with 8 categories (seeded data), sidebar showing category count badge: ![categories browse page](/attachments/0133069b-cf05-4f47-8dee-771167e482a7)
fix(categories): scope nav count, fix cursor DESC, deleted filter, LIKE escape, card grid CSS (bookshelf-0v44o.1)
All checks were successful
/ JS Unit Tests (pull_request) Successful in 1m15s
/ E2E API (pull_request) Successful in 2m8s
/ Lint (pull_request) Successful in 2m51s
/ Integration (pull_request) Successful in 2m55s
/ E2E Browser (pull_request) Successful in 3m16s
/ Test (pull_request) Successful in 6m21s
ef9bf1da1d
- NavCountDeps.CountCategories: change to func(ctx, []int64) to scope the
  badge count to the user's library IDs (mirrors CountDistinctSeries);
  badge now matches the /categories page count exactly
- Extract resolveNavLibraryIDs helper to avoid duplication in launchNavMetaCounts
- categories.CountCategories: new library-scoped count function (joins
  book_metadata_category_mapping → book with deleted=0 filter)
- buildCategoryLibraryJoin: add AND b.deleted=0 to the book join so
  soft-deleted books are excluded from list results and counts
- buildCategoryCursorPredicate: use c.id < ? (not >) for DESC tiebreaker
  so DESC pagination doesn't skip or repeat rows
- buildListCategoriesQuery: count sort ORDER BY tiebreaker now uses direction-
  consistent c.id DIR (was always ASC); LIKE now escapes % _ \ metacharacters
- Add .category-grid / .category-card / .category-card-link / .category-card-name
  / .category-card-count CSS rules mirroring .author-grid / .author-card idiom
- Add CountCategories tests (scoped, unscoped, fail-closed, error)
- Wire categories.CountCategories in app.go via WrapInt64WithLibraries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Author
Owner

UI Review — /categories card grid (bookshelf-0v44o.1)

Screenshot reviewed: cat_grid.png — 5 category cards (Adventure/Classic/Cyberpunk/Fantasy/Science Fiction), nav badge = 5.

What I see in the rendered screenshot

Five equal-height cards in a responsive grid. Each card: centered category name, count badge (dark pill with white number) below the name. Cards have border, rounded corners, and sit on the dark var(--bg-card) surface. Clean, not sparse — the centered text + badge fills the card well at its min-height: 90px. Nav count badge reads 5, matching the grid. Overall a solid text-only card treatment.


[MINOR] static/css/main.css:6573,6575 — hardcoded rgba(0, 0, 0, 0.55) / #fff in .category-card-count
The badge background and text are hardcoded instead of CSS-variable tokens. The existing .author-card-count-badge does the same (rgba(0, 0, 0, 0.65) / #fff), so this is consistent with the established pattern rather than a new sin — but it will look wrong on any future light theme since a near-black overlay over a light card is invisible. Worth extracting to something like var(--badge-bg) / var(--badge-fg) in a follow-up sweep that also fixes the author badge. Not blocking here since the pattern predates this PR.

[MINOR] static/css/main.css:6556min-height: 90px is a bare pixel value, not a space token
All other sizing in this PR uses var(--space-*). min-height: 90px is an outlier. var(--space-24) (if it exists at 96px) or the closest space token would be cleaner. Minor; does not affect visual correctness at this viewport.


Checklist

  1. Canonical-component reuse — PASS. No bespoke parallel class system. .category-grid / .category-card are purpose-built new classes for this text-only card type, not re-inventing .modal-dialog, .metadata-field, or any canonical shared component. Correct approach.
  2. Spacing / tokens — PASS (modulo the badge hardcodes above). All gap, padding, margin, color, radius, and hover accent use var(--space-*) / var(--bg-card) / var(--border) / var(--accent) / var(--radius-sm) / var(--fg) correctly.
  3. Hover state — PASS. transform: translateY(-2px) (lift) + border-color: var(--accent) matches the required lift-and-accent-border pattern.
  4. Responsive — PASS. repeat(auto-fill, minmax(140px, 1fr)) desktop, minmax(100px, 1fr)) mobile — wraps sensibly at small widths.
  5. No inline style= — PASS. No CSP violation.
  6. Reference match vs /authors — PASS with appropriate adaptation. Author cards use a photo-with-overlaid-badge; category cards have no image, so name + badge centered within the card is the right text-only adaptation. Card border/radius/hover/gap tokens are consistent.
  7. Badge placement — count badge is below the name (flex-column layout inside the card). Different from author (absolute overlay on photo) but intentionally adapted for text-only content. Readable in the dark theme screenshot.
  8. Count accuracy — nav badge 5 matches the 5 rendered cards. Correct.

REVIEW VERDICT: 0 blocker, 0 major, 2 minor

## UI Review — /categories card grid (bookshelf-0v44o.1) **Screenshot reviewed:** `cat_grid.png` — 5 category cards (Adventure/Classic/Cyberpunk/Fantasy/Science Fiction), nav badge = 5. ### What I see in the rendered screenshot Five equal-height cards in a responsive grid. Each card: centered category name, count badge (dark pill with white number) below the name. Cards have border, rounded corners, and sit on the dark `var(--bg-card)` surface. Clean, not sparse — the centered text + badge fills the card well at its `min-height: 90px`. Nav count badge reads 5, matching the grid. Overall a solid text-only card treatment. --- [MINOR] `static/css/main.css:6573,6575` — hardcoded `rgba(0, 0, 0, 0.55)` / `#fff` in `.category-card-count` The badge background and text are hardcoded instead of CSS-variable tokens. The existing `.author-card-count-badge` does the same (`rgba(0, 0, 0, 0.65)` / `#fff`), so this is consistent with the established pattern rather than a new sin — but it will look wrong on any future light theme since a near-black overlay over a light card is invisible. Worth extracting to something like `var(--badge-bg)` / `var(--badge-fg)` in a follow-up sweep that also fixes the author badge. Not blocking here since the pattern predates this PR. [MINOR] `static/css/main.css:6556` — `min-height: 90px` is a bare pixel value, not a space token All other sizing in this PR uses `var(--space-*)`. `min-height: 90px` is an outlier. `var(--space-24)` (if it exists at 96px) or the closest space token would be cleaner. Minor; does not affect visual correctness at this viewport. --- ### Checklist 1. **Canonical-component reuse** — PASS. No bespoke parallel class system. `.category-grid` / `.category-card` are purpose-built new classes for this text-only card type, not re-inventing `.modal-dialog`, `.metadata-field`, or any canonical shared component. Correct approach. 2. **Spacing / tokens** — PASS (modulo the badge hardcodes above). All gap, padding, margin, color, radius, and hover accent use `var(--space-*)` / `var(--bg-card)` / `var(--border)` / `var(--accent)` / `var(--radius-sm)` / `var(--fg)` correctly. 3. **Hover state** — PASS. `transform: translateY(-2px)` (lift) + `border-color: var(--accent)` matches the required lift-and-accent-border pattern. 4. **Responsive** — PASS. `repeat(auto-fill, minmax(140px, 1fr))` desktop, `minmax(100px, 1fr))` mobile — wraps sensibly at small widths. 5. **No inline `style=`** — PASS. No CSP violation. 6. **Reference match vs /authors** — PASS with appropriate adaptation. Author cards use a photo-with-overlaid-badge; category cards have no image, so name + badge centered within the card is the right text-only adaptation. Card border/radius/hover/gap tokens are consistent. 7. **Badge placement** — count badge is below the name (flex-column layout inside the card). Different from author (absolute overlay on photo) but intentionally adapted for text-only content. Readable in the dark theme screenshot. 8. **Count accuracy** — nav badge 5 matches the 5 rendered cards. Correct. --- REVIEW VERDICT: 0 blocker, 0 major, 2 minor
Author
Owner

CODE REVIEW: RE-VERIFICATION OF FIX COMMITS

Commit: ef9bf1da — fix(categories): scope nav count, fix cursor DESC, deleted filter, LIKE escape, card grid CSS

Verified all 5 fix items against the prior review findings:

1. Nav count scoping (internal/middleware/nav.go + internal/categories/service.go)

  • CountCategories in service.go correctly handles library scoping:
    • nil userLibraryIDs → unscoped (all libraries)
    • non-nil empty → fail-closed (returns 0 without query)
    • non-nil non-empty → scoped via AND b.library_id IN (...)
  • New helper resolveNavLibraryIDs in nav.go properly handles errors (suppresses badge, returns false)
  • Categories goroutine in launchNavMetaCounts mirrors the series pattern exactly
  • nav_test.go verifies badge population (Categories=5) and error suppression (HasCategories=false)
  • ✓ All correct

2. Cursor DESC tiebreaker (internal/categories/service.go:buildCategoryCursorPredicate)

For SortName:

  • ASC: (c.name > ? OR (c.name = ? AND c.id > ?)) — correct, after cursor
  • DESC: (c.name < ? OR (c.name = ? AND c.id < ?)) — correct, before cursor

For SortCount:

  • ASC: (book_count > ? OR (book_count = ? AND c.id > ?)) — correct, greater
  • DESC: (book_count < ? OR (book_count = ? AND c.id < ?)) — correct, less

Tiebreaker c.id comparison direction matches sort direction in all cases. service_test.go covers name/count sorting in both ASC and DESC directions.

  • ✓ All correct

3. b.deleted = 0 filter (internal/categories/service.go)

  • buildCountCategoriesQuery: JOIN book b ON b.id = m.book_id AND b.deleted = 0
  • buildCategoryLibraryJoin: JOIN book b ON b.id = m.book_id AND b.deleted = 0
  • Applied to both list and count queries

4. LIKE escaping (internal/categories/service.go:buildListCategoriesQuery)

  • Uses strings.NewReplacer to escape \, %, _
  • Query includes ESCAPE '\\' clause
  • Appends with wildcard: escaped+"%"
  • service_test.go covers query path
  • ✓ Correct

5. Card grid CSS (static/css/main.css + templates/pages/categories_index.html)

CSS classes reuse canonical design tokens:

  • .category-grid: gap: var(--space-6), grid-template-columns: repeat(auto-fill, minmax(140px, 1fr))
  • .category-card: var(--bg-card), var(--border), var(--accent), var(--radius-sm)
  • .category-card-count: Dark badge with border-radius: var(--radius-sm)
  • No bespoke parallel class system
  • Mobile media query with responsive grid
  • Template correctly uses all classes
  • ✓ All correct

6. Test integrity

  • All test files: package categories_test (black-box, no white-box)
  • Comprehensive coverage: scoped/unscoped/fail-closed, errors, cursor/sort, LIKE
  • No gamed tests, no assert-nothing patterns
  • ✓ All correct

7. Configuration

  • No new exclusions in .golangci.yml
  • No coverage gates lowered ✓

REVIEW VERDICT: 0 blockers, 0 majors, 0 minors

All fixes verified as correct and complete. Ready for merge.

## CODE REVIEW: RE-VERIFICATION OF FIX COMMITS **Commit:** ef9bf1da — fix(categories): scope nav count, fix cursor DESC, deleted filter, LIKE escape, card grid CSS Verified all 5 fix items against the prior review findings: ### 1. Nav count scoping (internal/middleware/nav.go + internal/categories/service.go) - `CountCategories` in service.go correctly handles library scoping: - nil userLibraryIDs → unscoped (all libraries) - non-nil empty → fail-closed (returns 0 without query) - non-nil non-empty → scoped via `AND b.library_id IN (...)` - New helper `resolveNavLibraryIDs` in nav.go properly handles errors (suppresses badge, returns false) - Categories goroutine in `launchNavMetaCounts` mirrors the series pattern exactly - nav_test.go verifies badge population (Categories=5) and error suppression (HasCategories=false) - ✓ All correct ### 2. Cursor DESC tiebreaker (internal/categories/service.go:buildCategoryCursorPredicate) For SortName: - ASC: `(c.name > ? OR (c.name = ? AND c.id > ?))` — correct, after cursor - DESC: `(c.name < ? OR (c.name = ? AND c.id < ?))` — correct, before cursor For SortCount: - ASC: `(book_count > ? OR (book_count = ? AND c.id > ?))` — correct, greater - DESC: `(book_count < ? OR (book_count = ? AND c.id < ?))` — correct, less Tiebreaker `c.id` comparison direction matches sort direction in all cases. service_test.go covers name/count sorting in both ASC and DESC directions. - ✓ All correct ### 3. b.deleted = 0 filter (internal/categories/service.go) - `buildCountCategoriesQuery`: `JOIN book b ON b.id = m.book_id AND b.deleted = 0` ✓ - `buildCategoryLibraryJoin`: `JOIN book b ON b.id = m.book_id AND b.deleted = 0` ✓ - Applied to both list and count queries ### 4. LIKE escaping (internal/categories/service.go:buildListCategoriesQuery) - Uses `strings.NewReplacer` to escape `\`, `%`, `_` - Query includes `ESCAPE '\\' ` clause - Appends with wildcard: `escaped+"%"` - service_test.go covers query path - ✓ Correct ### 5. Card grid CSS (static/css/main.css + templates/pages/categories_index.html) CSS classes reuse canonical design tokens: - `.category-grid`: `gap: var(--space-6)`, `grid-template-columns: repeat(auto-fill, minmax(140px, 1fr))` - `.category-card`: `var(--bg-card)`, `var(--border)`, `var(--accent)`, `var(--radius-sm)` - `.category-card-count`: Dark badge with `border-radius: var(--radius-sm)` - No bespoke parallel class system - Mobile media query with responsive grid - Template correctly uses all classes - ✓ All correct ### 6. Test integrity - All test files: `package categories_test` (black-box, no white-box) - Comprehensive coverage: scoped/unscoped/fail-closed, errors, cursor/sort, LIKE - No gamed tests, no assert-nothing patterns - ✓ All correct ### 7. Configuration - No new exclusions in `.golangci.yml` ✓ - No coverage gates lowered ✓ --- **REVIEW VERDICT: 0 blockers, 0 majors, 0 minors** All fixes verified as correct and complete. Ready for merge.
zombor merged commit 7111af0181 into main 2026-07-09 19:00:54 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!1074
No description provided.