nodebb-plugin-onekite-calendar 2.0.29 → 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,26 @@
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
+
13
+ ## 1.3.26
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.
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).
16
+
17
+ ## 1.3.25
18
+ - Scheduler : correction robuste du mécanisme "send-once" en environnement multi-process (utilise `db.incrObjectField` quand disponible).
19
+ - Fix : évite le cas "réservation expirée/supprimée sans email/Discord" lorsque la DB ne supporte pas correctement les sets/objets utilisés comme garde.
20
+
21
+ ## 1.3.24
22
+ - Multi-process : le scheduler ne démarre pas si NodeBB a `runJobs=false` (évite les exécutions multiples de jobs en cluster).
23
+
3
24
  ## 1.3.23
4
25
  - Scheduler (paiement en attente) : correction de la garde "send-once" pour compatibilité avec tous les adaptateurs DB NodeBB (fallback si `db.setAdd` n'est pas disponible).
5
26
  - Scheduler : les erreurs ne sont plus avalées silencieusement (logs `Scheduler error ...`).
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,15 +7,62 @@ 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) {
16
51
  const v = String(value);
17
52
  if (!v) return false;
18
53
 
54
+ // Best-effort atomic guard across multi-process / multi-instance.
55
+ // incrObjectField is implemented by most NodeBB DB adapters (Redis/Mongo).
56
+ // If it exists, it gives us a reliable "first winner" (value === 1).
57
+ if (typeof db.incrObjectField === 'function') {
58
+ try {
59
+ const n = await db.incrObjectField(key, v);
60
+ return Number(n) === 1;
61
+ } catch (e) {
62
+ // fall through
63
+ }
64
+ }
65
+
19
66
  // Preferred atomic helper (Redis + some adapters)
20
67
  if (typeof db.setAdd === 'function') {
21
68
  try {
@@ -33,29 +80,13 @@ async function addOnce(key, value) {
33
80
  await db.setObjectField(key, v, 1);
34
81
  return true;
35
82
  } catch (e) {
36
- return false;
83
+ // Last resort: allow sending rather than silently doing nothing.
84
+ // This may duplicate emails in edge cases, but avoids "expires with no email".
85
+ return true;
37
86
  }
38
87
  }
39
88
 
40
- function getSetting(settings, key, fallback) {
41
- const v = settings && Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : undefined;
42
- if (v == null || v === '') return fallback;
43
- if (typeof v === 'object' && v && typeof v.value !== 'undefined') return v.value;
44
- return v;
45
- }
46
-
47
- function formatFR(ts) {
48
- const d = new Date(ts);
49
- const dd = String(d.getDate()).padStart(2, '0');
50
- const mm = String(d.getMonth() + 1).padStart(2, '0');
51
- const yyyy = d.getFullYear();
52
- return `${dd}/${mm}/${yyyy}`;
53
- }
54
-
55
- function forumBaseUrl() {
56
- const base = String(nconf.get('url') || '').trim().replace(/\/$/, '');
57
- return base;
58
- }
89
+ const { getSetting, formatFR, forumBaseUrl, normalizeAllowedGroups, normalizeUids, arrayifyNames } = utils;
59
90
 
60
91
  // Resolve group identifiers from ACP (name or slug) and return UIDs.
61
92
  async function getMembersByGroupIdentifier(groupIdentifier) {
@@ -97,30 +128,7 @@ async function getMembersByGroupIdentifier(groupIdentifier) {
97
128
  return Array.isArray(members) ? members : [];
98
129
  }
99
130
 
100
- function normalizeUids(members) {
101
- if (!Array.isArray(members)) return [];
102
- const out = [];
103
- for (const m of members) {
104
- if (Number.isInteger(m)) { out.push(m); continue; }
105
- if (typeof m === 'string' && m.trim() && !Number.isNaN(parseInt(m, 10))) { out.push(parseInt(m, 10)); continue; }
106
- if (m && typeof m === 'object' && (Number.isInteger(m.uid) || (typeof m.uid === 'string' && m.uid.trim()))) {
107
- const u = Number.isInteger(m.uid) ? m.uid : parseInt(m.uid, 10);
108
- if (!Number.isNaN(u)) out.push(u);
109
- }
110
- }
111
- return Array.from(new Set(out)).filter((u) => Number.isInteger(u) && u > 0);
112
- }
113
-
114
- function normalizeAllowedGroups(raw) {
115
- if (!raw) return [];
116
- if (Array.isArray(raw)) {
117
- return raw.map((s) => String(s || '').trim()).filter(Boolean);
118
- }
119
- return String(raw)
120
- .split(',')
121
- .map((s) => String(s || '').trim())
122
- .filter(Boolean);
123
- }
131
+ // normalizeUids/normalizeAllowedGroups are provided by utils
124
132
 
125
133
  async function getValidatorUids(settings) {
126
134
  const out = new Set();
@@ -175,12 +183,14 @@ async function expirePending() {
175
183
  const validatorUids = validatorReminderMins > 0 ? await getValidatorUids(settings) : [];
176
184
 
177
185
  const ids = await dbLayer.listAllReservationIds(5000);
178
- if (!ids || !ids.length) {
179
- return;
180
- }
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);
181
190
 
182
- for (const rid of ids) {
183
- 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];
184
194
  if (!resv || resv.status !== 'pending') {
185
195
  continue;
186
196
  }
@@ -199,7 +209,7 @@ async function expirePending() {
199
209
  await sendEmail('calendar-onekite_validator_reminder', vuid, 'Location matériel - Demande en attente', {
200
210
  rid,
201
211
  requesterUsername,
202
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
212
+ itemNames: arrayifyNames(resv),
203
213
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
204
214
  adminUrl,
205
215
  kind: 'pending',
@@ -224,7 +234,7 @@ async function expirePending() {
224
234
  await sendEmail('calendar-onekite_expired', requesterUid, 'Location matériel - Demande expirée', {
225
235
  uid: requesterUid,
226
236
  username: requesterUsername,
227
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
237
+ itemNames: arrayifyNames(resv),
228
238
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
229
239
  delayMinutes: holdMins,
230
240
  reason,
@@ -238,7 +248,7 @@ async function expirePending() {
238
248
  await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
239
249
  rid,
240
250
  requesterUsername,
241
- itemNames: (Array.isArray(resv.itemNames) ? resv.itemNames : (resv.itemName ? [resv.itemName] : [])).filter(Boolean),
251
+ itemNames: arrayifyNames(resv),
242
252
  dateRange: `Du ${formatFR(resv.start)} au ${formatFR(resv.end)}`,
243
253
  adminUrl,
244
254
  reason,
@@ -294,21 +304,16 @@ async function processAwaitingPayment() {
294
304
  }
295
305
  }
296
306
 
297
- function formatFR(ts) {
298
- const d = new Date(ts);
299
- const dd = String(d.getDate()).padStart(2, '0');
300
- const mm = String(d.getMonth() + 1).padStart(2, '0');
301
- const yyyy = d.getFullYear();
302
- return `${dd}/${mm}/${yyyy}`;
303
- }
304
-
305
307
  const adminUrl = (() => {
306
308
  const base = forumBaseUrl();
307
309
  return base ? `${base}/admin/plugins/calendar-onekite` : '/admin/plugins/calendar-onekite';
308
310
  })();
309
311
 
310
- for (const rid of ids) {
311
- 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];
312
317
  if (!r || r.status !== 'awaiting_payment') continue;
313
318
 
314
319
  const approvedAt = parseInt(r.approvedAt || r.validatedAt || 0, 10) || 0;
@@ -335,8 +340,8 @@ async function processAwaitingPayment() {
335
340
  await sendEmail('calendar-onekite_reminder', toUid, 'Location matériel - Rappel', {
336
341
  uid: toUid,
337
342
  username: (u && u.username) ? u.username : '',
338
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
339
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
343
+ itemName: arrayifyNames(r).join(', '),
344
+ itemNames: arrayifyNames(r),
340
345
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
341
346
  paymentUrl: r.paymentUrl || '',
342
347
  delayMinutes: holdMins,
@@ -369,8 +374,8 @@ async function processAwaitingPayment() {
369
374
  await sendEmail('calendar-onekite_expired', toUid, 'Location matériel - Demande expirée', {
370
375
  uid: toUid,
371
376
  username: (u && u.username) ? u.username : '',
372
- itemName: (Array.isArray(r.itemNames) ? r.itemNames.join(', ') : (r.itemName || '')),
373
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])),
377
+ itemName: arrayifyNames(r).join(', '),
378
+ itemNames: arrayifyNames(r),
374
379
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
375
380
  delayMinutes: holdMins,
376
381
  reason: 'Paiement non reçu dans le temps imparti.',
@@ -384,7 +389,7 @@ async function processAwaitingPayment() {
384
389
  await sendEmail('calendar-onekite_validator_expired', vuid, 'Location matériel - Demande expirée', {
385
390
  rid: r.rid || rid,
386
391
  requesterUsername: (u && u.username) ? u.username : (r.username || ''),
387
- itemNames: (Array.isArray(r.itemNames) ? r.itemNames : (r.itemName ? [r.itemName] : [])).filter(Boolean),
392
+ itemNames: arrayifyNames(r),
388
393
  dateRange: `Du ${formatFR(r.start)} au ${formatFR(r.end)}`,
389
394
  adminUrl,
390
395
  reason: 'Paiement non reçu dans le temps imparti.',
@@ -421,17 +426,22 @@ async function processAwaitingPayment() {
421
426
  function start() {
422
427
  const runJobs = nconf.get('runJobs');
423
428
  if (runJobs === false || runJobs === 'false' || runJobs === 0 || runJobs === '0') {
429
+ // eslint-disable-next-line no-console
430
+ console.info('[calendar-onekite] Scheduler disabled (runJobs=false)');
424
431
  return;
425
432
  }
426
433
  if (timer) return;
434
+ // eslint-disable-next-line no-console
435
+ console.info('[calendar-onekite] Scheduler enabled');
427
436
  timer = setInterval(() => {
428
- expirePending().catch((err) => {
429
- // eslint-disable-next-line no-console
430
- console.warn('[calendar-onekite] Scheduler error in expirePending', err && err.message ? err.message : err);
431
- });
432
- 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) => {
433
443
  // eslint-disable-next-line no-console
434
- 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);
435
445
  });
436
446
  }, 60 * 1000);
437
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.29",
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.29"
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) || {});