🛠 This is the technical record for maintainers. The plain-language version users see lives in What's New.

June 2026

Admin control center (Phase 1), token search, feedback archive & more

Admin control center — Phase 1

  • Reworked #view-admin into a tabbed control center (switchAdmin() + .adm-tab/.adm-mod): Overview, Catalogue, Data Quality, Users, Series & Collections, Import/Export, Reports. Entry via enterAdminPanel(). Keyboard nav: g+o/c/d/u/s/i/r jumps, / focus search, and in Catalogue j/k move, x toggle, a select-all, Esc clear.
  • New endpoints (all requireAdmin, global books.id based, cross-user): GET /api/admin/overview, GET /api/admin/books (joins owner + series_name), PATCH /api/admin/books/bulk (registered before /:id to avoid capture), PATCH|DELETE /api/admin/books/:id, POST /api/admin/books/bulk-delete, GET /api/admin/quality + POST /api/admin/quality/{fix-whitespace,fix-orphan-series,purge-orphan-collections}, GET /api/admin/series, GET /api/admin/collections, POST /api/admin/series/merge, POST /api/admin/series/bulk-delete, GET /api/admin/export (csv/json, owner filter), GET /api/admin/backup (online db.backup() → timestamped .db download).
  • Catalogue: all books fetched once into adminBooks, filtered/sorted/selected client-side (token search, owner/status/sort). Inline read-toggle + delete by id; bulk read/unread, set type, delete via the selection bar. Single edits instant; bulk delete + cross-owner delete confirm.
  • Data Quality: 8 detection queries (dup ISBN, orphan series link, orphan collection rows, untrimmed title/author, empty series, missing cover/pages/genres). Deterministic groups get "Fix all"; dupes get per-row delete; missing cover gets OpenLibrary "Find cover" (GET /api/lookup/:isbnPATCH); missing pages get an inline input. Counts surface as Overview action cards + sidebar badges.
  • Bulk safety: all bulk mutations run in a single better-sqlite3 db.transaction(); bulk field edits whitelisted via ADMIN_BULK_FIELDS; shapeAdminBook() returns owner + series_name + read_date_start for the status filter.
  • No schema changes in the original Phase 1 cut. Activity log, error log, broken-cover scanning, command palette and CSV import are deferred to Phase 2/3. ⚠️ frontend/Dockerfile unaffected (no new static files).

Admin follow-up fixes

  • Data Quality no longer collapses on edit. loadQuality() now captures the set of expanded .adm-q-card.open keys before re-render and re-applies them (admQCard(g, isOpen)); the "Scanning…" placeholder only shows on first paint. Inline fixes (set pages, find cover, delete dupe) keep their card open.
  • Admin book editor. New #adm-edit-modal + openAdmEdit(id)/saveAdmEdit() editing title, author, ISBN, year, cover (URL, or "Fetch" via /lookup), pages, type, language, binding, genres — by global id via PATCH /api/admin/books/:id. Reachable from every book-based Data Quality item and from Catalogue rows. Fixes the "wrong/missing ISBN ⇒ can't get a cover" case (paste a URL, or correct the ISBN then fetch) and lets you fill genres. Registered in the ESC / back-gesture close stacks.
  • Series length / missing volumes. ⚠️ migration ALTER TABLE series ADD COLUMN length INTEGER; PATCH /api/series/:id now also accepts length. The series detail modal shows the declared length with an inline editor (editSeriesLength()) and a "Missing N of L: #…" / "Complete set" line computed from owned integer series_part values against the length.
  • Series-detail follow-ups. Editing a book from a series row no longer closes the series: dropped closeSeriesDetail() from the row's onclick, lowered #series-detail-modal to z-index:90 so the book editor (and other overlays) stack above it, and saveBook() now calls refreshSeriesDetailIfOpen(). formatSeriesPartsLabel() gained a hideMissing param (passed true when series.length is set) so the "missing …" suffix on the parts line no longer duplicates the red length-based missing box.

