Skip to content

Add browser table view - #745

Merged
ajslater merged 83 commits into
developfrom
browser-table-view
May 9, 2026
Merged

ajslater merged 83 commits into
developfrom
browser-table-view

Conversation

@ajslater

@ajslater ajslater commented May 9, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a table-view mode to the browser, alongside the existing cover grid. Users pick which columns to show, drag to reorder them, sort by any column (with shift-click for multi-column sort), and the M2M / scalar intersection columns on group rows display the intersection of children — "every child agrees on 2024 → show 2024; otherwise blank" — to match how cover view captions already behave.

The branch also folds in:

  • A 5-second-grace cancel button on the loading spinner that resets the heavy bits (sort + columns) but keeps filters / search.
  • A relative-cost indicator (clock icon) next to expensive columns in the column picker.
  • Two new vitest test files for browser-table-cell.vue and the filterShowGatedDefaults helper.

What changed

Backend

  • Settings: SettingsBrowser gains view_mode (cover | table), table_columns (per-top-group dict of ordered keys), table_cover_size (single sm choice in v1), and order_extra_keys (JSON list of multi-column-sort tiebreakers). Round-tripped through DIRECT_KEYS so saved-views clones include them automatically.
  • Migration 0041_browser_table_view.py is the consolidated v1 schema: adds the four new fields, refreshes order_by choices to the post-table-view set (drops issue_number/issue_suffix for a single issue key, adds ~30 keys for FK names + M2M sort), and ships develop's phantom Comic-as-folder cleanup as the final RunPython. Stored settings carrying retired keys 400 until re-saved (no coercion shim).
  • Column registry (codex/choices/browser.py): BROWSER_TABLE_COLUMNS, BROWSER_TABLE_DEFAULT_COLUMNS (per top-group), BROWSER_TABLE_COLUMN_COSTS (medium / high tiers). Exported as JSON via make build-choices with snake_case keys preserved (the keys are protocol identifiers in the columns= query param).
  • Serializer (codex/serializers/browser/page.py): unified BrowserPageSerializer always emits groups / books and additionally emits rows when columns= is present. Mobile auto-fallback (smAndDown) reuses the same response without re-fetching.
  • Intersections (codex/views/browser/intersections.py): brand-new module that computes group-row M2M intersections for display and sort. Scalar intersection sort uses a CASE-based RawSQL that mirrors the display rule (COUNT(field) = total AND MIN = MAX → MIN, else NULL); simple-M2M intersection sort splits aggregation from the target name JOIN (three-stage SQL) for performance; compound paths (credits, identifiers, story_arcs, universes) wrap the SQL template in a helper. Folder rows route through the Comic.folders ancestor M2M instead of the direct-parent FK so non-leaf folders aggregate / sort correctly. Scalar columns are batched into one query; simple-M2M intersections are batched via UNION ALL.
  • Order-by pipeline (codex/views/browser/annotate/order.py, order_by.py): single virtual issue order_by entry expands to [issue_number, issue_suffix] ORDER BY, multi-column sort appends extras between primary's order_fields_head and the pk tiebreaker, group queries annotate per-extra _table_extra_value_<idx> aliases for M2M / count / lazy aggregates. Two keys can't be expressed as extras (story_arc_number needs context, search_score needs an FTS subquery) — listed in BROWSER_EXTRA_SORT_UNSUPPORTED_KEYS and the frontend mirrors it.

Frontend

  • New components: browser-table.vue, browser-table-cell.vue, browser-table-column-picker.vue, view-mode-toggle.vue, columns-button.vue.
  • Plain <table> instead of <v-table> — Vuetify's internal scroll wrapper competed with the page scroll container.
  • Sticky leading columns: checkbox + cover pin to the left edge during horizontal scroll. Selected-row background uses --v-theme-surface-light.
  • Cells wrap to a 3-line clamp (-webkit-line-clamp) with overflow-wrap: break-word for unbreakable tokens; native :title tooltip surfaces full value on clamped cells. Issue / cover / bool cells stay nowrap.
  • Compound issue cell is split-justified — backend emits {number, suffix} and the cell renders two flex halves so digits align at the cell midpoint.
  • Drag-to-reorder in the picker uses native HTML5 D&D (no extra dep).
  • Multi-column sort: shift-click cycles asc → desc → off; primary unchanged, each extra header gets a small superscript priority badge (², ³, ⁴ …); discoverable via :title tooltip.
  • Mobile auto-fallback triggers below smAndDown (~960px); even with viewMode=table narrow viewports get the cover grid. Toolbar adjusts accordingly.
  • Default-columns gating: imprint_name / volume_name only lead the per-top-group default tuples when show.i / show.v are on. Backend mirrors via default_columns_filtered; the two stay in sync via the filterShowGatedDefaults helper.
  • Cost indicator in column picker: clock icon next to medium- and high-cost columns, faded for medium and warning-color for high; tooltip on hover.

Cancel button

A 5-second-grace cancel button appears on the loading spinner. Pressing it aborts the in-flight request and resets the per-top-group defaults for order (sort + extras) and columns; filters / search / view-mode are kept. Tooltip explains what gets reset.

Tests

  • 4 new pytest files: test_browser_columns_registry.py, test_browser_ordering.py, test_browser_settings_table.py, test_browser_table_response.py.
  • 2 new vitest files: browser-table-cell.test.js (14 tests) and browser-store-helpers.test.js (12 tests).

Test plan

  • make test passes (193 backend pytest, 27 frontend vitest)
  • make lint clean
  • makemigrations --check --dry-run reports no drift
  • Toggle view mode in the toolbar; table view appears with default columns
  • Open the column picker, toggle / reorder / drag columns; close + reopen — selections persist
  • All / None / Defaults shortcuts in the picker
  • Click column header to sort asc/desc/off; shift-click a second header to chain extras
  • Switch top-group through Publisher → Imprint → Series → Volume → Folder → Story Arc; group rows show intersection values
  • Hide imprints / volumes from breadcrumb (show.i / show.v off); Defaults shortcut omits those columns
  • Resize viewport below smAndDown; mobile auto-fallback returns the cover grid
  • Trigger a slow query; cancel button appears after 5s and resets sort + columns

