Add browser table view - #745
Merged
Merged
Conversation
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>
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.
4 tasks
6 tasks
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
browser-table-cell.vueand thefilterShowGatedDefaultshelper.What changed
Backend
SettingsBrowsergainsview_mode(cover | table),table_columns(per-top-group dict of ordered keys),table_cover_size(singlesmchoice in v1), andorder_extra_keys(JSON list of multi-column-sort tiebreakers). Round-tripped throughDIRECT_KEYSso saved-views clones include them automatically.0041_browser_table_view.pyis the consolidated v1 schema: adds the four new fields, refreshesorder_bychoices to the post-table-view set (dropsissue_number/issue_suffixfor a singleissuekey, adds ~30 keys for FK names + M2M sort), and ships develop's phantom Comic-as-folder cleanup as the finalRunPython. Stored settings carrying retired keys 400 until re-saved (no coercion shim).codex/choices/browser.py):BROWSER_TABLE_COLUMNS,BROWSER_TABLE_DEFAULT_COLUMNS(per top-group),BROWSER_TABLE_COLUMN_COSTS(medium / high tiers). Exported as JSON viamake build-choiceswith snake_case keys preserved (the keys are protocol identifiers in thecolumns=query param).codex/serializers/browser/page.py): unifiedBrowserPageSerializeralways emitsgroups/booksand additionally emitsrowswhencolumns=is present. Mobile auto-fallback (smAndDown) reuses the same response without re-fetching.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 theComic.foldersancestor 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 viaUNION ALL.codex/views/browser/annotate/order.py,order_by.py): single virtualissueorder_by entry expands to[issue_number, issue_suffix]ORDER BY, multi-column sort appends extras between primary'sorder_fields_headand thepktiebreaker, 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_numberneeds context,search_scoreneeds an FTS subquery) — listed inBROWSER_EXTRA_SORT_UNSUPPORTED_KEYSand the frontend mirrors it.Frontend
browser-table.vue,browser-table-cell.vue,browser-table-column-picker.vue,view-mode-toggle.vue,columns-button.vue.<table>instead of<v-table>— Vuetify's internal scroll wrapper competed with the page scroll container.--v-theme-surface-light.-webkit-line-clamp) withoverflow-wrap: break-wordfor unbreakable tokens; native:titletooltip surfaces full value on clamped cells. Issue / cover / bool cells stay nowrap.{number, suffix}and the cell renders two flex halves so digits align at the cell midpoint.:titletooltip.smAndDown(~960px); even withviewMode=tablenarrow viewports get the cover grid. Toolbar adjusts accordingly.imprint_name/volume_nameonly lead the per-top-group default tuples whenshow.i/show.vare on. Backend mirrors viadefault_columns_filtered; the two stay in sync via thefilterShowGatedDefaultshelper.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
test_browser_columns_registry.py,test_browser_ordering.py,test_browser_settings_table.py,test_browser_table_response.py.browser-table-cell.test.js(14 tests) andbrowser-store-helpers.test.js(12 tests).Test plan
make testpasses (193 backend pytest, 27 frontend vitest)make lintcleanmakemigrations --check --dry-runreports no driftAll/None/Defaultsshortcuts in the pickershow.i/show.voff); Defaults shortcut omits those columnssmAndDown; mobile auto-fallback returns the cover gridReviewer notes
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.pyreplaces develop's0041_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 finalRunPythonoperation 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.order_byvaluesissue_number/issue_suffix400 until re-saved; oldtable_columnsentriesissue_count400 until re-saved.🤖 Generated with Claude Code