@internetarchive/ia-book-actions 1.4.1 → 1.4.3-canary.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,16 +11,17 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
 
13
13
  steps:
14
- - uses: actions/checkout@v4
15
- - uses: actions/setup-node@v3
14
+ - uses: actions/checkout@v4.1.1
15
+ - uses: actions/setup-node@v4.0.2
16
16
  with:
17
- node-version: 16.x
17
+ node-version: 20.x
18
18
 
19
19
  - name: Install dependencies
20
20
  run: yarn install
21
21
 
22
- # - name: Run tests
23
- # run: yarn run test
22
+ - name: Run tests
23
+ run: yarn run test
24
24
 
25
- # - name: Upload reports
26
- # run: npx codecov
25
+ - uses: codecov/codecov-action@v5
26
+ with:
27
+ token: ${{ secrets.CODECOV_TOKEN }}
@@ -14,24 +14,47 @@ on:
14
14
 
15
15
  concurrency: preview-${{ github.ref }}
16
16
 
17
+ env:
18
+ PREVIEW_BRANCH: gh-pages
17
19
  jobs:
18
20
  deploy-preview:
19
- runs-on: ubuntu-20.04
21
+ runs-on: ubuntu-latest
20
22
  steps:
21
23
  - name: Checkout
22
24
  uses: actions/checkout@v4
23
-
24
- - uses: actions/setup-node@v3
25
- with:
26
- node-version: 20.x
25
+ - uses: actions/setup-node@v4
27
26
 
28
27
  - name: Install and Build
29
28
  run: |
30
29
  yarn install
31
30
  yarn prepare:ghpages
31
+
32
32
  # Reference: https://github.com/rossjrw/pr-preview-action
33
33
  - name: Deploy preview
34
34
  uses: rossjrw/pr-preview-action@v1
35
+ id: preview-step
35
36
  with:
36
37
  source-dir: ./ghpages/
37
38
  umbrella-dir: pr