Reviewer notes

  • The branch carries 80+ commits; the TODO.md under tasks/browser-table-view/ records every decision made along the way (what was tried, what was deferred, what was shelved). Worth a skim if any choice is unclear.
  • 0041_browser_table_view.py replaces develop's 0041_cleanup_phantom_comic_as_folder_rows.py — both branches added a migration numbered 0041 to a shared 0040 parent. The phantom-Comic cleanup ships as the final RunPython operation in the consolidated migration. Anyone who already ran develop's 0041 standalone should be on a fresh branch / DB; the migration is idempotent on a clean cleanup.
  • Two stored-settings classes of breakage (no coercion shim by design): old order_by values issue_number / issue_suffix 400 until re-saved; old table_columns entries issue_count 400 until re-saved.
  • Per-top-group view mode (each top-group remembers its own card / table choice) was considered and shelved pending feedback on the initial release. The single global toggle may turn out to be fine.
  • Performance is verified fine on a ~18k-comic library; profile pass on ≥50k libraries is the only open item, and the cancel button is the user-side escape hatch for stalls.

🤖 Generated with Claude Code

ajslater and others added 30 commits May 5, 2026 22:11
Captures the architecture for a tabular alternative to the cover-
emphasized browser view: per-top-group column visibility, header-click
sorting reusing the existing order_by/order_reverse settings, expanded
order_by enum, single-endpoint API for v1 with column-narrowed API
flagged as Phase 7 optimization debt, and a column registry shared
between backend and frontend that future-proofs inline cell editing
and M2M sorting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rather than accept the optimization debt of returning all column data
on every table-view request, narrow annotations and M2M aggregates by
a columns= query param up-front. The frontend reads the persisted
table_columns setting and translates it to the param on each request.
Cover view stays unchanged. Eliminates what was tracked as Phase 7
optimization debt and avoids paying GROUP_CONCAT cost on every page
load for users who never enable any tag-style columns.

