Skip to content

fix: catalogue "on loan" count includes books with no copies - #303

Merged
fabiodalez-dev merged 9 commits into
mainfrom
fix/catalog-borrowed-count-empty-copies
Jul 29, 2026
Merged

fix: catalogue "on loan" count includes books with no copies#303
fabiodalez-dev merged 9 commits into
mainfrom
fix/catalog-borrowed-count-empty-copies

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Problem

On the catalogue availability filter, "On loan" showed 7 on one library while only 2 books were actually lent. The filter derives "On loan" from copie_disponibili <= 0 — but a book with copie_totali = 0 (a record with no copies registered) also has copie_disponibili = 0, so those empty records were counted and filtered as "on loan" despite nothing being borrowed. In that catalogue 5 of the 7 had zero copies.

Fix

  • "On loan" (both the count and the filter's WHERE condition) now requires copie_disponibili <= 0 AND copie_totali > 0 — every copy is out, as opposed to there being no copies.
  • "All books" is now a real COUNT(DISTINCT l.id) instead of available + borrowed. A no-copies book is neither available nor on loan, so the sum silently under-counted the catalogue by exactly those records; counting directly keeps the "All" badge correct.

"Available" (copie_disponibili > 0) is unchanged. Empty-record books now appear only under "All books", not under either sub-filter.

Test

tests/catalog-borrowed-count-empty-copies.spec.js seeds one available, one truly on-loan (copie_totali=1, copie_disponibili=0) and two no-copies (copie_totali=0) books, then checks the filter badges by delta: "On loan" rises by 1 (not 3) and "All books" by 4.

Full-tree PHPStan level 5 clean.

Summary by CodeRabbit

  • Nuove funzionalità

    • Introdotta la categoria/filtro “Prenotati” nel catalogo con relativo conteggio e badge dedicato.
  • Correzioni

    • Migliorata la distinzione tra libri disponibili, in prestito, prenotati e non disponibili (inclusi i casi con 0 copie).
    • Aggiornati i conteggi dei filtri “Disponibili”, “In prestito”, “Prenotati” e “Tutti”, includendo correttamente i titoli senza copie.
  • Test

    • Aggiunti test end-to-end per la separazione “Reserved” vs “On loan” e per i libri con zero copie.
    • Aggiornati/aggiunti test unit sulla logica di statistiche delle lingue e sulle migrazioni.

The availability filter derived "On loan" from copie_disponibili <= 0. A book
with copie_totali = 0 (a record with no copies registered) also has
copie_disponibili = 0, so it was counted — and filtered — as "on loan" even
though nothing is borrowed. On one library this showed 7 "on loan" when only 2
books were actually lent (the other 5 had zero copies).

- "On loan" (count + filter condition) now requires copie_totali > 0: every copy
  is out, as opposed to there being no copies.
- "All books" is now a real COUNT(DISTINCT l.id) instead of available + borrowed
  — a no-copies book is neither available nor on loan, so the sum under-counted
  the catalogue by exactly those records.

tests/catalog-borrowed-count-empty-copies.spec.js seeds one available, one truly
on-loan and two no-copies books and checks the badges by delta: "On loan" rises
by 1 (not 3), "All books" by 4.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fabiodalez-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 2f1baf2f-c456-41a4-ac51-cc4ce0b47dd2

📥 Commits

Reviewing files that changed from the base of the PR and between 21ad825 and 3bfeede.

📒 Files selected for processing (23)
  • README.md
  • app/Controllers/Admin/LanguagesController.php
  • app/Controllers/FrontendController.php
  • app/Controllers/ScrapeController.php
  • app/Models/Language.php
  • app/Support/NotificationService.php
  • app/Support/SettingsMailTemplates.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • app/Views/settings/index.php
  • locale/da_DK.json
  • locale/de_DE.json
  • locale/en_US.json
  • locale/fr_FR.json
  • locale/it_IT.json
  • storage/plugins/open-library/OpenLibraryPlugin.php
  • storage/plugins/open-library/README.md
  • storage/plugins/open-library/plugin.json
  • tests/catalog-reserved-category.spec.js
  • tests/full-test.spec.js
  • tests/language-derived-stats.unit.php
  • tests/plugin-goodreads-cover.unit.php
  • version.json
📝 Walkthrough

Walkthrough

Il catalogo separa prenotati, prestati e libri non disponibili nei filtri, nei conteggi e nei badge. Il modello delle lingue ricalcola le statistiche dai file JSON usando la locale italiana come denominatore comune, con test dedicati.

Changes

Disponibilità e stati del catalogo

Layer / File(s) Summary
Filtri e statistiche di disponibilità
app/Controllers/FrontendController.php
I filtri e i conteggi distinguono prenotato e prestato tramite l.stato; il totale viene calcolato direttamente sui libri filtrati.
Filtro e badge degli stati
app/Views/frontend/catalog.php, app/Views/frontend/catalog-grid.php, locale/*.json
La UI aggiunge il filtro e il contatore dei prenotati e mostra un badge dedicato per i libri non disponibili, con nuove traduzioni.
Verifica dei conteggi e degli stati
tests/catalog-*.spec.js
I test seedano libri nei diversi stati e verificano contatori, filtri, badge e record senza copie.

Statistiche derivate delle lingue

Layer / File(s) Summary
Calcolo delle statistiche linguistiche
app/Models/Language.php
Le statistiche vengono ricalcolate dai JSON usando it_IT come denominatore e contando solo traduzioni non vuote entro il limite sorgente.
Integrazione amministrativa e test
app/Controllers/Admin/LanguagesController.php, tests/language-derived-stats.unit.php, tests/migration-0.7.4*.unit.php
La vista amministrativa usa i valori derivati; i test verificano caching, limiti, file mancanti e valori storici delle migrazioni.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 60.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo descrive correttamente il bug principale corretto dal PR: il conteggio “on loan” includeva libri senza copie.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/catalog-borrowed-count-empty-copies

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Controllers/FrontendController.php`:
- Around line 1185-1200: Update the availability badge text in the catalog view
to clarify that its total includes all catalogue records, including books
without copies, rather than describing it only as “Disponibili e in prestito.”
Keep the availability_stats calculation in the FrontendController unchanged.

In `@tests/catalog-borrowed-count-empty-copies.spec.js`:
- Around line 8-9: Correggi il commento descrittivo in
tests/catalog-borrowed-count-empty-copies.spec.js sostituendo l’incremento di
“All” da 3 a 4, mantenendo invariati il test e l’aspettativa esistente.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5640b6af-0a72-4e96-9358-8c867efde398

📥 Commits

Reviewing files that changed from the base of the PR and between 96f2745 and 4cc075d.

📒 Files selected for processing (2)
  • app/Controllers/FrontendController.php
  • tests/catalog-borrowed-count-empty-copies.spec.js

Comment thread app/Controllers/FrontendController.php Outdated
Comment thread tests/catalog-borrowed-count-empty-copies.spec.js Outdated
…4, not 3)

CodeRabbit: four records are seeded (available + on-loan + two no-copies), so
"All books" rises by 4 — the assertion was already correct, only the comment
said 3.
…from source

The availability filter derived "on loan" from copie_disponibili <= 0, which also
matched reserved books (held by a pending request or slot reservation) and records
with no copies. The filter and counts now mirror the recomputed libri.stato so the
filter, the card badge and the book page agree by construction:

- Available → copie_disponibili > 0
- Reserved  → l.stato = 'prenotato'  (new facet + count + card badge)
- On loan   → l.stato = 'prestato'   (a copy actually checked out)

The catalogue card gains a distinct "Not available" badge for books whose copies
are all out of circulation (l.stato = 'non_disponibile'), instead of mislabelling
them "on loan".

i18n completion stats are now derived live from the Italian source
(Language::getAllWithDerivedStats): every __() key IS the Italian string, so the
source key count is the canonical denominator for every locale. total_keys can no
longer drift and needs no migration when keys are added — the historical da_DK
migration tests now assert their shipped literal instead of the live file count.

Also clarifies the "All books" facet description (was "Available and on loan").

Tests: 12 E2E (catalog-reserved-category) + 17 unit (language-derived-stats).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Controllers/FrontendController.php (1)

1166-1217: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Quattro query COUNT separate sulla stessa base — considera un'unica query con aggregazione condizionale.

queryAvailable, queryBorrowed, queryReserved e queryTotal eseguono 4 scan indipendenti (5 LEFT JOIN ciascuno) sulla stessa $availabilityBaseQuery, ad ogni caricamento della pagina catalogo/filtri. Puoi ottenere gli stessi 4 valori con un'unica query usando SUM(CASE WHEN ... THEN 1 ELSE 0 END) e COUNT(DISTINCT l.id), riducendo i round-trip DB da 4 a 1 su un percorso ad alto traffico.

♻️ Esempio di consolidamento
-    $queryAvailable = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.copie_disponibili > 0";
-    $stmt = $db->prepare($queryAvailable);
-    ...
-    $queryBorrowed = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prestato'";
-    ...
-    $queryReserved = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery . " AND l.stato = 'prenotato'";
-    ...
-    $queryTotal = "SELECT COUNT(DISTINCT l.id) as cnt " . $availabilityBaseQuery;
-    ...
+    $queryStats = "SELECT
+        COUNT(DISTINCT l.id) AS total_cnt,
+        COUNT(DISTINCT CASE WHEN l.copie_disponibili > 0 THEN l.id END) AS available_cnt,
+        COUNT(DISTINCT CASE WHEN l.stato = 'prestato' THEN l.id END) AS borrowed_cnt,
+        COUNT(DISTINCT CASE WHEN l.stato = 'prenotato' THEN l.id END) AS reserved_cnt
+        " . $availabilityBaseQuery;
+    $stmt = $db->prepare($queryStats);
+    if (!empty($paramsAvail)) {
+        $stmt->bind_param($typesAvail, ...$paramsAvail);
+    }
+    $stmt->execute();
+    $row = $stmt->get_result()->fetch_assoc();
+    $availableCount = $row['available_cnt'] ?? 0;
+    $borrowedCount = $row['borrowed_cnt'] ?? 0;
+    $reservedCount = $row['reserved_cnt'] ?? 0;
+    $totalCount = $row['total_cnt'] ?? 0;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Controllers/FrontendController.php` around lines 1166 - 1217, Consolida i
conteggi attualmente eseguiti da $queryAvailable, $queryBorrowed, $queryReserved
e $queryTotal in un’unica query aggregata basata su $availabilityBaseQuery,
usando COUNT(DISTINCT l.id) e aggregazioni condizionali per available, reserved
e borrowed. Mantieni invariati i filtri, i parametri bindati e i valori
restituiti in $options['availability_stats'], sostituendo i quattro
prepare/execute con un solo round-trip.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Models/Language.php`:
- Around line 73-74: In app/Models/Language.php lines 73-74, update the
translated-count logic in the relevant Language method to filter decoded locale
entries to canonical source keys before counting non-empty strings, while
preserving the sourceTotal cap. In tests/language-derived-stats.unit.php lines
120-124, expect zero when only orphan keys exist and add a separate assertion
covering translated canonical keys.

In `@tests/language-derived-stats.unit.php`:
- Around line 71-73: Prepare the required language data before calling
getAllWithDerivedStats(): create an isolated fixture containing at least five
locales, including it_IT, and ensure total_keys is linked to Italian for each
locale. Clean up the fixture or roll back the transaction before asserting the
returned statistics.
- Around line 120-124: Update the test around $big so orphan-only keys are
expected to produce a translated count of 0 rather than $srcCount. Add a
separate fixture using canonical keys from it_IT.json with translated values,
and assert that those keys are counted while preserving the source-total cap
behavior.

---

Outside diff comments:
In `@app/Controllers/FrontendController.php`:
- Around line 1166-1217: Consolida i conteggi attualmente eseguiti da
$queryAvailable, $queryBorrowed, $queryReserved e $queryTotal in un’unica query
aggregata basata su $availabilityBaseQuery, usando COUNT(DISTINCT l.id) e
aggregazioni condizionali per available, reserved e borrowed. Mantieni invariati
i filtri, i parametri bindati e i valori restituiti in
$options['availability_stats'], sostituendo i quattro prepare/execute con un
solo round-trip.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 772765a6-2329-4fec-9876-230d75a27571

📥 Commits

Reviewing files that changed from the base of the PR and between 4cc075d and 21ad825.

📒 Files selected for processing (15)
  • app/Controllers/Admin/LanguagesController.php
  • app/Controllers/FrontendController.php
  • app/Models/Language.php
  • app/Views/frontend/catalog-grid.php
  • app/Views/frontend/catalog.php
  • locale/da_DK.json
  • locale/de_DE.json
  • locale/en_US.json
  • locale/fr_FR.json
  • locale/it_IT.json
  • tests/catalog-borrowed-count-empty-copies.spec.js
  • tests/catalog-reserved-category.spec.js
  • tests/language-derived-stats.unit.php
  • tests/migration-0.7.41.unit.php
  • tests/migration-0.7.42.unit.php

Comment thread app/Models/Language.php Outdated
Comment thread tests/language-derived-stats.unit.php
Comment thread tests/language-derived-stats.unit.php Outdated
The loan_pickup_ready / loan_pickup_expired notifications only exposed the
pickup deadline under {{scadenza_ritiro}}. A customised template using
{{pickup_deadline}} — the natural name a user copies from the DB column —
left the placeholder unresolved. I now pass the value under both names and
document {{pickup_deadline}} in each template's placeholder list.
…eholders

The admin email-template editor listed each {{token}} as a bare technical name
with no explanation, and nothing translated per locale. I added
SettingsMailTemplates::placeholderDescriptions() — a localised, human-readable
description for all 41 placeholder tokens — and the settings view now shows it
as a tooltip on each placeholder chip. The tokens stay untranslated (they are
substitution keys); only the descriptions are localised, across it/en/de/da/fr.
…ning

Four review findings on this branch:

1. The language *edit* page rendered stored (stale) total_keys/translated_keys
   while the *list* page rendered source-derived stats — the same locale showed
   two different denominators (en_US 4653 vs 6654). Added
   Language::getByCodeWithDerivedStats() (shared helper with
   getAllWithDerivedStats) and routed edit() through it.
2. Dropped the hardcoded "6613" from a controller comment (already stale at 6654);
   the denominator is derived at runtime and must not be cited as a literal.
3. Catalogue card badge: "In prestito" now shows ONLY for stato='prestato'; every
   other not-available case (non_disponibile, or a stale stato on a zero-copy
   book) falls to "Non disponibile" instead of being mislabelled as on loan.
4. Language::translatedKeyCount validates the locale code shape
   (^[a-z]{2}_[A-Z]{2}$) before interpolating it into a filesystem path —
   defence-in-depth against traversal, since the code comes from the DB.

Verified: PHPStan clean, language-derived-stats 17/17, catalog-reserved-category
13/13 (badges unchanged for the asserted cases).
…t gaps

Finding 5 from the review — the derived-stats helpers hardcoded the locale/
directory via __DIR__, which forced the unit test to write fixtures into the
real repository tree (orphaned on a hard kill) and left the malformed-JSON
branch untested. Root-cause fix:

- Language::__construct now takes an optional $localeDir (defaults to the
  shipped locale/); the source-key cache moved from a static to a per-instance
  property so an injected dir isn't shadowed by a prior instance's cache.
- Dropped the redundant min($translated, $sourceTotal) cap: array_intersect_key
  against the source key set already bounds the count to <= source, so the cap
  was unreachable dead code — documented the invariant instead.
- The unit test now points a dedicated Language instance at a throwaway dir
  under sys_get_temp_dir(): fixtures never touch repo locale/, and it adds the
  previously-missing malformed-JSON case. 17 → 20 assertions.

Backward compatible (the second constructor arg is optional; no other caller
passes it). PHPStan clean; migration/danish/export i18n tests still green.
@fabiodalez-dev

Copy link
Copy Markdown
Owner Author

Ran a multi-lens self-review of this branch before cutting a release. Everything surfaced was P2 (no P0/P1); all fixed in a33f2b1 + 3bfeede:

  • The language edit page rendered stored (stale) completion stats while the list page rendered the source-derived ones — the same locale showed two different denominators (e.g. en_US 4653 vs 6654). Routed edit() through getByCodeWithDerivedStats(), sharing one derivation helper with the list view.
  • Catalogue card badge now shows "In prestito" only for stato='prestato'; every other not-available case (non_disponibile, or a stale stato on a zero-copy record) falls to "Non disponibile" instead of being mislabelled as on loan.
  • Language::translatedKeyCount validates the locale-code shape (^[a-z]{2}_[A-Z]{2}$) before interpolating it into a filesystem path — defence-in-depth, since the code comes from the DB.
  • Made the locale directory injectable so the unit tests stop writing fixtures into the real locale/ tree, dropped a dead min() cap, and added the previously-missing malformed-JSON case (17 → 20 assertions).
  • Dropped a hardcoded "6613" from a comment (the denominator is derived at runtime and must not be cited as a literal).

Full-test suite and PHPStan green. Reviewed alongside #302 (both touch the catalogue views) — the two are region-disjoint, no semantic conflict.

@fabiodalez-dev
fabiodalez-dev merged commit 6b10c69 into main Jul 29, 2026
12 of 13 checks passed
fabiodalez-dev added a commit that referenced this pull request Jul 29, 2026
Release candidate bundling the reviewed catalogue + scraper + i18n work:
- #303 catalogue availability facets, source-derived language stats, Open
  Library 1.0.2 native Goodreads cover scraper, #304 email fixes, review fixes.
- #298 catalogue card row alignment (+ font-swap/thrashing/mobile review fixes).
- UI wording: "series number" → "number in series" (README + en_US); the CSV
  import header mapping is intentionally left unchanged for back-compatibility.

No database migration — availability is derived from circulation state and
language stats from the shipped locale files.
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