Configurable webhook (admin → Webhook tab)

  • ⚠️ migration CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT); webhook_url seeded from the env/default on first run. getWebhookConfig() resolves all settings with defaults.
  • Webhook layer split into postWebhook(url, embed) (low-level, returns 2xx) and notifyDiscord(embed, category) (respects webhook_enabled; category==='security' additionally gated by notify_security). The two password/reset notifications now pass 'security'.
  • Summary is now config-driven: sendFeedbackSummary(force) builds fields from summary_sections (open / in_progress / resolved) and honours quiet_when_empty. Replaced the fixed 12:30 timer with a self-rescheduling scheduleSummary() + msUntilSummary() supporting off / daily / weekly (+ weekday) at a configurable HH:MM; reschedules on every settings save.
  • Endpoints: GET /api/admin/settings, PATCH /api/admin/settings (whitelisted + coerced keys, then scheduleSummary()), POST /api/admin/settings/test (posts to the URL in the request body so you can test before saving), POST /api/admin/settings/send-now (forces a summary). New frontend module #adm-webhook (g w jump) with URL + Test, master toggle, frequency/weekday/time, section checkboxes, quiet-when-empty, security toggle, Save + Send-now.

Search

  • Added matchesSearch(query, ...fields) helper — tokenises the query on whitespace and requires every token to appear in the joined, lowercased fields (AND semantics, order-independent). So "Berserk 1" matches "Berserk Deluxe Vol. 1".
  • Replaced all substring .toLowerCase().includes(q) checks across the five search sites: getFiltered (books — now also searches series.name + series_part), renderSeries, renderCollections, filterStartReadingList, filterMarkReadList. Main search placeholder updated.

Feedback UI

  • Admin "Reports & requests" and the submitter "My reports" panel now split items into active vs. a collapsible <details> archive for done/declined (fbIsArchived helper). Active list shows an all-caught-up message when empty.
  • My reports archive predicate tightened to fbIsArchived(f) && f.seen_by_user === 1: a newly-resolved (unseen) item stays in the active list with an Updated badge and only moves to the archive after it's been seen (render happens before mark-seen, so the fetched seen_by_user still reflects the unseen state on first open). Admin view unchanged (archives by status only).

Discord

  • Removed the per-submission webhook from POST /api/feedback (flow is managed on-site). Added sendDailyFeedbackSummary() + scheduleDailyFeedbackSummary() — one embed per day at 12:30 with open / in-progress / resolved breakdowns (bug vs. feature), skipped when nothing is open or in progress. Container pinned to TZ=Europe/Amsterdam (docker-compose) so 12:30 is local wall-clock time and DST is handled automatically.

Fixes

  • Series detail rows now use the same 3-state status badge as book cards: read → openReadModal, reading/unread → openStatusMenu (start reading, update progress, mark done, stop, edit). The floating #status-menu (z-index 9999) renders above the modal overlay (z-index 100). Added refreshSeriesDetailIfOpen(), called from smStart/smStop/saveProgress/saveReadInfo/markUnread so the open series detail re-renders after a change. Replaced the earlier read-only quickToggleRead toggle. New .sbr-read.reading style.
  • Feedback textarea #fb-message bumped from 14px to 16px to stop iOS focus-zoom that lingered after the modal closed.

Reading progress, feedback persistence & two fixes

Database

  • Additive migration: ALTER TABLE books ADD COLUMN current_page INTEGER DEFAULT NULL; current_page added to the PATCH /api/books/:isbn allowed-fields list.
  • New feedback table (id, user_id FK, type, message, status, admin_note, created_at, updated_at, seen_by_user) plus idx_feedback_user.

API

  • POST /api/feedback now inserts a row in addition to the Discord webhook.
  • Added GET /api/feedback/mine, GET /api/feedback/unseen-count, POST /api/feedback/mark-seen, GET /api/admin/feedback, PATCH /api/admin/feedback/:id. Status whitelist: open / in_progress / done / declined. A status/note change resets seen_by_user = 0 to drive the user's notification badge.

Frontend

  • Reading progress: progress modal with live % preview, bookProgress() helper, progress bars on book cards + the dashboard Currently Reading card, and an "Update progress" item in the status menu.
  • Feedback: admin "Reports & requests" management UI (status dropdown + note field), submitter "My reports" panel, notification dot on the user menu + a login toast via refreshNotifications().