Also updates Phase 1 plan with the corresponding step changes (Step 5
adds columns= validation; Step 6 wires JsonGroupArray narrowed by the
requested set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 1 of Phase 1: introduce three new fields on SettingsBrowser to
persist the user's table-view preferences:

- view_mode (cover|table, default cover) - chooses which presentation
  the frontend renders.
- table_columns (JSON map keyed by top-group) - the visible column
  set per top-group; falls back to defaults from the column registry
  when missing or empty.
- table_cover_size (xs|sm, default sm) - thumbnail size for the cover
  column when displayed in table mode.

DIRECT_KEYS gains all three so they round-trip through
browser_instance_to_dict and get_browser_default_params automatically.
Schema-only migration; existing rows backfill with model defaults.
BROWSER_DEFAULTS updated for frontend choice consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 2 of Phase 1: BrowserSettingsSerializer accepts the three new
fields added in Step 1 (view_mode, table_columns, table_cover_size).
Persistence and reset already work since DIRECT_KEYS drives them
automatically.

table_columns is declared as DictField[ListField[CharField]] with a
custom validator that rejects keys outside BROWSER_TOP_GROUP_CHOICES.
Column-key validation against the registry waits for Step 3.

Tests cover the model-level wiring, the serializer accept/reject
matrix, and the HTTP PATCH/GET/DELETE round-trip including the
camelCase response shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 3 of Phase 1: define the column registry that drives every
table-view feature downstream (column picker, columns= validator,
row serializer field generation, header-click sort key resolution).

The registry data lives in codex.choices.browser so the existing JSON
build pipeline (choices_to_json.py, browser-table-columns.json,
browser-table-default-columns.json) can hand it to the frontend
without circular imports. Helper functions
(default_columns_for / is_sortable / is_m2m / sort_key_for) live in
codex.views.browser.columns and import the data downward.

Tightens validate_table_columns to reject unknown column keys per
top-group. Some sortable columns reference order_by enum keys that
aren't yet defined (Step 4 expands the enum); the runtime stays
consistent because the order_by ChoiceField rejects them until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 4 of Phase 1: enlarge BROWSER_ORDER_BY_CHOICES from 13 to 33
entries so the table view can offer header-click sorting on every
column the user is likely to expose.

The new keys split into two categories:

- direct Comic fields (year, month, day, issue_number, issue_suffix,
  file_type, monochrome, reading_direction, metadata_mtime). These
  use the existing pipeline unchanged: Comic queries order on the
  indexed field directly; group queries aggregate via Min through
  the relation prefix.

- FK-name keys (series_name, volume_name, publisher_name,
  imprint_name, country, language, original_format, tagger,
  scan_info, main_character, main_team). These need ORM-path
  translation since Comic carries FK objects, not denormalized name
  fields. The new `comic_order_path` helper maps each enum key to
  its `__name` path; both `_add_comic_order_by` and
  `annotate_order_value` route through it.

Adjusts the column registry so `issue_count` is sort_key=None (the
sortable promise needs a Count annotation that is out of scope for
Step 4). The new `test_registry_sortable_columns_resolve_to_enum`
catches future drift between the registry and the enum.

Tests cover serializer acceptance for all 20 new keys plus
representative end-to-end ordering paths (year, issue_number,
issue_number reverse, series_name FK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 5 of Phase 1: the browser endpoint now branches on the user's
view_mode setting and produces either the existing card grid (cover
mode) or a table-row response (table mode).

Three pieces:

- BrowserPageInputSerializer extends BrowserSettingsSerializer with a
  comma-separated columns= query param. Unknown column keys cause a
  400. The validated tuple flows through BrowserParamsView into
  self.params["columns"].

- BrowserTablePageSerializer mirrors BrowserPageSerializer's metadata
  fields but emits a single rows list (groups + books concatenated)
  projected through whichever columns the request asked for. The
  projection lives in _row_repr; getattr is intentionally tolerant
  so columns whose value source isn't annotated yet just render as
  None.

- BrowserView now exposes input_serializer_class explicitly and
  branches in get(): table mode picks the column set
  (explicit columns= -> stored table_columns[top_group] -> registry
  defaults) and instantiates the table page serializer.

For Step 5 the columns wired through end-to-end are the ones already
annotated by the cover pipeline (name, issue_number, year,
page_count, size, series_name, volume_name, publisher_name,
volume_number_to). Other registry columns (imprint_name, country,
language, FK chains, M2M aggregates) come back as null in v1; the
annotation extensions land in Step 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 6 of Phase 1: when a table-view request asks for an M2M column
(genres, tags, characters, etc.), the queryset gains a JsonGroupArray
aggregate over the related ``__name`` path; otherwise no M2M
machinery runs.

Implementation:

- ``_M2M_COLUMN_PATHS`` in views.browser.columns maps the simple M2M
  keys to their ORM paths (characters, genres, locations,
  series_groups, stories, tags, teams, universes). The complex ones
  (credits with person/role, identifiers with source/key, story_arcs
  with through-model) stay null in v1; their aggregation needs custom
  ORM construction that's deferred.

- ``m2m_annotations_for`` returns a dict of prefixed aliases. The
  prefix (``_table_m2m_``) avoids the conflict between an annotation
  named ``genres`` and ``Comic.genres``-the-M2M-field-attribute.

- ``BrowserView._get_group_and_books`` calls
  ``m2m_annotations_for`` only when ``view_mode == "table"`` and only
  applies the annotations to Comic book querysets; group rows skip
  M2M entirely.

- ``_row_repr`` recognizes M2M column keys and reads the value from
  the prefixed alias. JsonGroupArray's JSONField output_field
  ensures the value comes through as a Python list.

Tests: requesting columns=cover,name,genres on a comic with two
genres returns those names; requesting only scalar columns produces
a row dict with no M2M keys at all (verifying the skip path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the scaffold-level UI work for the table-view alternative:
view-mode toggle, BrowserTable component using v-table (not
v-data-table-server, so URL pagination stays intact), header-click
sorting wired to the existing orderBy/orderReverse settings, and
BrowserMain branching on viewMode.

Out of scope for Phase 2: column picker dialog (Phase 4),
bulk-selection in table (Phase 3), mobile fallback (Phase 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end UI for the table-view alternative. A user can now toggle
into table mode from the top toolbar, see comics rendered as rows
using the registry's per-top-group default columns, click headers
to sort, and toggle back to cover view.

What's wired:

- BrowserViewModeToggle next to the settings drawer button. The
  order-by select and reverse button hide when viewMode === "table"
  since header clicks own that affordance there.
- BrowserTable + BrowserTableCell render the rows array returned by
  the Phase 1 backend. v-table (not v-data-table-server) so the
  existing BrowserNavToolbar keeps owning URL pagination. Cell types
  recognized in v1: cover (img sized by tableCoverSize), m2m list
  (comma-joined), bool (Yes/No), fallback string.
- BrowserMain branches on viewMode to render BrowserTable or the
  card grid.
- The Pinia store gains viewMode/tableColumns/tableCoverSize in
  state.settings (round-tripped through loadSettings), and
  loadBrowserPage injects ``columns=`` from the registered defaults
  (or persisted overrides) before each table-mode request.

Side fix: choices_to_json.py's _make_json_serializable now
propagates jsonize_keys through nested mappings. Without this, the
new browser-table-columns.json would have its column keys
camelCased (issue_number -> issueNumber) and break the columns=
contract with the backend. search-map.json is the only existing
file affected by the propagation; its nested keys are all
single-word lowercase, so the output is identical.

Out of scope (later phases per 00-plan.md):
- bulk-selection wiring inside the table (Phase 3)
- column-picker dialog (Phase 4)
- mobile auto-fallback (Phase 5)
- per-type cell components (Phase 6 polish)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GET requests carry the user's settings as URL-encoded JSON values in
query params. The browser endpoint receives ``?tableColumns=%7B%7D``
and the DictField on the serializer sees the literal string ``"{}"``,
returning 400 ``Expected a dictionary of items but got type "str".``.

Two fixes in JSONFieldSerializer:

- ``_parse_json_field`` now passes through non-string values. The
  previous unquote_plus(dict) call raised AttributeError, was
  swallowed by ``except Exception``, and silently returned None. PATCH
  bodies arrive as already-parsed dicts; the only path that needs
  decoding is GET query strings. As a side effect this also fixes a
  latent bug: PATCH /r/settings with a non-empty ``filters`` dict was
  returning 400 ``This field may not be null.`` because filters went
  through the same null-on-error path.

- ``_is_json_field`` normalizes the incoming key (camelCase -> snake)
  before checking ``JSON_FIELDS``. The check ran before the
  underscoreize step, so single-word names like "filters"/"show"
  matched in both PATCH and GET paths but multi-word names like
  "table_columns" only matched on PATCH (CamelCaseJSONParser already
  underscored the body) — the GET path saw "tableColumns" and skipped
  parsing.

Adds ``table_columns`` to ``BrowserSettingsSerializer.JSON_FIELDS``.

Tests: GET ?tableColumns={...} round-trips, and PATCH filters with a
non-empty payload persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three fixes from frontend testing:

- Row navigation routed to the wrong destination because _row_repr
  only emitted ``pk`` and the requested columns. The frontend's
  onRowClick fell back to ``c`` + ``[row.pk]`` when ``group``/``ids``
  were missing, sending Publisher rows to /c/<publisher_pk>/1
  instead of /p/<publisher_pk>/1. ``group`` and ``ids`` are now
  always emitted alongside ``pk`` regardless of the requested column
  set; they're routing metadata, not user-selectable columns.

- Table content scrolled past the toolbar instead of inside its own
  container. ``height: 100%`` doesn't compose with the flex-column
  parent (#browsePane) — switched to ``flex: 1; min-height: 0;``
  with ``overflow-y: auto`` on the same div so v-table's fixed-
  header sticky thead pins to this scroller.

- Removed ``xs`` (~16px) from BROWSER_TABLE_COVER_SIZE_CHOICES.
  Only ``sm`` (~32px) remains in v1 per user feedback. The field
  is kept for future md/lg expansion. Migration 0042 captures the
  choice update and folds in the 20 new order_by keys from Step 4
  that should have been migrated alongside the model field default;
  Django emits no SQL for choice-only AlterField ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cky header

The previous v-table+fixed-header combination put overflow:auto on
v-table's own wrapper, which competes with our outer scroll container
and lets the whole table scroll past the toolbar instead of scrolling
internally with a pinned thead. Switch to a plain <table>:

- #browserTable stays the scroll container (flex:1 + min-height:0 +
  overflow-y:auto inside the flex-column #browsePane).
- thead th has position:sticky; top:0 in scoped css, which binds to
  the nearest scrolling ancestor — #browserTable — so the headers
  stay visible regardless of viewport height.

Replicates the few v-table styles we actually need (surface
background, padding, border-bottom, hover affordance) without the
internal-wrapper compositing surprises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a leftmost checkbox column to BrowserTable wired to the existing
useBrowserSelectManyStore. The same toolbar that appears in cover
view (BrowserToolbarSelectMany) shows up automatically when the
table activates the store.

- Per-row checkbox toggles via the store's toggleItem; an empty <td>
  catches the click so the row's onClick doesn't double-fire.
- Header checkbox is indeterminate when some rows on the page are
  selected, full when all, empty when none. Click flips between
  selectAll and clearSelection.
- Row click respects selectManyActive: if active, toggle selection;
  otherwise navigate (matches the cover overlay's contract).
- Selected rows get a subtle primary-color tint with a slightly
  stronger hover state.

Side fix: useBrowserSelectManyStore.selectAll() previously read from
page.groups + page.books only, so select-all-on-page was a no-op in
table mode. It now prefers page.rows when present and falls back to
the cover-view lists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Modal dialog for choosing which columns the table view shows. The
existing cover-view PATCH path was already enough to set
``tableColumns`` directly, but until now there was no UI for it.

Pieces:

- BrowserTableColumnPicker: v-dialog with category-grouped checkbox
  lists (Identity / Publishing / Counts / Files / Dates / Tagging /
  Reader / Tags & People). Categories are hardcoded in the picker;
  the registry doesn't carry a category field and the categories
  haven't yet earned the right to be backend-configured. Any
  registry column not categorized is surfaced under "Other" so
  future additions don't silently disappear.
- BrowserColumnsButton: top-toolbar icon button that mounts the
  picker. Visible only in table mode. The dialog snapshots the
  current selection on open, edits a draft, and commits via
  setSettings on save; cancel just closes.
- Reset-to-defaults wires to the registry's per-top-group default
  list (browser-table-default-columns.json).

The picker preserves the registry's natural column order rather
than the user's click order so the table layout stays visually
stable across edits. Drag-to-reorder is deferred — when the
registry order doesn't suit a user, we'll surface that in feedback
and act on it then.

Per-top-group: the dialog edits the column set for whatever
top_group is currently active. Switching top-groups closes the
dialog (via the regular page reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…error

Two cover-column polish items:

- Width: the cover column was inheriting the regular cell padding,
  so the cover thumbnail sat in a wide column. ``width: 1%`` +
  ``white-space: nowrap`` is the standard idiom for "as narrow as
  content allows" in a fluid-width table; combined with reduced
  right padding the column collapses to the thumbnail's footprint.
  Wired via a new cellClasses helper on the table so the same
  class drops on both <th> and <td>.

- Broken-image fallback: the <img> tag previously rendered the
  browser's broken-image icon when the cover URL 404'd (covers
  haven't been generated yet, etc.). Added an @error handler that
  flips an imgErrored flag and falls back to getPlaceholderSrc()
  for the row's group — the same svg used for rows that don't
  carry coverPk at all. The flag resets when the row identity
  changes so a transient error doesn't stick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The user's persisted view_mode is now honored only on viewports
≥ 960px (Vuetify smAndDown). On narrower screens BrowserMain renders
the cover grid even when viewMode === "table" — a multi-column
table is unusable on phone-sized devices. The setting itself
isn't touched; rotate / widen and the table reappears.

For this to work without a second round-trip on viewport changes,
the response shape changed: BrowserPageSerializer now always emits
``groups``/``books`` and additionally emits a ``rows`` list when
the request carries ``columns=`` (i.e. when in table mode). Mobile
fallback reads ``books``; table mode reads ``rows``. Drops
BrowserTablePageSerializer in favor of the unified shape.

Cost trade-off: table-mode responses now also serialize the cards
(adds ~14 fields per row). Acceptable in v1 — pagination caps the
row count, the duplication is a flat overhead, and the alternative
(re-requesting on viewport rotation) is a worse user experience.
If profiling later shows this matters, a ``shape=`` query param
can opt in/out.

Tests updated to expect both shapes in table-mode responses, and
to expect ``rows: []`` in cover-mode responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fills in the remaining columns that were returning null in v1:

- FK-name columns: imprint_name, country, language, original_format,
  tagger, scan_info, age_rating, main_character, main_team. Each is
  annotated via F() with a prefixed alias (``_table_fk_<col>``) so
  it doesn't clash with the matching Comic FK attribute. ``_row_repr``
  reads via the alias.
- Complex M2M aggregations: credits (-> person.name), identifiers
  (-> key), story_arcs (-> story_arc_numbers.story_arc.name).
  All routed through the existing JsonGroupArray helper.

Both annotation sets are narrowed by the request's columns= so a
user with only scalar columns visible pays no JOIN cost for any of
this. Tests verify country/language and story_arcs aggregate
correctly end-to-end; the 12 cards remaining (imprint, tagger,
scan_info, age_rating, etc.) follow the identical code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Sticky leading columns: the checkbox cell pins to left:0 during
  horizontal scroll; the cover column pins immediately after it.
  Both get an explicit surface background so scrolled content stays
  hidden underneath, and a higher z-index in thead so the corner
  cell sits above both the tbody and the rest of the thead. Selected
  rows override the sticky bg with surface-light so the selection
  tint is visible across the full row.

- Tooltips on truncated cells: list cells (genres / tags / etc.)
  and generic text cells get ``title`` attributes with the full
  string. The browser shows native tooltips when text is truncated;
  no JS needed. Generic text cells gain the same max-width +
  ellipsis treatment that list cells already had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generic stringification of raw values produced an ugly readout —
``size`` rendered as bytes (``1234567``), date-only columns as ISO
strings (``2024-05-23``), and timestamp columns with milliseconds.
Adds three formatters dispatched by column key:

- ``size`` -> ``prettyBytes`` (already used by metadata-header /
  order-by-caption for the same job).
- ``date`` -> ``DATE_FORMAT`` from datetime.js (sv-SE YYYY-MM-DD).
- ``created_at`` / ``updated_at`` / ``metadata_mtime`` /
  ``bookmark_updated_at`` -> ``getDateTime`` (date + 24h time).

24-hour time is hardcoded in the table cell for now; respecting
the user's ``twentyFourHourTime`` setting needs a store-aware
formatter and is a small follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The view-mode-aware toolbar bits (column picker button, hidden
order-by/reverse) were keying on the persisted ``viewMode`` setting
alone. On mobile with table mode set, the cover grid actually
renders (Phase 5 fallback), so the picker button was offering
table-only controls that didn't apply, and the cover view's
order-by + reverse buttons were missing.

Both toggles now use the same effective check:
``viewMode === "table" && !$vuetify.display.smAndDown``. When the
viewport is too narrow to render the table, the toolbar reverts to
its cover-view shape regardless of the persisted setting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the phase checklist in 00-plan.md to reflect everything
landed in the autonomous overnight run. Adds HANDOFF.md as a quick
read-this-when-you-wake-up summary covering branch state,
verification spots, decisions made autonomously, and open questions
for the user to weigh in on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cell formatter was hardcoding ``true`` (24h) for datetime columns
(created_at / updated_at / metadata_mtime / bookmark_updated_at),
ignoring the user's persisted ``twentyFourHourTime`` toggle. Pulls
the flag from the browser store and threads it into ``getDateTime``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The credits column resolves through Credit -> CreditPerson.name. The
existing M2M test covered story_arcs (through-model) and the simple
N-to-N keys; this adds the missing case so the full
``_M2M_COLUMN_PATHS`` map is exercised by integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three quick-action buttons in the picker's action row:

- ``All`` — selects every registry column.
- ``None`` — clears the selection.
- ``Defaults`` (renamed from "Reset to Defaults" for compactness) —
  restores the registry's per-top-group default set.

Useful for users who want to A/B different column profiles without
clicking through 40+ checkboxes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The OrderByCaption component dispatches by orderBy to format the
``order_value`` overlay shown on each browser card. With Step 4's
enum expansion ``metadata_mtime`` was a valid sort key but fell
through the type dispatch and rendered as a raw ISO timestamp. Now
treated like the other datetime sorts (bookmark_updated_at /
created_at / updated_at) so the caption formats with the locale's
date + time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tifiers

Three follow-ups from the morning review:

1. Cover view's order-by dropdown filters down to a curated subset.
   The Step 4 enum expansion went from 13 to 33 keys; most of the
   table-only additions (reading_direction, monochrome, country,
   language, FK-name keys) don't drive a useful cover-grid sort.
   ``BROWSER_COVER_ORDER_BY_KEYS`` (in choices/browser.py) lists the
   13 keys that survived; the frontend's ``orderByChoices`` getter
   filters the dropdown to this set. The full enum is still valid
   for ``order_by`` server-side (table-view header clicks use it).

2. ``credits`` aggregation surfaces the role: ``Person Name (Role)``
   when role is set, just ``Person Name`` when null. Implemented as
   a ``Case`` expression passed into ``JsonGroupArray``.

3. ``identifiers`` aggregation renders ``[source:]type:key``: a full
   composite when source is present, type:key when source is null.
   Same Case-expression pattern.

Tests cover both with-source and without-source paths for credits
and identifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The picker now leads with an "Order" section: an ordered list of the
currently-selected columns, each draggable. Drag a row to a new
position — the drop target shows a 2px primary-color line above or
below depending on which half of the row the cursor is in, and on
release the column moves to that position. Each row also has a close
button so the user can remove a column without scrolling down to
the category list.

Below the order section is the unchanged category-grouped checkbox
list. Toggling a checkbox on appends the column to the end of the
order list (drag from there to the desired position); toggling off
removes it. Save / Cancel / All / None / Defaults work the same.

Native HTML5 drag-and-drop — no library dependency. Firefox needs
``setData`` set during dragstart for the drag to actually start;
that's wired. ``effectAllowed = "move"`` and ``dropEffect = "move"``
give the user a move-cursor instead of the default copy-cursor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The morning review answered all five outstanding questions; four
landed as implementations (cover-friendly dropdown subset, composite
identifiers, role-suffixed credits, drag-to-reorder columns) and one
(per-top-group view mode) is intentionally deferred until you've
used the table view for real sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Identifiers can carry source=null, id_type="", key="" — placeholder
rows that rendered as just ":" in the aggregated list and polluted
the cell. Same shape for credits where a CreditPerson exists with an
empty name.

Adds per-column ``filter=`` Q expressions to ``m2m_annotations_for``;
identifiers require id_type or key non-empty, credits require
person.name non-empty. SQL filters before aggregation, so the JSON
list never sees the empty composite.

Tests cover both the placeholder-identifier and unnamed-credit paths
returning ``[]`` (or absent) from the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ajslater and others added 25 commits May 7, 2026 11:39
Backend table-view queries can take a long time on large libraries
(intersection sort + multi-column extras + M2M aggregates). Without
an escape hatch the user is stuck watching the indeterminate
spinner with no way back to the previous results.

After the spinner has been showing for 10 seconds, a "Cancel"
button surfaces below it. Clicking it aborts the in-flight
``getBrowserPage`` request via the abortable API and flips
``browserPageLoaded`` back to true so the prior page's cards /
table rows reappear from the unchanged ``state.page``.

- New ``abortKey(key)`` in ``api/v3/abortable.js`` looks up the
  controller for a single-flight key, calls its ``abort()``, and
  removes it from the registry. Returns whether anything was
  actually pending so the caller can decide what to do.
- New ``cancelBrowserPage()`` action in the browser store calls
  ``abortKey("browser:loadBrowserPage")`` and toggles
  ``browserPageLoaded = true`` only when an abort actually fired.
  ``loadBrowserPage``'s catch already swallows ``AbortError`` so
  state.page stays untouched.
- ``BrowserMain`` watches ``showPlaceHolder``; when it goes true
  it arms a 10s timeout that flips ``cancelButtonReady`` true.
  When the placeholder hides (success / error / cancel) the
  watcher clears the timer and resets the flag so the next
  request gets its own 10s grace. The button is gated on a real
  prior page existing (``state.page.mtime > 0``) so a brand-new
  session — where the spinner is just the initial libraries-fetch
  and there's nothing to fall back to — doesn't get a useless
  cancel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng stale state

Restoring the prior page on cancel left the route in the same heavy
settings configuration that just timed out, so the next load would
hit the same wall. Cancel now follows the "Clear All Filters" path:
DELETE /r/settings to reset server-side, ``_validateAndSaveSettings``
to pull the response back into local state, then a fresh
``loadBrowserPage`` against the defaulted settings.

The user lands on a route's baseline view (cover mode, sort_name,
no extras, no filters) — guaranteed to render — instead of being
stuck on a configuration the backend can't serve in a reasonable
time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous behavior reset every setting via DELETE /r/settings, which
threw away the user's filters, search, view-mode and any custom
columns for other top-groups along with whatever made the query
expensive. Now the cancel scopes the reset:

- ``orderBy`` → ``sort_name`` and ``orderReverse`` → false (drops
  aggregate / intersection sorts).
- ``orderExtraKeys`` → ``[]`` (drops the multi-sort chain).
- ``tableColumns[<topGroup>]`` cleared so the resolver falls back
  to the registry defaults for *this* top-group only — other
  top-groups' custom column sets stay intact.

Filters, search, view-mode, top-group and route stay on the user's
current values. Implementation routes through the existing
``setSettings`` action, which PATCHes the partial-reset payload,
re-loads, and persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Title attribute branches on the active view: ``"Reset order"`` in
cover view (where the cancel only flips ``order_by`` back to
``sort_name`` plus drops any extras), ``"Reset order and columns"``
in table view (the column set for the current top-group also rolls
back to the registry defaults). Honors the mobile auto-fallback so
the message tracks what the user actually sees, not what's
persisted.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cancel now only resets the sort — columns, filters, view-mode and
the rest of the user's settings stay put. The reset target is
chosen per top-group + view-mode:

- Folder, StoryArc, Publisher, Comic, and any cover-view request:
  ``orderBy = "sort_name"``, no extras. (Comic's ``sort_name``
  already expands to the full
  ``publisher_sort_name → … → sort_name`` chain via
  ``_add_comic_order_by``, so a single key suffices.)
- Imprint table mode: primary ``publisher_name`` + extra
  ``sort_name``.
- Series table mode: primary ``publisher_name`` + extras
  ``imprint_name`` then ``sort_name``.
- Volume table mode: primary ``publisher_name`` + extras
  ``imprint_name`` then ``series_name`` then ``sort_name``
  (Volume's ``sort_name`` expands to ``name, number_to`` in
  ``add_order_by``).

Lookup table lives in the store; ``cancelBrowserPage`` reads it
and routes through the existing ``setSettings`` action which
PATCHes the new order, re-loads, and persists. Tooltip simplifies
to ``"Reset order"`` since the column reset is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reorder ``BROWSER_TABLE_DEFAULT_COLUMNS`` so each top-group's
default visible column set begins with (after ``cover``) the
columns referenced by the default sort. Display layout now
visually tracks the per-top-group hierarchical sort:

- Imprint: cover, publisher_name, name, child_count.
- Series: cover, publisher_name, imprint_name, name, year, child_count.
- Volume: cover, publisher_name, imprint_name, series_name, name,
  year, child_count.
- Publisher / Folder / StoryArc / Comic: unchanged — their default
  sort is ``sort_name`` (single key) which already maps to
  ``name``, leading the existing column tuples.

The frontend's ``_DEFAULT_TABLE_ORDER`` lookup and this
``BROWSER_TABLE_DEFAULT_COLUMNS`` map are intentionally paired —
both define the per-top-group baseline that ``Cancel`` and the
initial-render path rely on. Comments updated to flag the pairing.

Three new tests pin the imprint / series / volume layouts.
``make build-choices`` regenerates the frontend JSON so
``_resolveTableColumns`` picks up the new ordering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related bugs:

A. Series rows (top_group=s, also Volume / Folder / StoryArc)
   left ``publisher_name`` and ``series_name`` / ``volume_name``
   blank in table view even when every child comic shared the
   publisher / series / volume. ``annotate_group_names`` only
   wires ``publisher_name`` for Comic and Imprint querysets and
   ``series_name`` for Comic and Volume; the missing rows fell
   through to ``getattr(instance, col, None)`` → ``None``.
   ``_SCALAR_FIELD_PATHS`` excluded these keys on the comment
   that ``annotate_group_names`` already covered them — but it
   didn't, so the scalar-intersection path skipped them too.

   Adds ``publisher_name``, ``imprint_name``, ``series_name``,
   ``volume_name`` to ``_SCALAR_FIELD_PATHS`` so
   ``compute_group_intersections`` picks them up uniformly across
   non-Comic group querysets — including Folder / StoryArc, which
   don't have direct FKs and so couldn't have been covered by
   ``annotate_group_names``. Comic queries short-circuit out of
   the intersection computer entirely; their rows continue to use
   ``annotate_group_names``'s direct annotations. Stale comment
   on the dict updated.

B. Default columns for ``c`` (comic top-group) didn't include
   ``publisher_name`` even though Comic's default sort is
   ``sort_name`` which expands to the full
   ``publisher_sort_name → … → sort_name`` ladder. The user
   pointed this out — the column set should mirror the sort
   ladder. Reordered to ``(cover, publisher_name, imprint_name,
   series_name, volume_name, issue, name, year, page_count,
   size)``.

Tests:
- New ``test_table_view_group_publisher_name_renders_for_series_rows``
  pins the Series-row fix end-to-end.
- ``test_comic_defaults_match_plan`` updated for the new column
  ladder.

172 backend tests pass; lint clean. ``make build-choices``
regenerated the frontend column-defaults JSON.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The wrapping ``<div>`` in BrowserColumnsButton broke the toolbar's
flex-item layout so the inner v-btn fell back to its default "icon"
oval highlight instead of the rectangular fill every other toolbar
button shows on active / hover. Drop the wrapper using Vue 3 multi-
root templates: the ``ToolbarButton`` becomes a direct child of the
parent ``<v-toolbar-items>`` flex container and the dialog (a
teleported portal) sits alongside without affecting layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Merge brings in #734 (poller log dedup), #735 (snapshot inode
collision fix + cleanup migration), and #736 (stale stat refresh).

Both branches added a migration numbered 0041 to a shared 0040
parent — develop's data migration to clean up phantom Comic-as-folder
rows, and browser-table-view's schema migration for the table view.
Squash all six >=0041 migrations down to a single
``0041_browser_table_view.py`` so Django sees a linear history.

Schema operations preserve the final state from 0044/0045:
``view_mode``, ``table_columns``, ``table_cover_size`` (single ``sm``
choice — ``xs`` was dropped), ``order_extra_keys``, and the
post-table-view ``order_by`` choice list. The phantom Comic
``RunPython`` cleanup runs as the last operation.

Verified: ``makemigrations --dry-run --check`` reports no drift, and
the full test suite (185 tests) passes.

Conflict resolution:
- frontend/{package.json,bun.lock}: pin @types/node to ^25.6.2
  (HEAD's higher version) over develop's ^25.6.1.
- migrations: drop develop's 0041_cleanup_phantom_comic_as_folder_rows
  and browser-table-view's 0042–0045; consolidate into a rewritten
  0041_browser_table_view.

Re-formatted five files surfaced by the merged ruff config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ate-sum bug

Two performance / correctness wins on
``compute_group_intersections``.

(1) Per-page scalar query count drops from ``1 + N`` (where N is
    the number of visible scalar / FK-name / cumulative columns)
    to a constant ``2``: the upstream ``annotate_group_names``
    pass and the new batched aggregate query. Each requested
    scalar contributes three ``Count`` / ``Min`` annotations to
    the same ``Comic.objects.filter().values(rel).annotate(**)``
    call rather than firing its own round-trip. Cumulative
    columns (``page_count``, ``size``) ride the same query via
    ``Sum``. M2M columns still issue their own queries — each
    traverses a distinct through-table whose JOIN would
    cross-multiply if combined.

    The intersection rule (``every child shares a single non-NULL
    value``) reduces to ``COUNT(DISTINCT field) == 1 AND
    COUNT(field) == comic_count``, with the value being ``MIN(field)``
    (which equals MAX when distinct == 1). The previous per-column
    helper materialized a row per ``(group, value)`` pair and
    walked the list in Python; the batched form computes the same
    answer in SQL with ``2N + 1`` aggregate columns.

(2) ``_compute_scalar_sum`` had ``Sum(path, distinct=True)``,
    which dedupes equal sibling values. Two child comics with
    ``page_count=22`` summed to 22 instead of 44. The batched
    helper drops ``distinct=True``; the new
    ``test_table_view_group_cumulative_page_count_with_duplicates``
    pins the fix.

Also added
``test_table_view_group_intersections_batch_into_one_scalar_query``
which captures the per-page query log and asserts that the
``codex_comic`` GROUP BY count stays bounded — adding visible
scalar columns must not scale the round-trip count.

187 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Inspired by ``MetadataQueryIntersectionsView._query_m2m_intersections``
which already unions per-through-table sub-queries into one SQL
statement. The table view does the same job for many groups at
once, so each sub-query gains a ``group_pk`` annotation and
aggregates per ``(group_pk, value)`` instead of HAVING-filtering
to a fixed count.

Result: per-page round-trip count for simple-M2M columns drops
from N (one per visible column: characters, genres, locations,
series_groups, stories, tags, teams) to 1. Composite-M2M columns
(``credits``, ``identifiers``, ``universes``, ``story_arcs``)
keep their per-column helpers — their display strings need
bespoke SQL the simple-name pattern can't express.

Each batched sub-query also drops Comic as the anchor. The
metadata view's pattern queries the through table directly and
JOINs out to the target's ``__name`` and (via ``comic__``) to
the parent group's FK column, so the row volume the SQL planner
sees per sub-query is bounded by the through-table's portion of
the visible page rather than every comic-anchored permutation.

Adds ``test_table_view_simple_m2m_intersections_share_one_union_query``
which captures the per-page query log and asserts the
through-table reference count stays bounded — adding visible
simple-M2M columns must not multiply round-trips.

188 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A user who hides imprints / volumes from breadcrumb navigation
(``show.i`` / ``show.v`` False — the model defaults, in fact)
almost certainly doesn't want the matching columns leading their
default table-view column set either. Drop them from the
defaults when the flag's off.

- New ``default_columns_filtered(top_group, show)`` in
  ``codex/views/browser/columns.py`` filters
  ``BROWSER_TABLE_DEFAULT_COLUMNS`` against a ``_SHOW_GATED_COLUMNS``
  map (``imprint_name`` → ``i``, ``volume_name`` → ``v``).
  ``BrowserView._resolve_table_columns`` now calls it with
  ``self.params["show"]``.
- New ``filterShowGatedDefaults(cols, show)`` exported from the
  browser store. ``_resolveTableColumns`` and the column picker's
  ``_snapshot`` / ``resetToDefaults`` route default-column
  computation through it so the picker's ``Defaults`` button and
  the table's first-time render both pick up the filtered set.
- ``default_columns_for`` stays unchanged so existing tests /
  call sites that need the canonical tuple keep working.

Imprint and Volume columns remain available via the column picker
— users who want them just enable the matching show flag (or
add the column manually). Series, Volume and Comic top-groups
benefit; Publisher / Folder / StoryArc defaults already lacked
both columns so the filter is a no-op there.

193 backend tests pass; lint + prettier clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rom name JOIN

Restructure the per-outer-row correlated subquery so the
aggregation phase only touches the through-table — the target's
``codex_<m2m>`` table JOIN happens after aggregation, against the
small set of target_ids that survived ``HAVING``.

Three-stage shape:

1. ``isect_ids`` — through-table-only aggregation. ``th.comic_id IN
   (SELECT c.id FROM codex_comic c {extra_join} WHERE {where})``
   uses the through table's comic_id index; ``GROUP BY
   th.<target>_id`` + ``HAVING COUNT(DISTINCT th.comic_id) ==
   <comic_count>`` rides the (target_id, comic_id) composite index.
   No JOIN to codex_comic, no JOIN to the target name table.
2. ``named`` — JOIN codex_<m2m> for ``name`` against the few
   surviving target_ids only. NULL / empty names drop here.
3. Outer ``GROUP_CONCAT(name, X'1F' ORDER BY name)``.

Pre-fix: aggregation happened over the through × target × comic
cross-product because both JOINs were inside the inner SELECT
that GROUP BY ran on. Post-fix: the cross-product is bounded by
the outer group's child-comic count × distinct target_ids, with
the target name JOIN reduced to the small post-HAVING set.

Same answer in every existing test (193 backend tests still
pass). Composite-display M2M sort (``credits`` /
``identifiers`` / ``universes`` / ``story_arcs``) keeps its
shape — each computes ``display_name`` from multiple target
columns so the JOIN can't move past aggregation without a
per-builder rewrite. Worth doing later if the composite paths
turn into a hot spot, but they aren't usually the visible
columns the user sorts by.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…columns

Users on huge libraries can now see at a glance which columns
carry weight. The picker renders a small clock icon next to
non-cheap columns; the indicator color and hover tooltip
distinguish the two cost tiers:

- ``medium`` (faded, "Loads moderately on large libraries.")
  — the simple-M2M columns whose display batches into the
  per-page UNION-ALL helper but whose sort still runs a
  per-outer-row correlated subquery.
- ``high`` (warning color, "Loads slowly on large libraries —
  disable if you don't need it.") — composite-M2M columns
  (``credits`` / ``identifiers`` / ``universes`` /
  ``story_arcs``) whose display issues per-column queries with
  bespoke composite display strings, and whose sort runs a
  per-outer-row correlated subquery against those expressions.

``BROWSER_TABLE_COLUMN_COSTS`` lives next to the column registry
in ``codex/choices/browser.py``; only non-low entries are listed.
build-choices exports it to
``frontend/src/choices/browser-table-column-costs.json``. The
picker reads it via the new ``_columnRow`` helper and renders an
``mdiClockOutline`` icon scoped by per-tier CSS class.

The rating reflects the worse of display vs. sort cost so the
indicator is a single, non-misleading hint. Low-cost columns
(everything not listed) remain unannotated — the cheap path is
the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ng helper

Two new unit-test files for the table-view feature:

- ``browser-table-cell.test.js`` — 14 tests covering compound issue
  cells (split number/suffix), list / M2M cells, bool Yes/No cells,
  type-aware text formatters (size, date, enum expansion), null /
  undefined handling, and snake_case → camelCase row attribute
  lookup. Mounts the component with a stubbed Pinia store so the
  ``twentyFourHourTime`` setting is available without a full
  application bootstrap.
- ``browser-store-helpers.test.js`` — 12 pure-function tests for
  ``filterShowGatedDefaults`` (the helper that mirrors the backend's
  ``default_columns_filtered`` logic for ``imprint_name`` /
  ``volume_name``). Covers happy path, both-flags-off, defensive
  fallbacks for null cols / null show, immutability of input array,
  and the same-reference contract when nothing is filtered.
Brings in v1.11.5 release work: Vite HMR / service-worker / dev-server
fixes (#738, #739, #742), the OCR-overlay revert (#741), comicbox
3.0.1, and assorted dep bumps (vuetify ^4.0.7, sass-loader ^16.0.8,
@types/node ^25.6.2).

No migration conflicts: the prior eda6461 merge already consolidated
develop's ``0041_cleanup_phantom_comic_as_folder_rows`` into this
branch's ``0041_browser_table_view``. The phantom-Comic cleanup ships
as the final ``RunPython`` operation in that combined migration; the
current merge brings no further migration churn from develop, so the
0041 file is unchanged. ``makemigrations --check --dry-run`` reports
no drift.

Verified: 193 backend tests + 27 frontend tests pass, ruff clean.
@ajslater
ajslater merged commit ecd3e3d into develop May 9, 2026
3 checks passed
@ajslater
ajslater deleted the browser-table-view branch May 11, 2026 00:10
ajslater added a commit that referenced this pull request May 11, 2026
…n groups (#762)

The browser table-view PR (#745) routed group-row scalar / M2M sort
through ``scalar_intersection_sort_expr`` / ``m2m_intersection_sort_expr``
for every view mode. Intersection returns NULL when a group's children
disagree on the sorted field, which is correct for table view (sort
matches the intersection cell display) but blanks the order_value
caption beneath cover-mode cards for any group with mixed children.
The user reported this for Publish Date across publishers, series,
and folders.

Gate the intersection branch on ``view_mode == "table"`` so cover
mode falls back to the pre-table-view aggregate (Min/Max/Avg/Sum)
for scalars and ``sort_name`` for M2M.

Regression test reproduces the user's scenario: a "Mixed" series
with children at year=2018 and year=2024 returned orderValue=null
before; now returns "2018" (Min aggregate).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant