Skip to content

Continue Reading Phase 1: Merge 'Active Loans' with 'Loan History' on 'My Books' page - #13281

Merged
mekarpeles merged 12 commits into
internetarchive:masterfrom
Sadashii:13272/feature/merge-loans-and-history-carousel
Aug 23, 2026
Merged

Continue Reading Phase 1: Merge 'Active Loans' with 'Loan History' on 'My Books' page#13281
mekarpeles merged 12 commits into
internetarchive:masterfrom
Sadashii:13272/feature/merge-loans-and-history-carousel

Conversation

@Sadashii

@Sadashii Sadashii commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Closes #13272

Feature: Merges active user loans with past loan history into a unified carousel on the My Books landing page (/account/books/).

Technical

  • Core Lending Service (openlibrary/core/lending.py): Extracted get_loan_history_data(username, page) out of account.py into core/lending.py so it can be shared between the loan history page and the My Books landing page controller.
  • My Books Controller (openlibrary/plugins/upstream/mybooks.py): Implemented a two-pass deduplicating merge algorithm for docs["loans"]:
    • Active Loans Pass: Fetches current active loans via get_loans_of_user(), following up to 5 redirect hops to key each item by its canonical work key.
    • Loan History Pass: Fetches page 1 of loan history via get_loan_history_data(), filtering out IA-only items without Open Library editions.
    • Deduplication & Sorting: Active loans take precedence; historical items are added if their work key is not already present. Active loans sort first (is_active=True), followed by timestamp descending (loaned_at / last_loan_date), capped at 18 books total.
  • Templates & Styling (custom_carousel.html, custom_carousel_card.html, custom-carousel-card.css, carousel.css):
    • Forwarded book.loan into the carousel card context (book2['loan']).
    • Active loans display overlay badges (expiry date, waitlist count, ADE return notice) and an inline return button form for active bookreader items.
    • Cover Wrapper & Expired Badge: Wrapped carousel items in .book-cover-wrapper and configured CSS absolute positioning to scale overlays exactly to the cover image width. Added grey "Loan Expired" overlay badges for past loans.
    • Unified Page / Redirects: Appended paginated history list section to /account/loans template. Modified /account/loan-history GET handler to issue a 301 Permanent Redirect to /account/loans?page={page}.
    • Heading & Cleanups: Added "Active Loans (count)" section heading in loans.html. Omitted book counts on the "Loans & History" carousel dashboard title. Safely resolved missing total_results lookups in mybooks.html to prevent AttributeErrors.
    • Templetor Compile Fix: Refactored return form inline $if compile syntax error to standard python ternary expressions.

Analytics Tracked

We have integrated key user interaction points with analytics tracking using data-ol-link-track attributes:

  1. Carousel Action Clicks:
    • BookCarousel|BorrowClick|loans — Tracks when a user borrows a book directly from the dashboard carousel.
    • BookCarousel|ReadClick|loans — Tracks when a user opens the reader for a borrowed book from the carousel.
    • BookCarousel|JoinWaitlistClick|loans — Tracks joining a waitlist from the carousel.
    • BookCarousel|ReturnClick|loans — Tracks returns initiated via the hover cover returns in the carousel.
  2. Carousel Cover Clicks:
    • BookCarousel|CoverClick|loans — Tracks navigation clicks on book covers inside the carousel.
  3. Unified Page Return Clicks:
    • LoansAndHistory|ReturnClick — Tracks return form submissions inside the active loans table on the /account/loans page.
  4. Details / Search Results Return Clicks:
    • LoanStatus|ReturnClick — Tracks return form submissions inside the generic LoanStatus.html macro (e.g. details pages).
  5. Sidebar Navigation Clicks:
    • MyBooksSidebar|Loans — Tracks sidebar clicks on the newly unified "Loans & History" link.