Fixes

  • Feedback Send button: querySelector('.btn-primary') also matched the active type-selector button (_syncFeedbackTypeButtons adds btn-primary to it). Now targets #fb-send-btn.
  • #mobile-bottom-nav defaulted to display:flex and showed on the login screen; now hidden until enterApp(), re-hidden on showAuth().

Dashboard rebuild, series grid & status internals

  • Dashboard. Rebuilt as a 3-column CSS grid (160px / 1fr / 240px), collapsing at ≤900px via CSS order. Toolbar forced display:none at initial load. Recently-added cover grid reduced from repeat(4) to repeat(2) to stop side-column overflow.
  • Reading status. 3-state tag rendered in makeBookCard; inline floating #status-menu popover positioned via getBoundingClientRect and clamped to the viewport.
  • Series. Tab refactored from accordion to CSS grid (repeat(auto-fill,minmax(140px,1fr))); new series.cover column with fallback to the first book's cover; detail modal via _renderSeriesDetail().
  • Filter dropdown. Per-section pill area capped at 3 rows with scroll; dropdown capped at min(560px,70vh); sticky section labels + footer.
  • Fixes. Sort dropdown right:0left:0 (was off-screen on mobile). formatSeriesPartsLabel() drops part 0, decimals, and the stray "+0". Feedback double-submit guarded with a feedbackSending flag.

Filters, bulk edit, metadata & mobile

  • Schema. Additive migrations added language, binding, and edition columns; series_part accepts decimals and zero (tracked but never counted as missing).
  • Filters. Moved from a flat bar to a popover with 7 dimensions; type/binding/language/series sections render only when multiple distinct values exist; active-filter badge counts engaged filters.
  • Bulk edit. Separate genre/collection/read actions merged into one modal issuing a partial PATCH — only changed fields are sent.
  • Mobile. Fixed bottom nav; PWA standalone + installable; 16px inputs (prevents iOS focus zoom); 44×44pt tap targets; aspect-ratio cover reservation; safe-area insets; numeric inputmode; popstate closes modals; sticky save button.
  • Feedback. POST /api/feedback → Discord webhook with username + type.

Layout blueprint, fixes & design system

  • Layout. Blueprint applied across views: result-count bar + filter toggle with active-filter badge (Books), spatial drill-in that hides the grid while browsing (Collections), parts-range summary + read-progress bar (Series). Fixed a stray closing tag that leaked the Dashboard exit button onto every tab.
  • Fixes. Custom genres persisted to localStorage and restored on open. ISBN-less books get a cover via the OpenLibrary search API. Removed the weekly background cover-refresh job (it was overwriting valid covers with placeholders); covers now refresh on-demand only when an image fails to load.
  • Design. "Private Reading Room" theme: Cormorant Garamond headings, Inter chrome, warm Parchment palette. Two-layer CSS custom-property token system (primitives → semantic roles), dark + light modes, WCAG AA contrast.

May 2026

Password reset, bulk scan & infrastructure

  • Password reset. Self-service flow issuing time-limited reset tokens; admins can generate tokens from the management panel. Reset links delivered via Discord webhook (no mail server).
  • Bulk scan. Retry for failed lookups, drag-to-reorder import queue, and manual fallback entry (title/author/year) for ISBNs that return no results.
  • Dashboard. Added as the default landing view with totals, pages read, streak, and genre/series breakdowns sourced from the local book cache.
  • Series fix. Parts-range summary (e.g. "Parts 1–3, 5–8 · missing 4"); fixed perpetual "0/0 read" by computing counts from the local cache instead of stale server totals.
  • Infra. Introduced themes.css token system (dark default + light). Google Fonts switched to display=swap. Nginx tuned: gzip level 6, HTML no-cache, static assets one-year immutable.

April 2026

Initial launch

  • Single-page app with an Express + better-sqlite3 (SQLite) backend, served via nginx in Docker.
  • Core: ISBN barcode scanning auto-populating book details, manual add/edit, star ratings, read/unread toggle, genre tags, series + collection grouping, and a multi-user account system with admin controls.