@internetarchive/ia-book-actions 1.4.4-alpha1 → 1.4.4-alpha11

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.
package/index.html CHANGED
@@ -97,6 +97,7 @@
97
97
  border: 2px solid #194880;
98
98
  border-radius: 10px;
99
99
  background: white;
100
+ height: 50px;
100
101
  }
101
102
  .timer-presets button.active-preset {
102
103
  background: #194880;
@@ -137,6 +138,9 @@
137
138
  .loan-status-badge.status-active {
138
139
  background: #2e7d32; /* green: loan is currently checked out */
139
140
  }
141
+ .loan-status-badge.status-renewing {
142
+ background: #b8860b; /* amber: a renew_loan attempt is in flight */
143
+ }
140
144
  .loan-status-badge.status-returned {
141
145
  background: #d9534f; /* red: auto-returned / not on loan */
142
146
  }
@@ -216,7 +220,7 @@
216
220
  console.log = (...consoleArgs) => {
217
221
  __nativeConsoleLog(...consoleArgs);
218
222
 
219
- const loggedPrefixes = ['[ActionsHandlerService]', '[IABookActions]', '[Demo]'];
223
+ const loggedPrefixes = ['[IABookActions]', '[IABookActions]', '[Demo]'];
220
224
  if (typeof consoleArgs[0] !== 'string' || !loggedPrefixes.some((prefix) => consoleArgs[0].startsWith(prefix))) {
221
225
  return;
222
226
  }
@@ -280,7 +284,7 @@
280
284
  const timerPresets = {
281
285
  '15s': { loanTotalTime: 15, loanRenewAtLast: 13, pageChangedInLast: 5 },
282
286
  '30s': { loanTotalTime: 30, loanRenewAtLast: 27, pageChangedInLast: 10 },
283
- '2': { loanTotalTime: 120, loanRenewAtLast: 110, pageChangedInLast: 30 },
287
+ '2': { loanTotalTime: 120, loanRenewAtLast: 95, pageChangedInLast: 15 },
284
288
  '5': { loanTotalTime: 300, loanRenewAtLast: 240, pageChangedInLast: 60 },
285
289
  '10': { loanTotalTime: 600, loanRenewAtLast: 480, pageChangedInLast: 120 },
286
290
  '30': { loanTotalTime: 1800, loanRenewAtLast: 1500, pageChangedInLast: 600 },
@@ -439,6 +443,14 @@
439
443
  await iaBookActions.updateComplete;
440
444
  }
441
445
 
446
+ // showWarningModal() reads its message text from
447
+ // loanRenewHelper.getMessageTexts(loanRenewResult.texts, ...) — that
448
+ // helper only exists, and loanRenewResult.texts only gets populated,
449
+ // once autoLoanRenewChecker() has run at least once (this is what the
450
+ // real periodic timer calls before ever showing the modal). Run it
451
+ // here too so the button shows the same populated message a real
452
+ // warning would.
453
+ await iaBookActions.autoLoanRenewChecker(false);
442
454
  iaBookActions.showWarningModal();
443
455
  });
444
456
 
@@ -578,6 +590,13 @@
578
590
  function computeLoanStatus() {
579
591
  const lendingStatus = iaBookActions.lendingStatus || {};
580
592
 
593
+ // Checked first: autoRenewExpiredLoan() optimistically flips
594
+ // browsingExpired back to false the moment a retry starts (before the
595
+ // renew_loan call has actually landed), so without this check the
596
+ // badge would misleadingly jump straight to ACTIVE during the retry.
597
+ if (iaBookActions.loanRenewInProgress) {
598
+ return { badge: 'RENEWING', className: 'status-renewing' };
599
+ }
581
600
  if (lendingStatus.browsingExpired) {
582
601
  return { badge: 'RETURNED', className: 'status-returned' };
583
602
  }
@@ -662,7 +681,10 @@
662
681
  <button id="show_expired_modal">2. Auto-return due to inactivity<br>(no modal, page stays put)</button>
663
682
  </td>
664
683
  <td class="action-button">
665
- <button class="pageChangedEvent">3. Interact with book (turn page)<br>silently renew if interact in given time</button>
684
+ <button class="pageChangedEvent">
685
+ 3. Interact with book (turn page)<br>
686
+ silently renew while active or after auto-return
687
+ </button>
666
688
  </td>
667
689
  <td class="action-button">
668
690
  <button id="resync_timer">4. Return to tab after time away<br>(visibilitychange — silent retry if expired)</button>
@@ -688,9 +710,6 @@
688
710
  <td class="searchParam">
689
711
  <label><input type="checkbox" name="search" value="Search Param" />Search Param</label>
690
712
  </td>
691
- <td>
692
- <label class="ia-button"><button class="pageChangedEvent">Page Change Event</button></label>
693
- </td>
694
713
  </tr>
695
714
  </table>
696
715
  </fieldset>
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "author": "Internet Archive",
9
9
  "license": "AGPL-3.0-only",
10
- "version": "1.4.4-alpha1",
10
+ "version": "1.4.4-alpha11",
11
11
  "main": "index.js",
12
12
  "module": "index.js",
13
13
  "publishConfig": {
@@ -84,10 +84,10 @@ export default async function ActionsHandlerService(options) {
84
84
  .then(data => {
85
85
  // `data` is the parsed version of the JSON returned from the above endpoint.
86
86
  if (!data?.error) {
87
- log(`[ActionsHandlerService] ✓ ${option.action} succeeded`, data);
87
+ log(`[IABookActions] ✓ ${option.action} succeeded`, data);
88
88
  option?.success(data);
89
89
  } else {
90
- log(`[ActionsHandlerService] ✗ ${option.action} failed`, data);
90
+ log(`[IABookActions] ✗ ${option.action} failed`, data);
91
91
  option?.error(data);
92
92
  }
93
93
  });
@@ -53,20 +53,8 @@ export default class ActionsHandler extends LitElement {
53
53
  );
54
54
  });
55
55
 
56
- this.addEventListener('autoRenew', async ({ detail }) => {
57
- this.handleLoanRenewNow();
58
- await this.loanAnanlytics?.storeLoanStatsCount(
59
- this.identifier,
60
- 'autorenew'
61
- );
62
-
63
- const analyticsLabel = detail?.renewType === 'auto' ? analyticsLabels.browseAutoRenew : analyticsLabels.browseManualRenew;
64
- this.loanAnanlytics?.sendEvent(
65
- analyticsCategories.browse,
66
- analyticsActions.browseRenew,
67
- analyticsLabel,
68
- this.identifier
69
- );
56
+ this.addEventListener('autoRenew', ({ detail }) => {
57
+ this.handleLoanRenewNow(detail?.renewType);
70
58
  });
71
59
 
72
60
  this.addEventListener('autoReturn', async () => {
@@ -177,14 +165,14 @@ export default class ActionsHandler extends LitElement {
177
165
  });
178
166
  }
179
167
 
180
- handleLoanRenewNow() {
168
+ handleLoanRenewNow(renewType) {
181
169
  const action = 'renew_loan';
182
170
 
183
171
  ActionsHandlerService({
184
172
  action,
185
173
  identifier: this.identifier,
186
174
  success: async data => {
187
- log('RENEW_LOAN --- ', data, action, data.loan, this.identifier);
175
+ log('RENEW_LOAN --- ', data, this.identifier);
188
176
  const activeLoan = data.loan ? data.loan : undefined;
189
177
  const isRenewal = activeLoan.renewal;
190
178
 
@@ -192,6 +180,23 @@ export default class ActionsHandler extends LitElement {
192
180
  // Await — loanAutoRenewed listeners read this same cache key
193
181
  // immediately; dispatching before the write lands was a race.
194
182
  await this.setBrowseTimeSession();
183
+
184
+ // Only record the renew analytics event once the renewal has
185
+ // actually succeeded — not merely attempted.
186
+ await this.loanAnanlytics?.storeLoanStatsCount(
187
+ this.identifier,
188
+ 'autorenew'
189
+ );
190
+ const analyticsLabel =
191
+ renewType === 'auto'
192
+ ? analyticsLabels.browseAutoRenew
193
+ : analyticsLabels.browseManualRenew;
194
+ this.loanAnanlytics?.sendEvent(
195
+ analyticsCategories.browse,
196
+ analyticsActions.browseRenew,
197
+ analyticsLabel,
198
+ this.identifier
199
+ );
195
200
  } else {
196
201
  log('RENEW_LOAN ERROR --- ', {
197
202
  action,
@@ -18,6 +18,18 @@ export class LoanTokenPoller {
18
18
 
19
19
  this.loanTokenInterval = undefined;
20
20
 
21
+ /**
22
+ * Lending::do_renew() (backend) deletes the old loan record and
23
+ * writes a brand-new one on every renewal — this poller's initial
24
+ * create_token call (fired right after a renewal re-establishes the
25
+ * reading session) can race that write's propagation and come back
26
+ * with "you do not currently have this book borrowed" even though
27
+ * the loan is genuinely valid. Retry a few times with backoff instead
28
+ * of giving up on the first attempt.
29
+ */
30
+ this.maxInitialTokenRetries = 3;
31
+ this.initialTokenRetryDelay = 1000; // ms, multiplied by attempt number
32
+
21
33
  /**
22
34
  * loan analytics instance
23
35
  * @see loan-analytics.js
@@ -58,29 +70,64 @@ export class LoanTokenPoller {
58
70
  }
59
71
  }
60
72
 
61
- async handleLoanTokenPoller(isInitial = false) {
73
+ /**
74
+ * @param {boolean} isInitial - the first create_token call right after
75
+ * bookAccessed()/a renewal, as opposed to a routine interval tick.
76
+ * @param {number} retryCount - internal, counts retries of the initial call.
77
+ */
78
+ async handleLoanTokenPoller(isInitial = false, retryCount = 0) {
62
79
  const action = 'create_token';
63
80
  ActionsHandlerService({
64
81
  identifier: this.identifier,
65
82
  action,
66
- error: data => {
67
- this.errorCallback({ detail: { action, data } });
68
-
69
- // send error to Sentry
70
- window?.Sentry?.captureMessage(
71
- `${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
72
- );
73
-
74
- // send LendingServiceError to GA
75
- this.loanAnalytics?.sendEvent(
76
- 'LendingServiceLoanError',
77
- action,
78
- this.identifier
79
- );
80
- },
83
+ error: data => this.handleTokenError(data, isInitial, retryCount),
81
84
  success: () => {
82
85
  if (isInitial) this.successCallback();
83
86
  },
84
87
  });
85
88
  }
89
+
90
+ /**
91
+ * Decide whether a create_token failure should be retried (a stale
92
+ * read of the loan record right after a renewal, see the constructor
93
+ * comment) or reported via errorCallback. Split out from
94
+ * handleLoanTokenPoller so it's directly testable without needing to
95
+ * mock the network call.
96
+ *
97
+ * @param {Object} data - the error payload from ActionsHandlerService.
98
+ * @param {boolean} isInitial
99
+ * @param {number} retryCount
100
+ */
101
+ handleTokenError(data, isInitial, retryCount) {
102
+ const action = 'create_token';
103
+ const isStaleLoanReadError =
104
+ typeof data?.error === 'string' &&
105
+ /do not currently have this book borrowed/i.test(data.error);
106
+
107
+ if (
108
+ isInitial &&
109
+ isStaleLoanReadError &&
110
+ retryCount < this.maxInitialTokenRetries
111
+ ) {
112
+ const delay = this.initialTokenRetryDelay * (retryCount + 1);
113
+ setTimeout(() => {
114
+ this.handleLoanTokenPoller(true, retryCount + 1);
115
+ }, delay);
116
+ return;
117
+ }
118
+
119
+ this.errorCallback({ detail: { action, data } });
120
+
121
+ // send error to Sentry
122
+ window?.Sentry?.captureMessage(
123
+ `${sentryLogs.handleLoanTokenPoller} - Error: ${JSON.stringify(data)}`
124
+ );
125
+
126
+ // send LendingServiceError to GA
127
+ this.loanAnalytics?.sendEvent(
128
+ 'LendingServiceLoanError',
129
+ action,
130
+ this.identifier
131
+ );
132
+ }
86
133
  }
@@ -61,6 +61,7 @@ export default class IABookActions extends LitElement {
61
61
  modal: { Object },
62
62
  tokenDelay: { type: Number },
63
63
  timerExecutionSeconds: { type: Number },
64
+ userActionGracePeriod: { type: Number },
64
65
  localCache: { type: Object },
65
66
  loanRenewTimeConfig: { type: Object },
66
67
  loanRenewResult: { type: Object },
@@ -83,6 +84,14 @@ export default class IABookActions extends LitElement {
83
84
  this.tokenDelay = 120; // in seconds
84
85
  this.timerExecutionSeconds = 30;
85
86
 
87
+ /**
88
+ * How long (ms) after bindLoanRenewEvents() runs to ignore
89
+ * BookReader:userAction events — BookReader fires that event on its
90
+ * own init-time jump to the last-read page, not just on genuine
91
+ * interaction. @see bindLoanRenewEvents
92
+ */
93
+ this.userActionGracePeriod = 2000;
94
+
86
95
  // private props
87
96
  this.postInitComplete = false;
88
97
  this.primaryActions = [];
@@ -96,6 +105,15 @@ export default class IABookActions extends LitElement {
96
105
 
97
106
  this.loanRenewInProgress = false;
98
107
 
108
+ /**
109
+ * True while a renewal in flight is recovering from a loan that
110
+ * genuinely lapsed (set by autoRenewExpiredLoan), as opposed to a
111
+ * routine pre-expiry top-up. Only the recovery case needs BookReader
112
+ * re-initialized once the renewal is confirmed — see
113
+ * handleLoanAutoRenewed.
114
+ */
115
+ this.recoveringFromLoanExpiry = false;
116
+
99
117
  this.warningModalOpen = false;
100
118
 
101
119
  /**
@@ -180,6 +198,13 @@ export default class IABookActions extends LitElement {
180
198
  }
181
199
 
182
200
  if (changed.has('loanRenewResult') && this.loanRenewResult.renewNow) {
201
+ // Any renewal — not just the auto-return recovery path — pauses the
202
+ // countdown for the renew_loan round-trip. Flag it here, centrally,
203
+ // so every path (interaction during active reading, periodic
204
+ // checker, auto-return recovery) gets the same "renewing" signal
205
+ // instead of just looking frozen until handleLoanAutoRenewed() clears
206
+ // this and restarts the timer.
207
+ this.loanRenewInProgress = true;
183
208
  window.IALendingIntervals.clearAll();
184
209
  }
185
210
  }
@@ -267,7 +292,14 @@ export default class IABookActions extends LitElement {
267
292
 
268
293
  this.borrowType = actions.borrowType ? actions.borrowType : null;
269
294
 
270
- if (this.borrowType === 'browsed') {
295
+ // Don't (re)start the countdown while a renewal is in flight — the
296
+ // optimistic browsingExpired flip in autoRenewExpiredLoan() (which
297
+ // triggers this same setupLendingToolbarActions() call) happens before
298
+ // renewal is confirmed, so secondsLeftOnLoan could still be a stale
299
+ // value from before the renewal. Wait for handleLoanAutoRenewed() to
300
+ // confirm the real value and clear loanRenewInProgress before showing
301
+ // a countdown again.
302
+ if (this.borrowType === 'browsed' && !this.loanRenewInProgress) {
271
303
  // start timer for loan-renew
272
304
  await this.startTimerCountdown();
273
305
 
@@ -283,11 +315,22 @@ export default class IABookActions extends LitElement {
283
315
 
284
316
  /**
285
317
  * tokenPoller determines if user has loan token for this book
286
- * - if book is going to renew, need to wait until renew is completed
318
+ * - if book is going to renew, need to wait until renew is completed:
319
+ * loanRenewInProgress is set before renew_loan is even dispatched
320
+ * (including the optimistic pre-confirmation window) and only
321
+ * clears once the renewal is confirmed, so starting the poller here
322
+ * mid-renewal would call create_token against the loan record
323
+ * before it's actually renewed.
287
324
  */
288
325
  setTimeout(() => {
289
- if (!hasExpired && !window.IALendingIntervals.tokenPoller)
326
+ if (
327
+ !hasExpired &&
328
+ !this.loanRenewInProgress &&
329
+ !window.IALendingIntervals.tokenPoller
330
+ ) {
331
+ this.recoveringFromLoanExpiry = false;
290
332
  this.startLoanTokenPoller();
333
+ }
291
334
  }, 100);
292
335
 
293
336
  this.requestUpdate();
@@ -300,21 +343,40 @@ export default class IABookActions extends LitElement {
300
343
  * 2. IABookActions:loanRenew - dispatched from timer-countdown component
301
344
  */
302
345
  bindLoanRenewEvents() {
346
+ this.userActionReadyAt = Date.now() + this.userActionGracePeriod;
347
+
303
348
  /**
304
349
  * dispatched this event from bookreader page changed
305
350
  */
306
351
  window.addEventListener('BookReader:userAction', () => {
352
+ if (Date.now() < this.userActionReadyAt) {
353
+ log(
354
+ '[IABookActions] BookReader:userAction ignored — within startup grace period'
355
+ );
356
+ return;
357
+ }
358
+
359
+ // Capture before autoRenewExpiredLoan() runs — it synchronously flips
360
+ // lendingStatus.browsingExpired to false as an optimistic UI update,
361
+ // so checking the live property afterward would always see it as
362
+ // false and wrongly let autoLoanRenewChecker() run too. That second
363
+ // call would then overwrite loanRenewResult.renewNow (set true by
364
+ // autoRenewExpiredLoan) back to false, since the loanTime cache entry
365
+ // was already deleted by browseHasExpired() — silently killing the
366
+ // in-flight renewal and leaving the countdown stuck.
367
+ const wasExpired = this.lendingStatus.browsingExpired;
368
+
307
369
  log('[IABookActions] BookReader:userAction received', {
308
370
  borrowType: this.borrowType,
309
- browsingExpired: this.lendingStatus.browsingExpired,
371
+ browsingExpired: wasExpired,
310
372
  });
311
373
 
312
374
  // If the loan expired while the tab stayed visible, auto-renew on page turn
313
- if (this.lendingStatus.browsingExpired) {
375
+ if (wasExpired) {
314
376
  this.autoRenewExpiredLoan();
315
377
  }
316
378
 
317
- if (this.borrowType === 'browsed' && !this.lendingStatus.browsingExpired) {
379
+ if (this.borrowType === 'browsed' && !wasExpired) {
318
380
  this.autoLoanRenewChecker(true);
319
381
  }
320
382
  });
@@ -372,6 +434,16 @@ export default class IABookActions extends LitElement {
372
434
  * @param {Boolean} hasPageChanged
373
435
  */
374
436
  async autoLoanRenewChecker(hasPageChanged = false) {
437
+ // Guards against re-entrancy the same way autoRenewExpiredLoan() does.
438
+ // BookReader:userAction can fire several times in quick succession
439
+ // (e.g. a single scroll gesture), and this method has no debouncing of
440
+ // its own — without this guard, each event would spin up its own
441
+ // LoanRenewHelper concurrently, and whichever's async localCache reads
442
+ // resolve last would clobber this.loanRenewResult, potentially
443
+ // re-triggering the whole renew_loan/create_token flow while a
444
+ // renewal from an earlier event is already in flight or just landed.
445
+ if (this.loanRenewInProgress) return;
446
+
375
447
  this.loanRenewHelper = new LoanRenewHelper(
376
448
  hasPageChanged,
377
449
  this.identifier,
@@ -395,16 +467,23 @@ export default class IABookActions extends LitElement {
395
467
  autoRenewExpiredLoan() {
396
468
  if (this.loanRenewInProgress) return;
397
469
  this.loanRenewInProgress = true;
470
+ this.recoveringFromLoanExpiry = true;
398
471
 
399
472
  this.modal?.closeModal();
400
473
 
401
474
  // Optimistically flip browsingExpired back to false so the action bar
402
475
  // stays red (patronIsReadingAction) while the renew_loan request is in
403
- // flight, instead of showing the blue "Borrow" state.
476
+ // flight, instead of showing the blue "Borrow" state. Deliberately NOT
477
+ // resetting secondsLeftOnLoan here — the renewal hasn't been confirmed
478
+ // yet (it can take several seconds, or genuinely fail), so showing a
479
+ // full hour before we know the outcome would be misleading. The real
480
+ // value is set once handleLoanAutoRenewed() confirms success;
481
+ // loanRenewInProgress (already true above) is the signal consumers
482
+ // should use to show a "renewing" state instead of trusting the
483
+ // countdown during this window.
404
484
  this.lendingStatus = {
405
485
  ...this.lendingStatus,
406
486
  browsingExpired: false,
407
- secondsLeftOnLoan: this.loanRenewTimeConfig.loanTotalTime,
408
487
  };
409
488
 
410
489
  // Defer renewNow until after lendingStatus timer setup completes.
@@ -457,19 +536,14 @@ export default class IABookActions extends LitElement {
457
536
  headerColor: '#194880',
458
537
  showCloseButton: false,
459
538
  closeOnBackdropClick: false,
460
- message: html`<span
461
- style="display:inline-flex;align-items:center;flex-wrap:nowrap;gap:4px;"
462
- >
463
- <span>${this.loanRenewHelper?.getMessageTexts(
464
- warningTexts,
465
- secondsLeft
466
- )}</span>
539
+ message: html`<span>
540
+ ${this.loanRenewHelper?.getMessageTexts(warningTexts, secondsLeft)}
467
541
  <a
468
542
  href="https://help.archive.org/help/borrowing-from-the-lending-library"
469
543
  target="_blank"
470
544
  title="Get more info on borrowing from The Lending Library"
471
545
  data-event-click-tracking="BookReader|BrowsableMoreInfo"
472
- style="display:inline-flex;flex-shrink:0;line-height:0;"
546
+ style="display:inline-block;vertical-align:middle;line-height:0;margin-left:4px;"
473
547
  >
474
548
  ${infoIcon}
475
549
  </a>
@@ -532,20 +606,21 @@ export default class IABookActions extends LitElement {
532
606
  * Show modal when the book can no longer be automatically renewed
533
607
  * because another patron has checked it out.
534
608
  */
535
- async showLoanUnavailableModal() {
609
+ async showLoanUnavailableModal(errorMsg) {
536
610
  const config = new ModalConfig({
537
611
  headline: '',
538
612
  showCloseButton: false,
539
613
  closeOnBackdropClick: false,
540
- headerColor: '#194880',
614
+ headerColor: '#d9534f',
541
615
  message:
616
+ errorMsg ||
542
617
  'Due to inactivity, this book was returned, and someone else has now borrowed it. Please try again later.',
543
618
  });
544
619
 
545
620
  const customModalContent = html`<br />
546
621
  <div style="text-align: center">
547
622
  <button
548
- style="${modalButtonStyle.iaButton} ${modalButtonStyle.renew}"
623
+ style="${modalButtonStyle.iaButton} ${modalButtonStyle.return}"
549
624
  @click=${() => URLHelper.goToUrl(this.returnUrl, true)}
550
625
  >
551
626
  Okay
@@ -639,6 +714,7 @@ export default class IABookActions extends LitElement {
639
714
  const activeLoan = detail?.data?.loan;
640
715
  const errorMessage = `Whoops, seems we hit a hiccup with renewing this book. Please refresh & retry. --- (Debug: ${detail?.data?.error})`;
641
716
  if (!activeLoan) {
717
+ this.recoveringFromLoanExpiry = false;
642
718
  this.showErrorModal(errorMessage, 'handleLoanAutoRenewed');
643
719
  return;
644
720
  }
@@ -661,6 +737,23 @@ export default class IABookActions extends LitElement {
661
737
  ajaxResponse: detail?.data,
662
738
  });
663
739
 
740
+ if (this.recoveringFromLoanExpiry) {
741
+ // The loan genuinely lapsed, and renew_loan has now CONFIRMED it's
742
+ // renewed server-side — only now is it safe to let BookReader
743
+ // re-initialize (create_token was minting its access cookie off
744
+ // the loan's expiry; doing this before confirmation risked reading
745
+ // the still-expired record). loanRenewInProgress already kept the
746
+ // token poller from restarting early, so it'll pick up fresh here
747
+ // once lendingStatus below re-triggers setupLendingToolbarActions().
748
+ // recoveringFromLoanExpiry itself is consumed/reset there, not
749
+ // here — this ONLY applies to an actual recovery, not a routine
750
+ // renewal (re-running br.init() on every routine top-up would be
751
+ // needlessly disruptive). create_token's own retry logic (see
752
+ // LoanTokenPoller) handles the datanode write-propagation race
753
+ // for both recovery and routine renewals.
754
+ this.postInitComplete = false;
755
+ }
756
+
664
757
  const currStatus = {
665
758
  ...this.lendingStatus,
666
759
  user_has_browsed: true,
@@ -718,14 +811,15 @@ export default class IABookActions extends LitElement {
718
811
 
719
812
  if (resyncd.hasSynced) {
720
813
  secondsLeft = resyncd.whatShouldLeft;
721
- log('[IABookActions] startTimerCountdown: timer re-synced', {
814
+ log('[IABookActions] timer: timer re-synced', {
722
815
  secondsLeft,
723
816
  });
724
817
  }
725
818
 
726
- log('[IABookActions] startTimerCountdown', {
819
+ log('[IABookActions] timer', {
727
820
  secondsLeft,
728
821
  loanRenewAtLast: this.loanRenewTimeConfig.loanRenewAtLast,
822
+ pageChangedInLast: this.loanRenewTimeConfig.pageChangedInLast,
729
823
  });
730
824
 
731
825
  /**
@@ -881,50 +975,71 @@ export default class IABookActions extends LitElement {
881
975
  handleLendingActionError(event) {
882
976
  this.disableActionGroup = false;
883
977
 
884
- // clear all intervals for lending system when error occured
885
- window?.IALendingIntervals?.clearAll();
886
-
887
978
  const action = event?.detail?.action;
888
979
  const errorMsg = event?.detail?.data?.error;
889
980
 
890
- // For renew_loan failures after auto-renew, show the patron-facing message
891
- if (
892
- errorMsg &&
893
- action === 'renew_loan' &&
894
- errorMsg.match(/not available/)
895
- ) {
896
- this.loanRenewInProgress = false;
897
- this.showLoanUnavailableModal();
898
- } else if (errorMsg && action !== 'create_token') {
899
- this.showErrorModal(errorMsg, action);
900
- }
901
-
902
- // if error related to loan token
903
- // - set user_has_browsed to `false`
904
981
  if (action === 'create_token') {
905
- const currStatus = {
982
+ // A create_token hiccup only affects BookReader's page-image access
983
+ // token — it says nothing about how much time is left on the loan
984
+ // itself, so it must not stop the reading countdown. Only clear the
985
+ // token poller so it can be retried.
986
+ window?.IALendingIntervals?.clearTokenPoller();
987
+
988
+ // The loan itself is fine — just this token refresh failed — but
989
+ // the action bar must reflect that it isn't safely browsable right
990
+ // now rather than silently doing nothing.
991
+ this.lendingStatus = {
906
992
  ...this.lendingStatus,
907
993
  user_has_browsed: false,
908
994
  available_to_browse: true,
909
995
  };
910
- this.lendingStatus = currStatus;
911
- }
912
996
 
913
- // update action bar state if book is not available to browse or borrow.
914
- if (errorMsg && errorMsg.match(/not available to borrow/gm)) {
915
- let currStatus = this.lendingStatus;
916
- if (action === 'browse_book') {
917
- currStatus = {
918
- ...this.lendingStatus,
919
- available_to_browse: false,
920
- };
921
- } else if (action === 'borrow_book') {
922
- currStatus = {
923
- ...this.lendingStatus,
924
- available_to_borrow: false,
925
- };
997
+ // By the time this handler runs, LoanTokenPoller has already
998
+ // exhausted its retries for the transient datanode-propagation
999
+ // race (or the error wasn't that in the first place) — this is a
1000
+ // genuine failure, so let the patron know instead of doing this
1001
+ // silently. showErrorModal has dedicated create_token messaging
1002
+ // (refresh button + support email).
1003
+ if (errorMsg) this.showErrorModal(errorMsg, action);
1004
+ } else if (action === 'renew_loan') {
1005
+ window?.IALendingIntervals?.clearAll();
1006
+ this.loanRenewInProgress = false;
1007
+ this.recoveringFromLoanExpiry = false;
1008
+
1009
+ // The loan was NOT actually renewed — reflect that immediately
1010
+ // (show Borrow, and clear the stale timer value) instead of leaving
1011
+ // the action bar showing "Return now" with a frozen countdown from
1012
+ // before the failed attempt, which falsely implies the book is
1013
+ // still actively being read.
1014
+ this.lendingStatus = {
1015
+ ...this.lendingStatus,
1016
+ user_has_browsed: false,
1017
+ available_to_browse: true,
1018
+ secondsLeftOnLoan: 0,
1019
+ };
1020
+
1021
+ // Show the real error and refresh the page on dismissal (the
1022
+ // modal's Okay button) so the client picks up whatever the server
1023
+ // now authoritatively considers true — matching on "not available"
1024
+ // to guess the reason was fragile since the server can return any
1025
+ // message (e.g. a lending limit).
1026
+ this.showLoanUnavailableModal(errorMsg);
1027
+ } else {
1028
+ // Every other action failure genuinely affects loan state.
1029
+ window?.IALendingIntervals?.clearAll();
1030
+
1031
+ if (!errorMsg) return;
1032
+
1033
+ this.showErrorModal(errorMsg, action);
1034
+
1035
+ // update action bar state if book is not available to browse or borrow.
1036
+ if (errorMsg.match(/not available to borrow/gm)) {
1037
+ if (action === 'browse_book') {
1038
+ this.lendingStatus = { ...this.lendingStatus, available_to_browse: false };
1039
+ } else if (action === 'borrow_book') {
1040
+ this.lendingStatus = { ...this.lendingStatus, available_to_borrow: false };
1041
+ }
926
1042
  }
927
- this.lendingStatus = currStatus;
928
1043
  }
929
1044
  }
930
1045