nodebb-plugin-onekite-calendar 2.0.30 → 2.0.31

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.3.28
4
+ - Perf (temps réel) : debounce global des `refetchEvents()` (calendrier + widget) et **skip** quand l’onglet est masqué (refetch unique au retour de visibilité).
5
+ - Perf/Robustesse (multi-instance) : ajout d’un **lock distribué Redis** sur le tick scheduler (un seul runner exécute le cycle à un instant donné).
6
+ - Perf (API events) : cache Redis très court (**TTL 2s**) par utilisateur/permissions sur l’endpoint events pour absorber les rafales.
7
+
8
+ ## 1.3.27
9
+ - Refactor : ajout d'un module `lib/utils.js` (format dates FR, lecture settings ACP, helpers listes/UIDs) pour supprimer les duplications.
10
+ - Cleanup : suppression d'un doublon de fonctions dans `helloassoWebhook.js` (formatFR + getReservationIdFromPayload).
11
+ - Perf : scheduler optimisé (chargement **batch** des réservations via `getReservations()` au lieu d'un `getReservation()` par id).
12
+
3
13
  ## 1.3.26
4
14
  - Scheduler : garde "send-once" rendue **atomique** en multi-instance (priorité à `db.incrObjectField(key, rid)`), pour garantir qu'au moins une instance envoie les emails/Discord avant suppression.
5
15
  - Scheduler : en cas d'erreur DB sur la garde, on **privilégie l'envoi** plutôt que "expire sans email" (dernier recours : autoriser l'envoi même si la garde échoue).
package/lib/admin.js CHANGED
@@ -3,20 +3,7 @@
3
3
  const meta = require.main.require('./src/meta');
4
4
  const user = require.main.require('./src/user');
5
5
  const emailer = require.main.require('./src/emailer');
6
- const nconf = require.main.require('nconf');
7
-
8
- function forumBaseUrl() {
9
- const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
10
- return base;
11
- }
12
-
13
- function formatFR(tsOrIso) {
14
- const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
15
- const dd = String(d.getDate()).padStart(2, '0');
16
- const mm = String(d.getMonth() + 1).padStart(2, '0');
17
- const yyyy = d.getFullYear();
18
- return `${dd}/${mm}/${yyyy}`;
19
- }
6
+ const { forumBaseUrl, formatFR } = require('./utils');
20
7
 
21
8
  function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
22
9
  const base = String(baseLabel || 'Réservation matériel Onekite').trim();
package/lib/api.js CHANGED
@@ -9,8 +9,11 @@ const user = require.main.require('./src/user');
9
9
  const groups = require.main.require('./src/groups');
10
10
  const db = require.main.require('./src/database');
11
11
  const logger = require.main.require('./src/logger');
12
+ let cache = null;
13
+ try { cache = require.main.require('./src/cache'); } catch (e) { cache = null; }
12
14
 
13
15
  const dbLayer = require('./db');
16
+ const { formatFR } = require('./utils');
14
17
 
15
18
  // Resolve group identifiers from ACP.
16
19
  // Admins may enter a group *name* ("Test") or a *slug* ("onekite-ffvl-2026").
@@ -177,14 +180,6 @@ function overlap(aStart, aEnd, bStart, bEnd) {
177
180
  }
178
181
 
179
182
 
180
- function formatFR(tsOrIso) {
181
- const d = new Date(typeof tsOrIso === 'string' && /^[0-9]+$/.test(tsOrIso) ? parseInt(tsOrIso, 10) : tsOrIso);
182
- const dd = String(d.getDate()).padStart(2, '0');
183
- const mm = String(d.getMonth() + 1).padStart(2, '0');
184
- const yyyy = d.getFullYear();
185
- return `${dd}/${mm}/${yyyy}`;
186
- }
187
-
188
183
  function buildHelloAssoItemName(baseLabel, itemNames, start, end) {
189
184
  const base = String(baseLabel || '').trim();
190
185
  const items = Array.isArray(itemNames) ? itemNames.map((s) => String(s || '').trim()).filter(Boolean) : [];
@@ -484,6 +479,42 @@ function computeEtag(payload) {
484
479
  return `W/"${hash}"`;
485
480
  }
486
481
 
482
+ async function cacheGet(key) {
483
+ try {
484
+ if (!cache) return null;
485
+ if (typeof cache.get === 'function') {
486
+ if (cache.get.length >= 2) {
487
+ return await new Promise((resolve) => {
488
+ cache.get(key, (err, data) => resolve(err ? null : data));
489
+ });
490
+ }
491
+ return await cache.get(key);
492
+ }
493
+ } catch (e) {}
494
+ return null;
495
+ }
496
+
497
+ async function cacheSet(key, value, ttlSeconds) {
498
+ try {
499
+ if (!cache) return;
500
+ const ttl = Math.max(1, parseInt(ttlSeconds, 10) || 2);
501
+ if (typeof cache.set === 'function') {
502
+ if (cache.set.length >= 4) {
503
+ await new Promise((resolve) => {
504
+ cache.set(key, value, ttl, (err) => resolve(!err));
505
+ });
506
+ return;
507
+ }
508
+ // Some NodeBB versions accept an options object.
509
+ try {
510
+ await cache.set(key, value, { ttl });
511
+ return;
512
+ } catch (e) {}
513
+ await cache.set(key, value, ttl);
514
+ }
515
+ } catch (e) {}
516
+ }
517
+
487
518
  api.getEvents = async function (req, res) {
488
519
  const qStartRaw = (req && req.query && req.query.start !== undefined) ? String(req.query.start).trim() : '';
489
520
  const qEndRaw = (req && req.query && req.query.end !== undefined) ? String(req.query.end).trim() : '';
@@ -503,6 +534,22 @@ api.getEvents = async function (req, res) {
503
534
  const canSpecialCreate = req.uid ? await canCreateSpecial(req.uid, settings) : false;
504
535
  const canSpecialDelete = req.uid ? await canDeleteSpecial(req.uid, settings) : false;
505
536
 
537
+ // Ultra-short Redis-backed cache (2s) to absorb bursts and reduce redundant renders.
538
+ // Keyed per uid + permissions because payload can include private fields.
539
+ const uidKey = req.uid ? String(req.uid) : '0';
540
+ const cacheKey = `calendar-onekite:events:${uidKey}:${canMod ? 'm' : 'u'}:${widgetMode ? 'w' : 'p'}:${qStartYmd || startTs}:${qEndYmd || endTs}`;
541
+ try {
542
+ const cached = await cacheGet(cacheKey);
543
+ if (cached && cached.payload && cached.etag) {
544
+ res.setHeader('ETag', cached.etag);
545
+ res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
546
+ if (String(req.headers['if-none-match'] || '') === String(cached.etag)) {
547
+ return res.status(304).end();
548
+ }
549
+ return res.json(cached.payload);
550
+ }
551
+ } catch (e) {}
552
+
506
553
  // Fetch a wider window because an event can start before the query range
507
554
  // and still overlap.
508
555
  const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
@@ -641,6 +688,12 @@ api.getEvents = async function (req, res) {
641
688
  });
642
689
 
643
690
  const etag = computeEtag(out);
691
+
692
+ // Store in cache for a very short period (burst absorption).
693
+ try {
694
+ await cacheSet(cacheKey, { etag, payload: out }, 2);
695
+ } catch (e) {}
696
+
644
697
  res.setHeader('ETag', etag);
645
698
  res.setHeader('Cache-Control', 'private, max-age=0, must-revalidate');
646
699
  if (String(req.headers['if-none-match'] || '') === etag) {
@@ -10,6 +10,7 @@ const dbLayer = require('./db');
10
10
  const helloasso = require('./helloasso');
11
11
  const discord = require('./discord');
12
12
  const realtime = require('./realtime');
13
+ const { formatFR } = require('./utils');
13
14
 
14
15
  async function auditLog(action, actorUid, payload) {
15
16
  try {
@@ -49,14 +50,6 @@ async function sendEmail(template, uid, subject, data) {
49
50
  }
50
51
  }
51
52
 
52
- function formatFR(tsOrIso) {
53
- const d = new Date(tsOrIso);
54
- const dd = String(d.getDate()).padStart(2, '0');
55
- const mm = String(d.getMonth() + 1).padStart(2, '0');
56
- const yyyy = d.getFullYear();
57
- return `${dd}/${mm}/${yyyy}`;
58
- }
59
-
60
53
  function getReservationIdFromPayload(payload) {
61
54
  try {
62
55
  const data = payload && payload.data ? payload.data : null;
@@ -186,27 +179,6 @@ function isConfirmedPayment(payload) {
186
179
  }
187
180
  }
188
181
 
189
- function formatFR(tsOrIso) {
190
- const d = new Date(tsOrIso);
191
- const dd = String(d.getDate()).padStart(2, '0');
192
- const mm = String(d.getMonth() + 1).padStart(2, '0');
193
- const yyyy = d.getFullYear();
194
- return `${dd}/${mm}/${yyyy}`;
195
- }
196
-
197
- function getReservationIdFromPayload(payload) {
198
- try {
199
- const m = payload && payload.data ? payload.data.meta : null;
200
- if (!m) return null;
201
- if (typeof m === 'object' && m.reservationId) return String(m.reservationId);
202
- if (typeof m === 'object' && m.reservationID) return String(m.reservationID);
203
- return null;
204
- } catch (e) {
205
- return null;
206
- }
207
- }
208
-
209
-
210
182
  function getCheckoutIntentIdFromPayload(payload) {
211
183
  try {
212
184
  const data = payload && payload.data ? payload.data : null;
package/lib/scheduler.js CHANGED
@@ -7,9 +7,44 @@ const discord = require('./discord');
7
7
  const realtime = require('./realtime');
8
8
  const nconf = require.main.require('nconf');
9
9
  const groups = require.main.require('./src/groups');
10
+ const utils = require('./utils');
11
+ let redis = null;
12
+ try { redis = require.main.require('./src/redis'); } catch (e) { redis = null; }
10
13
 
11
14
  let timer = null;
12
15
 
16
+ // Distributed lock (Redis) to avoid running the scheduler on multiple NodeBB instances.
17
+ // Safe no-op if Redis is not available.
18
+ async function acquireSchedulerLock(ttlMs) {
19
+ const key = 'calendar-onekite:scheduler:lock';
20
+ const value = `${process.pid}:${Date.now()}`;
21
+ const ttl = Math.max(1000, parseInt(ttlMs, 10) || 55000);
22
+
23
+ // Prefer Redis SET NX PX.
24
+ try {
25
+ const client = redis && (redis.client || redis);
26
+ if (client && typeof client.set === 'function') {
27
+ const res = await new Promise((resolve, reject) => {
28
+ // node_redis style
29
+ client.set(key, value, 'NX', 'PX', ttl, (err, out) => {
30
+ if (err) return reject(err);
31
+ resolve(out);
32
+ });
33
+ });
34
+ return String(res || '').toUpperCase() === 'OK';
35
+ }
36
+ } catch (e) {
37
+ // ignore and fall back
38
+ }
39
+
40
+ // Fallback: in-process lock only.
41
+ if (global.__onekiteSchedulerInProcessLockUntil && Date.now() < global.__onekiteSchedulerInProcessLockUntil) {
42
+ return false;
43
+ }
44
+ global.__onekiteSchedulerInProcessLockUntil = Date.now() + ttl;
45
+ return true;
46
+ }
47
+
13
48
  // Some NodeBB database adapters don't expose setAdd/setRemove helpers.
14
49
  // Use a safe "add once" guard to avoid crashing the scheduler.
15
50
  async function addOnce(key, value) {
@@ -51,25 +86,7 @@ async function addOnce(key, value) {
51
86
  }
52
87
  }
53
88
 
54
- function getSetting(settings, key, fallback) {
55
- const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
56
- if (v == null || v === '') return fallback;
57
- if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
58
- return v;
59
- }
60
-
61
- function formatFR(ts) {
62
- const d = new Date(ts);
63
- const dd = String(d.getDate()).padStart(2, '0');
64
- const mm = String(d.getMonth() + 1).padStart(2, '0');
65
- const yyyy = d.getFullYear();
66
- return `${dd}/${mm}/${yyyy}`;
67
- }
68
-
69
- function forumBaseUrl() {
70
- const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
71
- return base;
72
- }
89
+ const { getSetting, formatFR, forumBaseUrl, normalizeAllowedGroups, normalizeUids, arrayifyNames } = utils;
73
90
 
74
91
  // Resolve group identifiers from ACP (name or slug) and return UIDs.
75
92
  async function getMembersByGroupIdentifier(groupIdentifier) {
@@ -111,30 +128,7 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
111
128
  return Array.isArray(members) ? members : [];
112
129
  }
113
130
 
114
- function normalizeUids(members) {
115
- if (!Array.isArray(members)) return [];
116
- const out = [];
117
- for (const m of members) {
118
- if (Number.isInteger(m)) { out.push(m); continue; }
119
- if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
120
- if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
121
- const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
122
- if (!Number.isNaN(u)) out.push(u);
123
- }
124
- }
125
- return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
126
- }
127
-
128
- function normalizeAllowedGroups(raw) {
129
- if (!raw) return [];
130
- if (Array.isArray(raw)) {
131
- return raw.map((s) => String(s || '').trim()).filter(Boolean);
132
- }
133
- return String(raw)
134
- .split(',')
135
- .map((s) => String(s || '').trim())
136
- .filter(Boolean);
137
- }
131
+ // normalizeUids/normalizeAllowedGroups are provided by utils
138
132
 
139
133
  async function getValidatorUids(settings) {
140
134
  const out = new Set();
@@ -189,12 +183,14 @@ async function expirePending() {
189
183
  const validatorUids = validatorReminderMins > 0 ? await getValidatorUids(settings) : [];
190
184
 
191
185
  const ids = await dbLayer.listAllReservationIds(5000);
192
- if (!ids || !ids.length) {
193
- return;
194
- }
186
+ if (!ids || !ids.length) return;
187
+
188
+ // Batch load to avoid N+1 queries on Mongo/Redis adapters.
189
+ const reservations = await dbLayer.getReservations(ids);
195
190
 
196
- for (const rid of ids) {
197
- const resv = await dbLayer.getReservation(rid);
191
+ for (let i = 0; i < ids.length; i += 1) {
192
+ const rid = ids[i];
193
+ const resv = reservations[i];
198
194
  if (!resv || resv.status !== 'pending') {
199
195
  continue;
200
196
  }
@@ -213,7 +209,7 @@ async function expirePending() {
213
209
  await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
214
210
  rid,
215
211
  requesterUsername,
216
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
212
+ itemNames: arrayifyNames(resv),
217
213
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
218
214
  adminUrl,
219
215
  kind: 'pending',
@@ -238,7 +234,7 @@ async function expirePending() {
238
234
  await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
239
235
  uid: requesterUid,
240
236
  username: requesterUsername,
241
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
237
+ itemNames: arrayifyNames(resv),
242
238
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
243
239
  delayMinutes: holdMins,
244
240
  reason,
@@ -252,7 +248,7 @@ async function expirePending() {
252
248
  await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
253
249
  rid,
254
250
  requesterUsername,
255
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
251
+ itemNames: arrayifyNames(resv),
256
252
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
257
253
  adminUrl,
258
254
  reason,
@@ -308,21 +304,16 @@ async function processAwaitingPayment() {
308
304
  }
309
305
  }
310
306
 
311
- function formatFR(ts) {
312
- const d = new Date(ts);
313
- const dd = String(d.getDate()).padStart(2, '0');
314
- const mm = String(d.getMonth() + 1).padStart(2, '0');
315
- const yyyy = d.getFullYear();
316
- return `${dd}/${mm}/${yyyy}`;
317
- }
318
-
319
307
  const adminUrl = (() => {
320
308
  const base = forumBaseUrl();
321
309
  return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
322
310
  })();
323
311
 
324
- for (const rid of ids) {
325
- const r = await dbLayer.getReservation(rid);
312
+ const reservations = await dbLayer.getReservations(ids);
313
+
314
+ for (let i = 0; i < ids.length; i += 1) {
315
+ const rid = ids[i];
316
+ const r = reservations[i];
326
317
  if (!r || r.status !== 'awaiting_payment') continue;
327
318
 
328
319
  const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
@@ -349,8 +340,8 @@ async function processAwaitingPayment() {
349
340
  await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
350
341
  uid: toUid,
351
342
  username: (u && u.username) ? u.username : '',
352
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
353
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
343
+ itemName: arrayifyNames(r).join(', '),
344
+ itemNames: arrayifyNames(r),
354
345
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
355
346
  paymentUrl: r.paymentUrl || '',
356
347
  delayMinutes: holdMins,
@@ -383,8 +374,8 @@ async function processAwaitingPayment() {
383
374
  await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
384
375
  uid: toUid,
385
376
  username: (u && u.username) ? u.username : '',
386
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
387
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
377
+ itemName: arrayifyNames(r).join(', '),
378
+ itemNames: arrayifyNames(r),
388
379
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
389
380
  delayMinutes: holdMins,
390
381
  reason: 'Paiement non reçu dans le temps imparti.',
@@ -398,7 +389,7 @@ async function processAwaitingPayment() {
398
389
  await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
399
390
  rid: r.rid || rid,
400
391
  requesterUsername: (u && u.username) ? u.username : (r.username || ''),
401
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
392
+ itemNames: arrayifyNames(r),
402
393
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
403
394
  adminUrl,
404
395
  reason: 'Paiement non reçu dans le temps imparti.',
@@ -443,13 +434,14 @@ function start() {
443
434
  // eslint-disable-next-line no-console
444
435
  console.info('[calendar-onekite] Scheduler enabled');
445
436
  timer = setInterval(() => {
446
- expirePending().catch((err) => {
447
- // eslint-disable-next-line no-console
448
- console.warn('[calendar-onekite] Scheduler error in expirePending', err && err.message ? err.message : err);
449
- });
450
- processAwaitingPayment().catch((err) => {
437
+ (async () => {
438
+ const locked = await acquireSchedulerLock(55 * 1000);
439
+ if (!locked) return;
440
+ await expirePending();
441
+ await processAwaitingPayment();
442
+ })().catch((err) => {
451
443
  // eslint-disable-next-line no-console
452
- console.warn('[calendar-onekite] Scheduler error in processAwaitingPayment', err && err.message ? err.message : err);
444
+ console.warn('[calendar-onekite] Scheduler tick error', err && err.message ? err.message : err);
453
445
  });
454
446
  }, 60 * 1000);
455
447
  }
package/lib/utils.js ADDED
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ const nconf = require.main.require('nconf');
4
+
5
+ function getSetting(settings, key, fallback) {
6
+ const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
7
+ if (v == null || v === '') return fallback;
8
+ if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
9
+ return v;
10
+ }
11
+
12
+ function formatFR(tsOrIso) {
13
+ const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
14
+ const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
15
+ const dd = String(d.getDate()).padStart(2, '0');
16
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
17
+ const yyyy = d.getFullYear();
18
+ return `${dd}/${mm}/${yyyy}`;
19
+ }
20
+
21
+ function formatFRShort(tsOrIso) {
22
+ const ts = (typeof tsOrIso === 'string' && tsOrIso.includes('-')) ? Date.parse(tsOrIso) : Number(tsOrIso);
23
+ const d = new Date(Number.isFinite(ts) ? ts : tsOrIso);
24
+ const dd = String(d.getDate()).padStart(2, '0');
25
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
26
+ return `${dd}/${mm}`;
27
+ }
28
+
29
+ function forumBaseUrl() {
30
+ return String(nconf.get('url') || '').trim().replace(/\/$/, '');
31
+ }
32
+
33
+ function normalizeAllowedGroups(raw) {
34
+ if (!raw) return [];
35
+ if (Array.isArray(raw)) {
36
+ return raw.map((s) => String(s || '').trim()).filter(Boolean);
37
+ }
38
+ return String(raw)
39
+ .split(',')
40
+ .map((s) => String(s || '').trim())
41
+ .filter(Boolean);
42
+ }
43
+
44
+ function normalizeUids(members) {
45
+ if (!Array.isArray(members)) return [];
46
+ const out = [];
47
+ for (const m of members) {
48
+ if (Number.isInteger(m)) { out.push(m); continue; }
49
+ if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
50
+ if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
51
+ const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
52
+ if (!Number.isNaN(u)) out.push(u);
53
+ }
54
+ }
55
+ return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
56
+ }
57
+
58
+ function arrayifyNames(obj) {
59
+ return (Array.isArray(obj && obj.itemNames) ? obj.itemNames : (obj && obj.itemName ? [obj.itemName] : [])).filter(Boolean);
60
+ }
61
+
62
+ module.exports = {
63
+ getSetting,
64
+ formatFR,
65
+ formatFRShort,
66
+ forumBaseUrl,
67
+ normalizeAllowedGroups,
68
+ normalizeUids,
69
+ arrayifyNames,
70
+ };
package/lib/widgets.js CHANGED
@@ -376,13 +376,20 @@ dateClick: function() { window.location.href = calUrl; },
376
376
  let tRefetch = null;
377
377
  const refetch = function () {
378
378
  try {
379
+ // Skip work while tab is hidden; refetch once when visible again.
380
+ try {
381
+ if (typeof document !== 'undefined' && document && document.hidden) {
382
+ window.__oneKiteWidgetNeedsRefetch = true;
383
+ return;
384
+ }
385
+ } catch (e) {}
379
386
  if (tRefetch) return;
380
387
  tRefetch = setTimeout(() => {
381
388
  tRefetch = null;
382
389
  try {
383
390
  calendar.refetchEvents();
384
391
  } catch (e) {}
385
- }, 200);
392
+ }, 450);
386
393
  } catch (e) {}
387
394
  };
388
395
 
@@ -402,6 +409,21 @@ dateClick: function() { window.location.href = calUrl; },
402
409
  };
403
410
  socket.on('event:calendar-onekite.calendarUpdated', triggerAll);
404
411
  socket.on('event:calendar-onekite.reservationUpdated', triggerAll);
412
+
413
+ // If updates occurred while hidden, refetch once when visible again.
414
+ try {
415
+ if (!window.__oneKiteWidgetVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
416
+ window.__oneKiteWidgetVisibilityBound = true;
417
+ document.addEventListener('visibilitychange', () => {
418
+ try {
419
+ if (!document.hidden && window.__oneKiteWidgetNeedsRefetch) {
420
+ window.__oneKiteWidgetNeedsRefetch = false;
421
+ triggerAll();
422
+ }
423
+ } catch (e) {}
424
+ }, { passive: true });
425
+ }
426
+ } catch (e) {}
405
427
  }
406
428
  } catch (e) {}
407
429
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.30",
3
+ "version": "2.0.31",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
@@ -533,13 +533,40 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
533
533
  function scheduleRefetch(cal) {
534
534
  try {
535
535
  if (!cal || typeof cal.refetchEvents !== 'function') return;
536
+
537
+ // Avoid refetching while the tab is hidden; do it once when visible again.
538
+ try {
539
+ if (typeof document !== 'undefined' && document && document.hidden) {
540
+ window.__onekiteNeedsRefetch = true;
541
+ return;
542
+ }
543
+ } catch (e) {}
544
+
536
545
  clearTimeout(window.__onekiteRefetchTimer);
537
546
  window.__onekiteRefetchTimer = setTimeout(() => {
538
547
  try { cal.refetchEvents(); } catch (e) {}
539
- }, 150);
548
+ }, 450);
540
549
  } catch (e) {}
541
550
  }
542
551
 
552
+ // If updates occurred while hidden, refetch once when visible again.
553
+ try {
554
+ if (!window.__onekiteVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
555
+ window.__onekiteVisibilityBound = true;
556
+ document.addEventListener('visibilitychange', () => {
557
+ try {
558
+ if (!document.hidden && window.__onekiteNeedsRefetch) {
559
+ window.__onekiteNeedsRefetch = false;
560
+ const cal = window.oneKiteCalendar;
561
+ if (cal && typeof cal.refetchEvents === 'function') {
562
+ scheduleRefetch(cal);
563
+ }
564
+ }
565
+ } catch (e) {}
566
+ }, { passive: true });
567
+ }
568
+ } catch (e) {}
569
+
543
570
  async function fetchJsonCached(url, opts) {
544
571
  const cached = jsonCache.get(url);
545
572
  const headers = Object.assign({}, (opts && opts.headers) || {});
package/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.30"
42
+ "version": "2.0.31"
43
43
  }
package/public/client.js CHANGED
@@ -543,13 +543,40 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
543
543
  function scheduleRefetch(cal) {
544
544
  try {
545
545
  if (!cal || typeof cal.refetchEvents !== 'function') return;
546
+
547
+ // Avoid refetching while the tab is hidden; do it once when visible again.
548
+ try {
549
+ if (typeof document !== 'undefined' && document && document.hidden) {
550
+ window.__onekiteNeedsRefetch = true;
551
+ return;
552
+ }
553
+ } catch (e) {}
554
+
546
555
  clearTimeout(window.__onekiteRefetchTimer);
547
556
  window.__onekiteRefetchTimer = setTimeout(() => {
548
557
  try { cal.refetchEvents(); } catch (e) {}
549
- }, 150);
558
+ }, 450);
550
559
  } catch (e) {}
551
560
  }
552
561
 
562
+ // If updates occurred while hidden, refetch once when visible again.
563
+ try {
564
+ if (!window.__onekiteVisibilityBound && typeof document !== 'undefined' && document && typeof document.addEventListener === 'function') {
565
+ window.__onekiteVisibilityBound = true;
566
+ document.addEventListener('visibilitychange', () => {
567
+ try {
568
+ if (!document.hidden && window.__onekiteNeedsRefetch) {
569
+ window.__onekiteNeedsRefetch = false;
570
+ const cal = window.oneKiteCalendar;
571
+ if (cal && typeof cal.refetchEvents === 'function') {
572
+ scheduleRefetch(cal);
573
+ }
574
+ }
575
+ } catch (e) {}
576
+ }, { passive: true });
577
+ }
578
+ } catch (e) {}
579
+
553
580
  async function fetchJsonCached(url, opts) {
554
581
  const cached = jsonCache.get(url);
555
582
  const headers = Object.assign({}, (opts && opts.headers) || {});