39
+ preview-branch: ${{ env.PREVIEW_BRANCH }}
40
+ comment: false
41
+
42
+ - uses: marocchino/sticky-pull-request-comment@v2
43
+ if: steps.preview-step.outputs.deployment-action == 'deploy'
44
+ with:
45
+ header: pr-preview
46
+ message: |
47
+ [PR Preview Action](https://github.com/rossjrw/pr-preview-action) ${{ steps.preview-step.outputs.action-version }}
48
+ :---:
49
+ | <p></p> :rocket: View preview at <br> ${{ steps.preview-step.outputs.preview-url }} <br><br>
50
+ | <h6>Built to branch [`${{ env.PREVIEW_BRANCH }}`](${{ github.server_url }}/${{ github.repository }}/tree/${{ env.PREVIEW_BRANCH }}) at ${{ steps.preview-step.outputs.action-start-time }}. <br> Preview will be ready when the [GitHub Pages deployment](${{ github.server_url }}/${{ github.repository }}/deployments) is complete. <br><br> </h6>
51
+
52
+ - uses: marocchino/sticky-pull-request-comment@v2
53
+ if: steps.preview-step.outputs.deployment-action == 'remove'
54
+ with:
55
+ header: pr-preview
56
+ message: |
57
+ [PR Preview Action](https://github.com/rossjrw/pr-preview-action) ${{ steps.preview-step.outputs.action-version }}
58
+ :---:
59
+ Preview removed because the pull request was closed.
60
+ ${{ steps.preview-step.outputs.action-start-time }}
package/README.md CHANGED
@@ -1,12 +1,21 @@
1
- # Component for lendable and embed items
2
1
 
3
- The book action button component build on LitElement with support for custom content and light DOM elements.
2
+ ![Build Status](https://github.com/internetarchive/iaux-book-actions/actions/workflows/ci.yml/badge.svg) [![codecov](https://codecov.io/gh/internetarchive/iaux-book-actions/branch/main/graph/badge.svg?token=ZOYRJ2BV9W)](https://codecov.io/gh/internetarchive/iaux-book-actions)
3
+
4
+ # Internet Archive Book Lending Actions
5
+
6
+ This WebComponent, built using the [IA Typescript WebComponent Template](https://github.com/internetarchive/iaux-typescript-wc-template), provides support for Bookreader actions for borrowable books on Internet Archive. It extends the [Open WebComponents generator](https://open-wc.org/docs/development/generator/) with Internet Archive-specific features and developer tools.
4
7
 
5
8
  ## Installation
6
9
  ```bash
7
10
  yarn install --save @internetarchive/ia-book-actions
8
11
  ```
9
12
 
13
+ ## Local Demo with `web-dev-server`
14
+ ```bash
15
+ yarn start
16
+ ```
17
+ To run a local development server that serves the basic demo located in `index.html`
18
+
10
19
  ## Usage of Book Actions
11
20
  ```html
12
21
  <!-- index.html -->
package/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@internetarchive/ia-book-actions",
3
- "version": "1.4.1",
4
3
  "description": "Bookreader actions for borrowable book",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/internetarchive/iaux-book-actions.git"
7
+ },
5
8
  "author": "Internet Archive",
6
9
  "license": "AGPL-3.0-only",
10
+ "version": "1.4.3-canary.0",
7
11
  "main": "index.js",
8
12
  "module": "index.js",
9
13
  "publishConfig": {
@@ -15,28 +19,27 @@
15
19
  "prepare": "rimraf dist && husky install",
16
20
  "lint": "eslint --ext \"./src/*.js\",.html . --ignore-path .gitignore && prettier \"./src/*.js\" --check --ignore-path .gitignore",
17
21
  "format": "eslint --ext \"./src/*.js\",.html . --fix --ignore-path .gitignore && prettier \"./src/*.js\" --write --ignore-path .gitignore",
18
- "test": "yarn run lint && yarn test:fast",
19
- "test:fast": "web-test-runner --coverage --playwright --browsers chromium firefox webkit",
22
+ "test": "yarn run format && yarn run lint && yarn test:fast --coverage",
23
+ "test:fast": "web-test-runner",
20
24
  "test:watch": "web-test-runner --watch --playwright --browsers chromium firefox webkit",
21
25
  "deploy": "yarn run deploy:run -e $(git branch --show-current)",
22
26
  "deploy:run": "yarn run prepare:ghpages && touch ghpages/.nojekyll && yarn run deploy:gh",
23
27
  "deploy:gh": "gh-pages -t -d ghpages -m \"Build for $(git log --pretty=format:\"%h %an %ai %s\" -n1) [skip ci]\""
24
28
  },
25
29
  "dependencies": {
26
- "@internetarchive/icon-info": "1.3.5",
30
+ "@internetarchive/icon-info": "^1.4.1",
27
31
  "@internetarchive/local-cache": "^0.2.1",
28
- "@internetarchive/modal-manager": "2.0.0",
32
+ "@internetarchive/modal-manager": "^2.0.0",
29
33
  "@internetarchive/shared-resize-observer": "^0.2.0",
30
- "install-deps": "^1.1.0",
31
- "lit": "^2.8.0"
34
+ "lit": "^2.8.0 || ^3.3.2"
32
35
  },
33
36
  "devDependencies": {
34
37
  "@open-wc/eslint-config": "^4.2.0",
35
- "@open-wc/testing": "^2.0.0",
36
- "@web/dev-server": "^0.1.1",
37
- "@web/dev-server-esbuild": "^0.2.16",
38
- "@web/test-runner": "^0.11.7",
39
- "@web/test-runner-playwright": "^0.8.8",
38
+ "@open-wc/testing": "^4.0.0",
39
+ "@web/dev-server": "^0.4.6",
40
+ "@web/dev-server-esbuild": "^1.0.5",
41
+ "@web/test-runner": "^0.20.2",
42
+ "@web/test-runner-playwright": "^0.11.1",
40
43
  "eslint": "^7.32.0",
41
44
  "eslint-config-prettier": "^6.11.0",
42
45
  "eslint-plugin-import": "^2.22.1",
@@ -150,7 +150,7 @@ export default class LoanAnanlytics {
150
150
  }
151
151
 
152
152
  /**
153
- * reponsible to sent events to GA
153
+ * responsible to sent events to GA
154
154
  *
155
155
  * @param {*} eventCategory
156
156
  * @param {*} eventAction
@@ -27,7 +27,7 @@ export class LoanRenewHelper {
27
27
  };
28
28
  }
29
29
 
30
- handleLoanRenew() {
30
+ async handleLoanRenew() {
31
31
  try {
32
32
  if (this.hasPageChanged) {
33
33
  return this.pageChanged(); // user clicked on page
@@ -98,6 +98,10 @@ export default class IABookActions extends LitElement {
98
98
  */
99
99
  this.returnNow = false;
100
100
 
101
+ this.loanRenewInProgress = false;
102
+
103
+ this.warningModalOpen = false;
104
+
101
105
  /**
102
106
  * contains one hour auto-loan-renew time configuration
103
107
  * defaults to 1 hour config
@@ -120,7 +124,12 @@ export default class IABookActions extends LitElement {
120
124
  * @property {boolean} renewNow - key to determine if need to renew now
121
125
  * @property {number} secondsLeft - seconds left in active loan
122
126
  */
123
- this.loanRenewResult = { texts: '', renewNow: false, secondsLeft: 0, renewType: '' };
127
+ this.loanRenewResult = {
128
+ texts: '',
129
+ renewNow: false,
130
+ secondsLeft: 0,
131
+ renewType: '',
132
+ };
124
133
  }
125
134
 
126
135
  disconnectedCallback() {
@@ -136,7 +145,6 @@ export default class IABookActions extends LitElement {
136
145
  * @param {string} msg
137
146
  */
138
147
  sentryCaptureMsg(msg) {
139
- log(window?.Sentry);
140
148
  window?.Sentry?.captureMessage(msg);
141
149
  }
142
150
 
@@ -228,7 +236,7 @@ export default class IABookActions extends LitElement {
228
236
  'browsingExpired' in this.lendingStatus &&
229
237
  this.lendingStatus?.browsingExpired;
230
238
  if (hasExpired) {
231
- log('setupLendingToolbarActions > hasExpired --- ');
239
+ log('[IABookActions] browsing expired — showing expired state');
232
240
 
233
241
  if (!this.tokenPoller) {
234
242
  this.sentryCaptureMsg(sentryLogs.bookWasExpired);
@@ -285,7 +293,11 @@ export default class IABookActions extends LitElement {
285
293
  * dispatched this event from bookreader page changed
286
294
  */
287
295
  window.addEventListener('BookReader:userAction', () => {
288
- log('IABookActions:BookReader:userAction');
296
+ // If the loan expired while the tab stayed visible, auto-renew on page turn
297
+ if (this.lendingStatus.browsingExpired) {
298
+ this.autoRenewExpiredLoan();
299
+ }
300
+
289
301
  if (this.borrowType === 'browsed') {
290
302
  this.autoLoanRenewChecker(true);
291
303
  }
@@ -298,17 +310,17 @@ export default class IABookActions extends LitElement {
298
310
  */
299
311
  document.addEventListener('visibilitychange', async () => {
300
312
  if (!document.hidden) {
301
- log(
302
- 'visibilitychange event execute:------------------ ',
303
- new Date().getMinutes(),
304
- new Date().getSeconds(),
305
- this.borrowType
306
- );
307
-
308
- if (
309
- this.borrowType === 'browsed' &&
310
- this.lendingStatus.browsingExpired === false
311
- ) {
313
+ log('[IABookActions] visibilitychange:', this.borrowType);
314
+
315
+ // Loan already expired while tab was hidden — try to silently renew
316
+ if (this.lendingStatus.browsingExpired === true) {
317
+ this.autoRenewExpiredLoan();
318
+ return;
319
+ }
320
+
321
+ if (this.borrowType !== 'browsed') return;
322
+
323
+ if (this.lendingStatus.browsingExpired === false) {
312
324
  const loanTime = await this.localCache.get(
313
325
  `${this.identifier}-loanTime`
314
326
  );
@@ -319,7 +331,8 @@ export default class IABookActions extends LitElement {
319
331
  if (secondsLeft >= this.timerExecutionSeconds) {
320
332
  this.loanStatusCheckInterval(Number(secondsLeft));
321
333
  } else {
322
- this.browseHasExpired();
334
+ // Loan expired while user was away — try to silently renew
335
+ this.autoRenewExpiredLoan();
323
336
  this.disconnectedCallback();
324
337
  }
325
338
  }
@@ -345,6 +358,36 @@ export default class IABookActions extends LitElement {
345
358
  this.loanRenewResult = this.loanRenewHelper.result;
346
359
  }
347
360
 
361
+ /**
362
+ * Attempt to automatically renew a browse loan that has expired.
363
+ * Called on visibilitychange (user returns to tab) or on page turn when
364
+ * the loan has expired but the book may still be available.
365
+ *
366
+ * Reuses the same renew_loan path as the "Keep reading" warning modal button.
367
+ * On success: handleLoanAutoRenewed() resets the timer seamlessly.
368
+ * On error: handleLendingActionError() shows showLoanUnavailableModal().
369
+ */
370
+ autoRenewExpiredLoan() {
371
+ if (this.loanRenewInProgress) return;
372
+ this.loanRenewInProgress = true;
373
+
374
+ this.modal?.closeModal();
375
+
376
+ // Optimistically flip browsingExpired back to false so the action bar
377
+ // stays red (patronIsReadingAction) while the renew_loan request is in
378
+ // flight, instead of showing the blue "Borrow" state.
379
+ this.lendingStatus = {
380
+ ...this.lendingStatus,
381
+ browsingExpired: false,
382
+ secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
383
+ };
384
+
385
+ // Defer renewNow until after lendingStatus timer setup completes.
386
+ setTimeout(() => {
387
+ this.loanRenewResult = { texts: '', renewNow: true, renewType: 'auto' };
388
+ }, 0);
389
+ }
390
+
348
391
  /**
349
392
  * Required as sibling on page
350
393
  * @returns HTMLElement
@@ -360,29 +403,34 @@ export default class IABookActions extends LitElement {
360
403
  * Show the waring modal to ask user if they are still reading
361
404
  */
362
405
  async showWarningModal() {
363
- log('****** showWarningModal ******');
364
- // clear modal
365
- this.modal.customModalContent = nothing;
366
- this.modal?.closeModal();
367
- this.loanRenewResult = { texts: '', renewNow: false };
406
+ if (this.warningModalOpen) return;
407
+ this.warningModalOpen = true;
368
408
 
369
- // if secondsLeft < 60, consider it 1 minute
370
- let { secondsLeft } = this.loanRenewResult;
371
- if (secondsLeft === undefined) {
409
+ log('[IABookActions] showWarningModal');
410
+
411
+ // Capture texts and secondsLeft before resetting loanRenewResult
412
+ const {
413
+ texts: warningTexts,
414
+ secondsLeft: rawSecondsLeft,
415
+ } = this.loanRenewResult;
416
+ let secondsLeft = rawSecondsLeft;
417
+ if (secondsLeft === undefined || secondsLeft <= 0) {
372
418
  secondsLeft = this.lendingStatus.secondsLeftOnLoan;
373
419
  } else {
374
420
  secondsLeft = secondsLeft > 60 ? secondsLeft : 60;
375
421
  }
376
422
 
423
+ // clear modal and reset renew state
424
+ this.modal.customModalContent = nothing;
425
+ this.modal?.closeModal();
426
+ this.loanRenewResult = { texts: '', renewNow: false };
427
+
377
428
  const config = new ModalConfig({
378
429
  headline: 'Are you still reading?',
379
430
  headerColor: '#194880',
380
431
  showCloseButton: false,
381
432
  closeOnBackdropClick: false,
382
- message: this.loanRenewHelper?.getMessageTexts(
383
- this.loanRenewResult.texts,
384
- secondsLeft
385
- ),
433
+ message: this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft),
386
434
  });
387
435
 
388
436
  const customModalContent = html`<br />
@@ -479,6 +527,7 @@ export default class IABookActions extends LitElement {
479
527
 
480
528
  /**
481
529
  * Show modal when book is auto returned
530
+ * @deprecated
482
531
  */
483
532
  async showExpiredModal() {
484
533
  const config = new ModalConfig({
@@ -508,7 +557,7 @@ export default class IABookActions extends LitElement {
508
557
  * Execute when loan is expired
509
558
  */
510
559
  async browseHasExpired() {
511
- log('BrowseHasExpired ---');
560
+ log('[IABookActions] browseHasExpired');
512
561
  window?.IALendingIntervals?.clearAll();
513
562
 
514
563
  const currStatus = {
@@ -527,11 +576,38 @@ export default class IABookActions extends LitElement {
527
576
  this.loanRenewResult.texts =
528
577
  'This book has been returned due to inactivity.';
529
578
 
530
- await this.showExpiredModal();
579
+ this.modal?.closeModal();
531
580
 
532
581
  this.sentryCaptureMsg(sentryLogs.browseHasExpired);
533
582
  }
534
583
 
584
+ /**
585
+ * Show modal when the book can no longer be automatically renewed
586
+ * because another patron has checked it out.
587
+ */
588
+ async showLoanUnavailableModal() {
589
+ const config = new ModalConfig({
590
+ headline: '',
591
+ showCloseButton: false,
592
+ closeOnBackdropClick: false,
593
+ headerColor: '#194880',
594
+ message:
595
+ 'Another patron is borrowing this book. Please check back later.',
596
+ });
597
+
598
+ const customModalContent = html`<br />
599
+ <div style="text-align: center">
600
+ <button
601
+ style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
602
+ @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
603
+ >
604
+ Okay
605
+ </button>
606
+ </div>`;
607
+
608
+ await this.modal?.showModal({ config, customModalContent });
609
+ }
610
+
535
611
  async startBrowseTimer() {
536
612
  window?.IALendingIntervals?.clearBrowseExpireTimeout();
537
613
 
@@ -542,19 +618,10 @@ export default class IABookActions extends LitElement {
542
618
  } = this.lendingStatus;
543
619
 
544
620
  if (!user_has_browsed || browsingExpired) {
545
- log('startBrowseTimer --- !user_has_browsed || browsingExpired', {
546
- user_has_browsed,
547
- browsingExpired,
548
- secondsLeftOnLoan,
549
- });
550
621
  return;
551
622
  }
552
623
 
553
624
  window.IALendingIntervals.browseExpireTimeout = setTimeout(() => {
554
- log(
555
- 'startBrowseTimer > browseExpireTimeout --- will expire loan',
556
- secondsLeftOnLoan
557
- );
558
625
  this.browseHasExpired();
559
626
  }, secondsLeftOnLoan * 1000);
560
627
  }
@@ -636,12 +703,9 @@ export default class IABookActions extends LitElement {
636
703
 
637
704
  // number of seconds left in current loan
638
705
  const secondsLeft = Math.round((loanTime - new Date()) / 1000);
639
- log(loanTime, secondsLeft);
640
-
641
- log('IABookActions: handleLoanAutoRenewed --- ', {
706
+ log('[IABookActions] handleLoanAutoRenewed', {
707
+ secondsLeft,
642
708
  ajaxResponse: detail?.data,
643
- loanRenewResult: this.loanRenewResult,
644
- secondsLeftOnLoan: secondsLeft,
645
709
  });
646
710
 
647
711
  const currStatus = {
@@ -658,6 +722,9 @@ export default class IABookActions extends LitElement {
658
722
  this.modal.customModalContent = nothing;
659
723
  this.sentryCaptureMsg(sentryLogs.bookHasRenewed);
660
724
  }
725
+
726
+ this.warningModalOpen = false;
727
+ this.loanRenewInProgress = false;
661
728
  }
662
729
 
663
730
  /**
@@ -693,29 +760,19 @@ export default class IABookActions extends LitElement {
693
760
  // re-sync timer if gone off because of background window
694
761
  // side effect: updates this.lendingStatus & kicks off lifecycle,
695
762
  // if really updated, escape from here
696
- const resyncd = await this.reSyncTimerIfGoneOff(secondsLeft);
763
+ const resyncd = this.reSyncTimerIfGoneOff(secondsLeft);
697
764
 
698
765
  if (resyncd.hasSynced) {
699
766
  secondsLeft = resyncd.whatShouldLeft;
700
- log('startTimerCountdown --- stale, timer has resyncd', {
767
+ log('[IABookActions] startTimerCountdown: timer re-synced', {
701
768
  secondsLeft,
702
769
  });
703
770
  }
704
771
 
705
- log(
706
- 'startTimerCountdown --- countdown still valid. continue...',
707
- {
708
- resyncd,
709
- secondsLeft,
710
- loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
711
- },
712
- 'time: ',
713
- new Date().getMinutes(),
714
- ':',
715
- new Date().getSeconds(),
716
- ', timerDelay: ',
717
- this.timerExecutionSeconds
718
- );
772
+ log('[IABookActions] startTimerCountdown', {
773
+ secondsLeft,
774
+ loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
775
+ });
719
776
 
720
777
  /**
721
778
  * execute from last 10th minutes to 0th minute
@@ -740,7 +797,7 @@ export default class IABookActions extends LitElement {
740
797
  * @param {number} timerSecondsLeft - actual seconds left get from setInterval
741
798
  * @returns {Object} { hasSynced: [boolean], whatShouldLeft: [number] }
742
799
  */
743
- async reSyncTimerIfGoneOff(timerSecondsLeft) {
800
+ reSyncTimerIfGoneOff(timerSecondsLeft) {
744
801
  const currentTime = new Date();
745
802
 
746
803
  // current time - loan time
@@ -750,27 +807,11 @@ export default class IABookActions extends LitElement {
750
807
  const secondsShouldLeft =
751
808
  this.lendingStatus.secondsLeftOnLoan - diffInSeconds;
752
809
 
753
- log('currentTime: ', currentTime);
754
- log('timeWhenTimerStart: ', this.timeWhenTimerStart);
755
- log('diffInSeconds: ', diffInSeconds);
756
- log('secondsShouldLeft: ', secondsShouldLeft);
757
- log(
758
- 'this.lendingStatus.secondsLeftOnLoan: ',
759
- this.lendingStatus.secondsLeftOnLoan
760
- );
761
-
762
810
  // convert in minutes
763
811
  const whatIsleft = Math.round(timerSecondsLeft);
764
812
  const whatShouldLeft = Math.round(secondsShouldLeft);
765
813
  const timerElSeconds = this.timerCountdownEl.secondsLeftOnLoan || 0;
766
814
 
767
- log(`reSyncTimerIfGoneOff?`, {
768
- whatIsleft,
769
- whatShouldLeft,
770
- timerElSeconds,
771
- timeLeftInMin: Math.ceil(timerSecondsLeft / 60),
772
- });
773
-
774
815
  if (timerElSeconds !== whatShouldLeft || whatIsleft !== whatShouldLeft) {
775
816
  // set lending status with new time to update decrementor
776
817
  const currStatus = {
@@ -781,7 +822,9 @@ export default class IABookActions extends LitElement {
781
822
  }
782
823
 
783
824
  if (whatIsleft !== whatShouldLeft) {
784
- log(`reSyncTimerIfGoneOff --- let's re-sync.`);
825
+ log(
826
+ `[IABookActions] reSyncTimerIfGoneOff ${whatIsleft} - ${whatShouldLeft}: re-syncing timer`
827
+ );
785
828
  return { hasSynced: true, whatShouldLeft };
786
829
  }
787
830
 
@@ -797,10 +840,6 @@ export default class IABookActions extends LitElement {
797
840
  * @memberof IABookActions
798
841
  */
799
842
  async loanRenewAttempt(secondsLeft) {
800
- log('loanRenewAttempt ---', {
801
- secondsLeft,
802
- loanRenewResult: this.loanRenewResult,
803
- });
804
843
  let loanSecondsLeft = secondsLeft;
805
844
  /**
806
845
  * auto-renew is not possible in last seconds (let say 50 second) because,
@@ -810,7 +849,7 @@ export default class IABookActions extends LitElement {
810
849
  * so if seconds left is < 50, just expire the loan
811
850
  */
812
851
  if (loanSecondsLeft < 50) {
813
- log('loanRenewAttempt --- loanSecondsLeft < 50, will expire');
852
+ log('[IABookActions] loanRenewAttempt: < 50s left, expiring loan');
814
853
  await this.browseHasExpired();
815
854
  return;
816
855
  }
@@ -892,9 +931,17 @@ export default class IABookActions extends LitElement {
892
931
  const action = event?.detail?.action;
893
932
  const errorMsg = event?.detail?.data?.error;
894
933
 
895
- // template not show create_token errors
896
- if (errorMsg && action !== 'create_token')
934
+ // For renew_loan failures after auto-renew, show the patron-facing message
935
+ if (
936
+ errorMsg &&
937
+ action === 'renew_loan' &&
938
+ errorMsg.match(/not available/)
939
+ ) {
940
+ this.loanRenewInProgress = false;
941
+ this.showLoanUnavailableModal();
942
+ } else if (errorMsg && action !== 'create_token') {
897
943
  this.showErrorModal(errorMsg, action);
944
+ }
898
945
 
899
946
  // if error related to loan token
900
947
  // - set user_has_browsed to `false`
@@ -82,7 +82,7 @@ describe('<ia-book-actions>', () => {
82
82
  expect(el.disableActionGroup).to.be.true;
83
83
  });
84
84
 
85
- it('handles `BookReader:userAction` event', async () => {
85
+ it('handles `BookReader:userAction` event when loan is active', async () => {
86
86
  const el = await fixture(
87
87
  container({
88
88
  userid: '@user1',
@@ -91,8 +91,10 @@ describe('<ia-book-actions>', () => {
91
91
  );
92
92
 
93
93
  const spy = Sinon.spy(el, 'autoLoanRenewChecker');
94
- el.borrowType = 'browsed';
94
+ el.lendingStatus = { ...el.lendingStatus, browsingExpired: false };
95
95
  await el.updateComplete;
96
+ // Set borrowType after the update so setupLendingToolbarActions() doesn't overwrite it
97
+ el.borrowType = 'browsed';
96
98
 
97
99
  window.dispatchEvent(new Event('BookReader:userAction'));
98
100
  await el.updateComplete;
@@ -100,6 +102,25 @@ describe('<ia-book-actions>', () => {
100
102
  expect(spy.calledOnce).to.be.true;
101
103
  expect(spy.calledWith(true)).to.be.true;
102
104
  });
105
+
106
+ it('handles `BookReader:userAction` event when loan has expired', async () => {
107
+ const el = await fixture(
108
+ container({
109
+ userid: '@user1',
110
+ identifier: 'foobar',
111
+ lendingStatus: { user_has_browsed: true, browsingExpired: true },
112
+ })
113
+ );
114
+
115
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
116
+ el.borrowType = 'browsed';
117
+ await el.updateComplete;
118
+
119
+ window.dispatchEvent(new Event('BookReader:userAction'));
120
+ await el.updateComplete;
121
+
122
+ expect(spy.calledOnce).to.be.true;
123
+ });
103
124
  });
104
125
 
105
126
  describe('Primary Actions data', () => {
@@ -467,7 +488,7 @@ describe('Visibility change API for document', async () => {
467
488
  });
468
489
  });
469
490
 
470
- it('when book is not expired', async () => {
491
+ it('when book is not expired and loan time is valid', async () => {
471
492
  const el = await fixture(
472
493
  container({
473
494
  userid: '@user1',
@@ -480,31 +501,220 @@ describe('Visibility change API for document', async () => {
480
501
  );
481
502
  await el.updateComplete;
482
503
 
504
+ // Set a valid loan time well into the future
505
+ await el.localCache.set({
506
+ key: 'foobar-loanTime',
507
+ value: new Date(new Date().getTime() + 3600 * 1000),
508
+ ttl: 3600,
509
+ });
510
+
483
511
  const spy = Sinon.spy(el, 'loanStatusCheckInterval');
484
512
  document.dispatchEvent(new Event('visibilitychange'));
513
+ await aTimeout(100);
514
+
515
+ expect(spy.calledOnce).to.be.true;
516
+ });
517
+
518
+ it('when loan expired while tab was hidden (browsingExpired false, loanTime past)', async () => {
519
+ const el = await fixture(
520
+ container({
521
+ userid: '@user1',
522
+ identifier: 'foobar',
523
+ lendingStatus: {
524
+ user_has_browsed: true,
525
+ browsingExpired: false,
526
+ },
527
+ })
528
+ );
529
+ await el.updateComplete;
530
+
531
+ // Set a loan time already in the past
532
+ await el.localCache.set({
533
+ key: 'foobar-loanTime',
534
+ value: new Date(new Date().getTime() - 10 * 1000),
535
+ ttl: 3600,
536
+ });
537
+
538
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
539
+ document.dispatchEvent(new Event('visibilitychange'));
540
+ await aTimeout(100);
485
541
 
486
542
  expect(spy.calledOnce).to.be.true;
487
543
  });
488
544
 
489
- it('when book is already expired', async () => {
545
+ it('when loan already marked expired (browsingExpired true)', async () => {
490
546
  const el = await fixture(
491
547
  container({
492
548
  userid: '@user1',
493
549
  identifier: 'foobar',
494
550
  lendingStatus: {
495
551
  user_has_browsed: true,
552
+ browsingExpired: true,
496
553
  },
497
554
  })
498
555
  );
499
556
  await el.updateComplete;
500
557
 
501
- const spy = Sinon.spy(el, 'browseHasExpired');
558
+ el.borrowType = 'browsed';
559
+
560
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
502
561
  document.dispatchEvent(new Event('visibilitychange'));
562
+ await aTimeout(100);
503
563
 
504
564
  expect(spy.calledOnce).to.be.true;
505
565
  });
506
566
  });
507
567
 
568
+ describe('autoRenewExpiredLoan', () => {
569
+ it('optimistically resets browsingExpired so the button stays red', async () => {
570
+ const el = await fixture(
571
+ container({
572
+ userid: '@user1',
573
+ identifier: 'foobar',
574
+ lendingStatus: {
575
+ user_has_browsed: true,
576
+ available_to_browse: true,
577
+ browsingExpired: true,
578
+ },
579
+ })
580
+ );
581
+ await el.updateComplete;
582
+
583
+ // Before: browsingExpired true → borrow1HrAction → primary color is 'primary' (blue)
584
+ expect(el.primaryColor).to.equal('primary');
585
+
586
+ el.autoRenewExpiredLoan();
587
+ await el.updateComplete;
588
+
589
+ // After: browsingExpired flipped to false → patronIsReadingAction → 'danger' (red)
590
+ expect(el.lendingStatus.browsingExpired).to.be.false;
591
+ expect(el.primaryColor).to.equal('danger');
592
+ });
593
+
594
+ it('sets loanRenewInProgress and triggers renewNow after timeout', async () => {
595
+ const el = await fixture(
596
+ container({
597
+ userid: '@user1',
598
+ identifier: 'foobar',
599
+ lendingStatus: {
600
+ user_has_browsed: true,
601
+ browsingExpired: true,
602
+ },
603
+ })
604
+ );
605
+ await el.updateComplete;
606
+
607
+ el.autoRenewExpiredLoan();
608
+
609
+ expect(el.loanRenewInProgress).to.be.true;
610
+
611
+ // renewNow is set in a setTimeout — flush the macrotask queue
612
+ await aTimeout(50);
613
+ expect(el.loanRenewResult.renewNow).to.be.true;
614
+ expect(el.loanRenewResult.renewType).to.equal('auto');
615
+ });
616
+
617
+ it('ignores subsequent calls while a renewal is already in progress', async () => {
618
+ const el = await fixture(
619
+ container({
620
+ userid: '@user1',
621
+ identifier: 'foobar',
622
+ lendingStatus: {
623
+ user_has_browsed: true,
624
+ browsingExpired: true,
625
+ },
626
+ })
627
+ );
628
+ await el.updateComplete;
629
+
630
+ // Spy *after* fixture so the handler is the same instance
631
+ const spy = Sinon.spy(el, 'autoRenewExpiredLoan');
632
+
633
+ el.autoRenewExpiredLoan(); // first call — proceeds
634
+ el.autoRenewExpiredLoan(); // second call — should be a no-op
635
+
636
+ // The spy counts both invocations but the internal guard stops the second
637
+ expect(spy.callCount).to.equal(2);
638
+ // loanRenewInProgress is still true (not double-set or cleared)
639
+ expect(el.loanRenewInProgress).to.be.true;
640
+
641
+ // Only one setTimeout fires (the second call returned early)
642
+ await aTimeout(50);
643
+ expect(el.loanRenewResult.renewNow).to.be.true;
644
+ });
645
+
646
+ it('clears loanRenewInProgress after a successful loanAutoRenewed event', async () => {
647
+ const el = await fixture(
648
+ container({
649
+ userid: '@user1',
650
+ identifier: 'foobar',
651
+ lendingStatus: {
652
+ user_has_browsed: true,
653
+ browsingExpired: false,
654
+ secondsLeftOnLoan: 100,
655
+ },
656
+ })
657
+ );
658
+ await el.updateComplete;
659
+
660
+ await el.localCache.set({
661
+ key: 'foobar-loanTime',
662
+ value: new Date(new Date().getTime() + 3600 * 1000),
663
+ ttl: 3600,
664
+ });
665
+
666
+ el.autoRenewExpiredLoan();
667
+ expect(el.loanRenewInProgress).to.be.true;
668
+
669
+ // Wait for the deferred renewNow to be set
670
+ await aTimeout(50);
671
+
672
+ // Simulate the renew_loan success response
673
+ const collapsibleActionGroupEl = el.shadowRoot.querySelector(
674
+ 'collapsible-action-group'
675
+ );
676
+ collapsibleActionGroupEl.dispatchEvent(
677
+ new CustomEvent('loanAutoRenewed', {
678
+ detail: { data: { loan: { identifier: 'foobar' } } },
679
+ })
680
+ );
681
+
682
+ await aTimeout(100);
683
+ await el.updateComplete;
684
+
685
+ expect(el.loanRenewInProgress).to.be.false;
686
+ });
687
+
688
+ it('clears loanRenewInProgress and shows unavailable modal on renew_loan failure', async () => {
689
+ const el = await fixture(
690
+ container({
691
+ userid: '@user1',
692
+ identifier: 'foobar',
693
+ lendingStatus: {
694
+ user_has_browsed: true,
695
+ browsingExpired: true,
696
+ },
697
+ })
698
+ );
699
+ await el.updateComplete;
700
+
701
+ const showModalSpy = Sinon.spy(el, 'showLoanUnavailableModal');
702
+
703
+ el.autoRenewExpiredLoan();
704
+ expect(el.loanRenewInProgress).to.be.true;
705
+
706
+ el.handleLendingActionError({
707
+ detail: {
708
+ action: 'renew_loan',
709
+ data: { error: 'book is not available' },
710
+ },
711
+ });
712
+
713
+ expect(el.loanRenewInProgress).to.be.false;
714
+ expect(showModalSpy.calledOnce).to.be.true;
715
+ });
716
+ });
717
+
508
718
  describe('Shared Resize Observer', () => {
509
719
  it('can receive a Shared Resize Observer', async () => {
510
720
  const sharedObserverStub = new SharedResizeObserver();
@@ -3,7 +3,7 @@
3
3
  export default /** @type {import("@web/test-runner").TestRunnerConfig} */ ({
4
4
  files: 'test/**/*.test.js',
5
5
  nodeResolve: true,
6
- watch: true,
6
+ watch: false,
7
7
 
8
8
  /** Compile JS for older browsers. Requires @web/dev-server-esbuild plugin */
9
9
  esbuildTarget: 'auto',