Testing

  • Ran pytest integration tests:
    uv run --with-requirements requirements_test.txt pytest openlibrary/tests/fastapi/test_unified_loans_carousel.py openlibrary/tests/fastapi/test_account_loans.py
  • Tested manually on dev server (http://localhost:8080/account/books/):
    1. Logged in as @openlibrary.
    2. Verified active loans display with expiry badges and return buttons.
    3. Verified returned/past loans seamlessly fill the remaining slots in the carousel sorted by date.

Stakeholders

@Sadashii @mekarpeles

Copilot AI lite review requested due to automatic review settings August 7, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR implements Phase 1 of a unified “My Loans” carousel on /account/books/ by merging active loans with recent loan history, plus UI affordances (badges + return button) for active loans.

Changes:

  • Extracts get_loan_history_data(username, page) into openlibrary/core/lending.py and updates legacy account endpoints to use it.
  • Updates openlibrary/plugins/upstream/mybooks.py to merge active loans + page 1 of loan history with deduping and sorting, capped to 18 items.
  • Extends the custom carousel card template and styling to render active-loan overlays and an inline return action; adds FastAPI tests for the merge behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
static/css/legacy.css Adds import for the new custom carousel card stylesheet.
static/css/components/custom-carousel-card.css Introduces overlay badge + action-row styling for loan UI.
openlibrary/tests/fastapi/test_unified_loans_carousel.py Adds tests asserting merge/dedup/sort behavior and active-loan precedence.
openlibrary/tests/fastapi/test_account_loans.py Adds a regression test for null/empty loan-history API responses (currently written against the old helper location/signature).
openlibrary/templates/books/custom_carousel.html Forwards book.loan into the card context for rendering.
openlibrary/templates/books/custom_carousel_card.html Renders loan overlay badges and an inline return form/button for active bookreader loans.
openlibrary/plugins/upstream/mybooks.py Implements merged active+history loans list creation and sorting for the My Books page.
openlibrary/plugins/upstream/account.py Switches to the new core.lending.get_loan_history_data(username, page) helper.
openlibrary/core/lending.py Adds the extracted get_loan_history_data helper with null-safe response handling.
Suppressed comments (1)

openlibrary/templates/books/custom_carousel_card.html:55

  • These inline styles use hard-coded hex colors; prefer existing CSS token variables (var(--red), var(--link-blue), etc.) to stay consistent with Stylelint/project conventions and avoid embedding palette values in templates.
                <span style="color: #ff8888; font-weight: bold;">$_("Not yet downloaded.")</span><br/>
                <a href="$loan.get('loan_link')" style="color: #88c0ff; text-decoration: underline;">$_("Download Now")</a>

Comment thread openlibrary/tests/fastapi/test_account_loans.py
Comment thread openlibrary/plugins/upstream/mybooks.py Outdated
Comment thread openlibrary/templates/books/custom_carousel_card.html Outdated
Comment thread openlibrary/templates/books/custom_carousel_card.html Outdated
Comment thread openlibrary/templates/books/custom_carousel_card.html Outdated
@Sadashii
Sadashii force-pushed the 13272/feature/merge-loans-and-history-carousel branch 4 times, most recently from 8ee10c4 to a24b732 Compare August 7, 2026 18:58
@Sadashii Sadashii changed the title my books loans carousel: merge active loans with loan history Continue Reading Phase 1: Merge 'Active Loans' with 'Loan History' on 'My Books' page Aug 7, 2026
@Sadashii
Sadashii force-pushed the 13272/feature/merge-loans-and-history-carousel branch 3 times, most recently from 82dcd8f to 318679b Compare August 10, 2026 17:28
@mekarpeles mekarpeles self-assigned this Aug 10, 2026
@mekarpeles

Copy link
Copy Markdown
Member
Screenshot 2026-08-11 at 5 22 13 AM

@Sadashii have you run this locally and visually QA'd? As is implemented, it's a bit confusing because it still says, "My Loans (20)"; we may want to switch to Continue Reading.

It would be useful to have screenshots attached to the PR from testing and also any questions you have or feedback you would like.

In particular, we'll want to get feedback from @lokesh and the community on the rest of the UI (e.g. the return button iconography, color, etc)
Screenshot 2026-08-11 at 5 26 12 AM

@mekarpeles

Copy link
Copy Markdown
Member
Screenshot 2026-08-11 at 5 29 03 AM

i18n'd text overflows (e.g. german)

@Sadashii

Copy link
Copy Markdown
Collaborator Author
  • Remove the total count number

@accesslint accesslint 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.

Found 4 issues across 2 rules (2 WCAG, 2 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/templates/account/loans.html
Comment thread openlibrary/templates/account/loans.html

@accesslint accesslint 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.

Found 4 issues across 2 rules (2 WCAG, 2 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/templates/account/loans.html
Comment thread openlibrary/templates/account/loans.html
@github-actions github-actions Bot added the Needs: Response Issues which require feedback from lead label Aug 12, 2026

@accesslint accesslint 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.

Found 4 issues across 2 rules (2 WCAG, 2 Best Practice).

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/templates/account/loans.html
Comment thread openlibrary/templates/account/loans.html

@accesslint accesslint 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.

Found 1 issue across 1 rule.

Reviewed by AccessLint, which checks every pull request for accessibility issues.

Comment thread openlibrary/templates/account/loans.html
Sadashii and others added 7 commits August 23, 2026 10:10
…rousel

- Move get_loan_history_data from account.py to openlibrary/core/lending.py and decouple from MyBooksTemplate.

- Handle missing or null history/items in IA loan API response gracefully.

- Merge active loans and history in mybooks.py, deduplicating by work key and prioritizing active loans over returned history.

- Update custom_carousel.html and custom_carousel_card.html to render loan overlay badges (expiry, waitlist count, download links) and return buttons.

- Add CSS rules for loan overlay badges, action rows, and return button styling.

- Add unit tests for unified loans carousel ranking and null safety in loan history fetching.
- Rename My Loans carousel and sidebar to Loans & History

- Redirect old /account/loan-history page to /account/loans via 301 Permanent Redirect

- Embed paginated Loan History list under the Active Loans section

- Add Active Loans (count) section header to match waitlist headers

- Display cover overlay Loan Expired badges on dashboard carousel for expired loans

- Support cover wrapper scaling and clean badge border positioning in CSS

- Safely fetch total_results in compact and mobile carousels to prevent AttributeErrors
- Track specific carousel card action clicks separately (e.g. BookCarousel|BorrowClick|loans, BookCarousel|ReadClick|loans)

- Support dynamic {action} placeholder replacement inside LoanStatus analytics_override

- Track unified page active loans table return button clicks as LoansAndHistory|ReturnClick

- Track book details / search results return button clicks as LoanStatus|ReturnClick
@mekarpeles
mekarpeles force-pushed the 13272/feature/merge-loans-and-history-carousel branch from 4707d60 to de55b43 Compare August 23, 2026 16:39
ReturnForm's new `analytics_track` parameter built the whole attribute as
one pre-formatted string spliced into the tag:

    $('data-ol-link-track="' + analytics_track + '"' if analytics_track else '')

`$(...)` HTML-escapes, so the quotes rendered as `&quot;` and the attribute
came out unquoted. A browser parses the value as `"LoansAndHistory|ReturnClick"`
-- literal quote characters included -- and ol.analytics.js:65 splits it on
`|` into category `"LoansAndHistory` and action `ReturnClick"`, corrupting
both events this branch adds (LoansAndHistory|ReturnClick on the loans table,
LoanStatus|ReturnClick on book details and search results).

This is the same anti-pattern removed from four templates in 4285eb8
(issue internetarchive#13186); the escaped quotes are inert text rather than real delimiters,
so a value containing a space can also inject a new attribute. Use the fix
established there instead: an inline `$if` emitting the attribute with a
static, template-source quote the value cannot break out of.

Adds regression coverage to test_link_track_attribute_escaping.py, exercising
the real macro rather than a copy of its markup. Verified the injection test
fails against the previous version and passes against this one.
This branch folds the loan-history list into /account/loans and reduces
account_loan_history.GET to a 301, removing the only
render["account/loan_history"] call. The template itself was left on disk
with nothing referencing it.

Master has since added openlibrary/tests/test_unused_templates.py, which
fails on exactly this:

    1 templates/macros are not referenced in any Python, Templetor, Jinja,
    or JS source:
      templetor template: openlibrary/templates/account/loan_history.html

Its markup is superseded by the new Loan History section in
account/loans.html, which renders the same OlPaginationArrows / IABook /
SearchResultsWork structure. The dropped include_ratings/StarRatings branch
was dead too -- the old call site never passed ratings.

An entry in DB_USED_EXCLUSIONS would be wrong here; the template is not
reachable from database content, it is simply dead.
…logged in

render_template() guarded the merged loans carousel with `if mb.me:` -- which
only asks whether *someone* is signed in -- and then fetched history for
`mb.username`, which is the profile in the URL, not the session:

    if mb.me:
        myloans = get_loans_of_user(mb.me.key)      # viewer's own loans
        ...
        history_data = get_loan_history_data(mb.username, page=1)   # URL-supplied

get_loan_history_data() resolves S3 credentials for whatever username it is
handed (get_s3_keys() prefers the requester's own `s3` cookie, but falls back
to the store entry for the account it was passed -- the path taken by sessions
predating that cookie). So any signed-in visitor to /people/<someone>/books
could drive a borrow-history lookup against that patron's account.

MyBooksTemplate already computes the real ownership flag, `mb.is_my_page`;
this uses it. Nothing was rendered to the visitor either way -- mybooks.html
gates both carousels behind `$if owners_page:` -- but that guard lives in the
view layer, so the data layer was one template change away from leaking. It
also removes an external IA round-trip from every profile page view.

Scope: only the history fetch this branch introduced is gated. The pre-existing
`get_loans_of_user(mb.me.key)` call is left alone.
@openlibrary-bot

Copy link
Copy Markdown
Collaborator

PR #13281 — Rebase + verification (Ada, on behalf of Mek)

@Sadashii — this branch was 72 commits behind master and CONFLICTING, so with Mek's authorization I rebased it onto current master and pushed the result, then pushed three fixes on top. Details below so nothing here is a surprise.

The rebase

All 7 of your commits are preserved: same author, same author dates, same messages, in the same order. Exactly one file conflicted — openlibrary/templates/account/mybooks.html — where master's unified icon system (#12955) had rewritten the chevron <img> inside the same "Loan History" sidebar <li> your commit deletes. Resolution was the deletion, i.e. your change, applied to master's version of that block. The only line-level difference across the whole rebase is that the removed line is now master's $:macros.icon("chevron-right", ...) spelling instead of the old <img ...>.

I checked this by merging master into your original branch independently and comparing: both routes produce an identical tree. Your pre-rebase head (4707d60) is unchanged in history terms and remains recoverable.

Fixes pushed on top

89ebd40ReturnForm.html analytics attribute was HTML-escaped. The new analytics_track parameter built the whole attribute as one string:

$('data-ol-link-track="' + analytics_track + '"' if analytics_track else '')

$(...) escapes in Templetor, so the quotes rendered as &quot; and the attribute came out unquoted. A browser parses the value as "LoansAndHistory|ReturnClick" — literal quote characters included — and ol.analytics.js:65 splits it on | into category "LoansAndHistory and action ReturnClick". That silently corrupted both events this branch adds (LoansAndHistory|ReturnClick on the loans table, LoanStatus|ReturnClick on book details and search results), so the tracking commit wasn't landing usable data.

This is the same pattern removed from four templates in 4285eb85 (#13186) — worth knowing about generally, since the escaped quotes are inert text rather than real delimiters, so a value containing a space can also inject a new attribute. Switched to the idiom established there (an inline $if with a static, template-source quote) and added regression coverage to test_link_track_attribute_escaping.py that exercises the real macro. Confirmed the new test fails against the previous version and passes against the fix.

fe25aaeaccount/loan_history.html was orphaned. Folding the list into /account/loans removed the only render["account/loan_history"] call, but the template stayed on disk. Master has since added openlibrary/tests/test_unused_templates.py, which fails on it — so this was going to block CI regardless of the rebase. Deleted it; its markup is superseded by the new Loan History section in loans.html, and the include_ratings/StarRatings branch was already dead (the old call site never passed ratings). messages.pot is regenerated accordingly — no strings added or removed, only source references moved.

597217dloan-history fetch is now gated on ownership. render_template() guarded with if mb.me:, which only asks whether someone is signed in, then fetched history for mb.username — the profile in the URL, not the session. Since get_loan_history_data() resolves S3 credentials for whichever username it is handed, a signed-in visitor to /people/<someone-else>/books triggered a borrow-history lookup against that account. Nothing was shown to the visitor — mybooks.html gates both carousels behind $if owners_page: — but that guard is in the view layer, so the data layer was one template change away from a problem. MyBooksTemplate already exposes the right flag (mb.is_my_page), so this uses it. It also removes an external archive.org round-trip from every profile page view. Only the fetch this branch introduced is gated; the pre-existing get_loans_of_user(mb.me.key) call is untouched.

Verification

Docker, on the rebased branch: 5745 passed, 18 skipped. The one remaining failure, test_home.py::TestHomeTemplates::test_home_template, reproduces on clean origin/master — pre-existing, unrelated to this PR. test_unused_templates.py passes (it errors in a git-worktree checkout for an unrelated tooling reason; verified with the real gitdir mounted). Your own 14 tests all pass, and the null-safety cases for the IA history payload are a genuinely good addition. pre-commit clean including mypy. Logged in locally and confirmed /account/loans and the My Books page both render 200 with no errors, all three sections present, sidebar and breadcrumb consolidated to a single "Loans & History", and no stale /account/loan-history links.

Not fixed — worth your call

  • Three <h1>s on /account/loans. "Active Loan(s) (N)", "Books You're Waiting For", and "Loan History" are all <h1 class="details-title">; only the middle one pre-existed. The <section> wrappers in 4707d60 don't resolve it — the HTML5 outline algorithm was never implemented by browsers or assistive tech, so all three still announce as top-level headings. <h2> for the section headings would fix it (WCAG 1.3.1). Related: the new return <button> correctly has both title and aria-label, but its inline <svg> could use aria-hidden="true", and <span style="opacity: 0.9;"> on "Return via ADE" is worth a contrast check.
  • get_loan_history_data() on the My Books landing page is a synchronous archive.org call on a page that previously made none, wrapped in a bare except Exception: with no logging — so an IA outage degrades the carousel silently. Worth logging, and possibly caching.
  • total_results for the loans carousel changed meaning: it was len(myloans), now len(merged_books) (active loans plus up to 25 history items) while only 18 render ([:18]). Currently invisible because mybooks.html suppresses the count for key == "loans", but the value no longer matches what's displayed.
  • Minor: RESULTS_PER_PAGE at account.py:65 is now dead (logic moved to lending.py), and for _ in range(5) in mybooks.py shadows the module-level gettext _ for that function's scope — harmless today, but a TypeError waiting for whoever adds a _() call there.

Overall

Nice piece of work — the merge logic, the active-over-returned ranking, and the redirect handling all hold up under test. The two blockers are fixed and pushed; the rest above is advisory and yours to take or leave. Human review and merge remain with Mek.

Reported from testing.openlibrary.org:

    TypeError: unsupported operand type(s) for |: 'NoneType' and 'dict'
      openlibrary/core/lending.py in s3_loan_api      data = s3_keys | kwargs
      openlibrary/core/lending.py in get_loan_history_data
      openlibrary/plugins/upstream/account.py in GET  (account_loans)

get_s3_keys() is annotated `dict | None` and genuinely returns None when the
patron has no `s3` session cookie and no `s3_keys` in the account store.
get_loan_history_data() passed that straight into s3_loan_api(), which does
`s3_keys | kwargs`.

This became a page-level failure with this branch. Before it, /account/loans
never called get_loan_history_data() -- only /account/loan-history did. Folding
history into the loans page put an unguarded IA call in front of the
active-loans table, so a patron with no S3 keys now gets a 500 on a page that
previously rendered fine, and loses the loan table too, which has nothing to do
with IA history. The My Books carousel survives only because its call sits in a
bare `except Exception:` -- it just silently shows no history.

Degrade to an empty history instead, and log a warning so this is diagnosable
rather than invisible. Covered by tests: the guard short-circuits before
s3_loan_api, real keys still reach it (so this can't be "fixed" by disabling
history for everyone), and the TypeError at the s3_loan_api boundary is pinned
so the reason for the guard doesn't get lost.
…g it

The My Books carousel wraps its loan-history fetch in a bare
`except Exception:  # noqa: BLE001` that discards the error entirely. Keeping
the page alive when IA is unreachable is right -- the active loans and every
other shelf should still render -- but discarding the reason is not: a missing
history section becomes indistinguishable from a patron who simply has no
borrow history, with nothing in the logs to tell them apart.

That is exactly how the TypeError fixed in the previous commit stayed hidden.
On testing, /account/loans surfaced it as a 500 while My Books rendered a
clean 200 with the history silently absent and not one log line to explain it.

Keep the catch, log the exception.
@mekarpeles
mekarpeles merged commit f60b655 into internetarchive:master Aug 23, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs: Response Issues which require feedback from lead

Projects

None yet

Development

Successfully merging this pull request may close these issues.

"Continue Reading" Phase 1: Merge Active Loans & Loan History Carousel

4 participants