nodebb-plugin-calendar-onekite 12.0.2 → 12.0.4

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/lib/admin.js CHANGED
@@ -202,13 +202,40 @@ admin.purgeByYear = async function (req, res) {
202
202
  const startTs = new Date(Date.UTC(y, 0, 1)).getTime();
203
203
  const endTs = new Date(Date.UTC(y + 1, 0, 1)).getTime() - 1;
204
204
 
205
- const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 100000);
206
- let count = 0;
207
- for (const rid of ids) {
208
- await dbLayer.removeReservation(rid);
209
- count++;
205
+ const ids = await dbLayer.listReservationIdsByStartRange(startTs, endTs, 200000);
206
+ const ts = Date.now();
207
+ let removed = 0;
208
+ let keptForAccounting = 0;
209
+
210
+ // Bulk fetch to avoid one-by-one DB reads.
211
+ const chunkSize = 500;
212
+ for (let i = 0; i < ids.length; i += chunkSize) {
213
+ const chunk = ids.slice(i, i + chunkSize);
214
+ const objs = await dbLayer.getReservations(chunk);
215
+ for (let j = 0; j < chunk.length; j++) {
216
+ const rid = chunk[j];
217
+ const r = objs[j];
218
+ if (!r) {
219
+ // Ensure index doesn't keep dangling ids
220
+ try { await dbLayer.removeReservation(rid); } catch (e) {}
221
+ removed++;
222
+ continue;
223
+ }
224
+
225
+ // IMPORTANT: Do not purge accounting. If the reservation was paid,
226
+ // keep the object for exports and mark it as hidden from the calendar.
227
+ if (String(r.status) === 'paid' || r.paidAt) {
228
+ r.calendarPurgedAt = ts;
229
+ await dbLayer.saveReservation(r);
230
+ keptForAccounting++;
231
+ } else {
232
+ await dbLayer.removeReservation(rid);
233
+ removed++;
234
+ }
235
+ }
210
236
  }
211
- res.json({ ok: true, removed: count });
237
+
238
+ res.json({ ok: true, removed, keptForAccounting });
212
239
  };
213
240
 
214
241
  admin.purgeSpecialEventsByYear = async function (req, res) {
package/lib/api.js CHANGED
@@ -153,9 +153,17 @@ api.getEvents = async function (req, res) {
153
153
  const wideStart = Math.max(0, startTs - 366 * 24 * 3600 * 1000);
154
154
  const ids = await dbLayer.listReservationIdsByStartRange(wideStart, endTs, 5000);
155
155
  const out = [];
156
- for (const rid of ids) {
157
- const r = await dbLayer.getReservation(rid);
158
- if (!r) continue;
156
+
157
+ // Bulk fetch to avoid one-by-one DB reads.
158
+ const chunkSize = 500;
159
+ for (let i = 0; i < ids.length; i += chunkSize) {
160
+ const chunk = ids.slice(i, i + chunkSize);
161
+ const objs = await dbLayer.getReservations(chunk);
162
+ for (let j = 0; j < chunk.length; j++) {
163
+ const r = objs[j];
164
+ if (!r) continue;
165
+ // Hidden by year purge (kept for accounting, not shown on calendar)
166
+ if (r.calendarPurgedAt) continue;
159
167
  // Only show active statuses
160
168
  if (!['pending', 'awaiting_payment', 'paid'].includes(r.status)) continue;
161
169
  const rStart = parseInt(r.start, 10);
@@ -192,6 +200,7 @@ api.getEvents = async function (req, res) {
192
200
  }
193
201
  }
194
202
  out.push(minimal);
203
+ }
195
204
  }
196
205
  }
197
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-calendar-onekite",
3
- "version": "12.0.2",
3
+ "version": "12.0.4",
4
4
  "description": "FullCalendar-based equipment reservation workflow with admin approval & HelloAsso payment for NodeBB",
5
5
  "main": "library.js",
6
6
  "license": "MIT",
package/public/client.js CHANGED
@@ -36,6 +36,10 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
36
36
  // interactions or quick re-renders.
37
37
  let isDialogOpen = false;
38
38
 
39
+ // Used to avoid a visible "flash" of events when a live update triggers
40
+ // a refetch during the very first load.
41
+ let hasLoadedEventsOnce = false;
42
+
39
43
  function escapeHtml(str) {
40
44
  return String(str)
41
45
  .replace(/&/g, '&amp;')
@@ -434,6 +438,73 @@ define('forum/calendar-onekite', ['alerts', 'bootbox', 'hooks'], function (alert
434
438
  return await res.json();
435
439
  }
436
440
 
441
+ // Load FullCalendar from CDN when templates are rendered via ajaxify.
442
+ // NodeBB does not reliably execute <script> tags from ajaxified templates,
443
+ // so we must ensure the scripts are present before initialising.
444
+ const FULLCALENDAR_ASSETS = {
445
+ css: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/main.min.css',
446
+ jsMain: 'https://cdn.jsdelivr.net/npm/fullcalendar@latest/index.global.min.js',
447
+ jsLocales: 'https://cdn.jsdelivr.net/npm/@fullcalendar/core@latest/locales-all.global.min.js',
448
+ };
449
+
450
+ function loadCssOnce(href, id) {
451
+ return new Promise((resolve) => {
452
+ try {
453
+ if (id && document.getElementById(id)) return resolve();
454
+ const existing = [...document.querySelectorAll('link[rel="stylesheet"]')].find(l => l.href === href);
455
+ if (existing) return resolve();
456
+ const link = document.createElement('link');
457
+ link.rel = 'stylesheet';
458
+ link.href = href;
459
+ if (id) link.id = id;
460
+ link.onload = () => resolve();
461
+ link.onerror = () => resolve();
462
+ document.head.appendChild(link);
463
+ // resolve even if browser doesn't fire onload for cached assets
464
+ setTimeout(resolve, 300);
465
+ } catch (e) {
466
+ resolve();
467
+ }
468
+ });
469
+ }
470
+
471
+ function loadScriptOnce(src, id) {
472
+ return new Promise((resolve) => {
473
+ try {
474
+ if (id && document.getElementById(id)) return resolve();
475
+ const existing = [...document.querySelectorAll('script')].find(s => s.src === src);
476
+ if (existing) return resolve();
477
+ const s = document.createElement('script');
478
+ if (id) s.id = id;
479
+ s.src = src;
480
+ s.async = true;
481
+ s.onload = () => resolve();
482
+ s.onerror = () => resolve();
483
+ document.head.appendChild(s);
484
+ } catch (e) {
485
+ resolve();
486
+ }
487
+ });
488
+ }
489
+
490
+ async function ensureFullCalendarLoaded(timeoutMs) {
491
+ const started = Date.now();
492
+ if (typeof FullCalendar !== 'undefined') return true;
493
+
494
+ // Inject assets (safe to call multiple times).
495
+ await loadCssOnce(FULLCALENDAR_ASSETS.css, 'onekite-fc-css');
496
+ await loadScriptOnce(FULLCALENDAR_ASSETS.jsMain, 'onekite-fc-js');
497
+ await loadScriptOnce(FULLCALENDAR_ASSETS.jsLocales, 'onekite-fc-locales');
498
+
499
+ // Wait until FullCalendar becomes available.
500
+ const limit = typeof timeoutMs === 'number' ? timeoutMs : 8000;
501
+ while (Date.now() - started < limit) {
502
+ if (typeof FullCalendar !== 'undefined') return true;
503
+ await new Promise(r => setTimeout(r, 50));
504
+ }
505
+ return typeof FullCalendar !== 'undefined';
506
+ }
507
+
437
508
  // Simple in-memory cache for JSON endpoints (used for events prefetch + ETag).
438
509
  const jsonCache = new Map(); // url -> { etag, data, ts }
439
510
 
@@ -887,34 +958,37 @@ function toDatetimeLocalValue(date) {
887
958
 
888
959
  async function init(selector) {
889
960
 
890
- const container = typeof selector === 'string' ? document.querySelector(selector) : selector;
891
- if (!container) return;
892
-
893
- // Prevent double-init (ajaxify + initial load, or return from payment)
894
- if (container.dataset && container.dataset.onekiteCalendarInit === '1') {
895
- // Still refresh the custom button label in case FC rerendered
896
- try { refreshDesktopModeButton(); } catch (e) {}
897
- return;
898
- }
899
- if (container.dataset) container.dataset.onekiteCalendarInit = '1';
961
+ const container = typeof selector === 'string' ? document.querySelector(selector) : selector;
962
+ if (!container) return;
900
963
 
901
- // If a previous instance exists (shouldn't, but happens in some navigation flows), destroy it.
902
- try {
903
- if (window.oneKiteCalendar && typeof window.oneKiteCalendar.destroy === 'function') {
904
- window.oneKiteCalendar.destroy();
905
- }
906
- window.oneKiteCalendar = null;
907
- } catch (e) {}
908
- const el = document.querySelector(selector);
909
- if (!el) {
910
- return;
964
+ // FullCalendar must exist before we can render. When the page is loaded via ajaxify,
965
+ // NodeBB does not consistently execute <script> tags in templates.
966
+ // We therefore load the CDN assets here as a fallback.
967
+ if (typeof FullCalendar === 'undefined') {
968
+ const ok = await ensureFullCalendarLoaded(9000);
969
+ if (!ok) {
970
+ showAlert('error', 'Le calendrier ne peut pas se charger (FullCalendar indisponible)');
971
+ return;
972
+ }
911
973
  }
912
974
 
913
- if (typeof FullCalendar === 'undefined') {
914
- showAlert('error', 'FullCalendar non chargé');
975
+ // Prevent double-init (ajaxify + initial load, or return from payment)
976
+ if (container.dataset && container.dataset.onekiteCalendarInit === '1') {
977
+ try { refreshDesktopModeButton(); } catch (e) {}
915
978
  return;
916
979
  }
917
980
 
981
+ // If a previous instance exists (shouldn't, but happens in some navigation flows), destroy it.
982
+ try {
983
+ if (window.oneKiteCalendar && typeof window.oneKiteCalendar.destroy === 'function') {
984
+ window.oneKiteCalendar.destroy();
985
+ }
986
+ window.oneKiteCalendar = null;
987
+ } catch (e) {}
988
+
989
+ const el = typeof selector === 'string' ? document.querySelector(selector) : container;
990
+ if (!el) return;
991
+
918
992
  const items = await loadItems();
919
993
  const caps = await loadCapabilities().catch(() => ({}));
920
994
  const canCreateSpecial = !!caps.canCreateSpecial;
@@ -1112,10 +1186,15 @@ try {
1112
1186
  }
1113
1187
  const abort = new AbortController();
1114
1188
  window.__onekiteEventsAbort = abort;
1189
+ // Hard timeout so FullCalendar doesn't appear to load forever if the API hangs
1190
+ const timeout = setTimeout(() => {
1191
+ try { abort.abort(); } catch (e) {}
1192
+ }, 15000);
1115
1193
 
1116
1194
  const qs = new URLSearchParams({ start: info.startStr, end: info.endStr });
1117
1195
  const url = `/api/v3/plugins/calendar-onekite/events?${qs.toString()}`;
1118
1196
  const data = await fetchJsonCached(url, { signal: abort.signal });
1197
+ clearTimeout(timeout);
1119
1198
 
1120
1199
  // Prefetch adjacent range (previous/next) for snappier navigation.
1121
1200
  try {
@@ -1159,7 +1238,18 @@ try {
1159
1238
  });
1160
1239
 
1161
1240
  successCallback(mapped);
1241
+ // Mark first successful events load. We use this to prevent a
1242
+ // refetch-on-socket-update from flashing the UI during initial load.
1243
+ hasLoadedEventsOnce = true;
1162
1244
  } catch (e) {
1245
+ try {
1246
+ // Stop the spinner and give the user feedback.
1247
+ successCallback([]);
1248
+ } catch (e2) {}
1249
+ try {
1250
+ const status = (e && (e.status || e.name)) ? String(e.status || e.name) : '';
1251
+ showAlert('error', status === 'AbortError' ? 'Chargement du calendrier trop long (timeout)' : 'Erreur de chargement du calendrier');
1252
+ } catch (e3) {}
1163
1253
  failureCallback(e);
1164
1254
  }
1165
1255
  },
@@ -1689,6 +1779,9 @@ try {
1689
1779
  // Expose for live updates
1690
1780
  try { window.oneKiteCalendar = calendar; } catch (e) {}
1691
1781
 
1782
+ // Mark initialised only after we have a valid FullCalendar instance.
1783
+ try { if (container.dataset) container.dataset.onekiteCalendarInit = '1'; } catch (e) {}
1784
+
1692
1785
  calendar.render();
1693
1786
 
1694
1787
  // Keep the custom button label stable even if FullCalendar rerenders the toolbar
@@ -1824,6 +1917,9 @@ try {
1824
1917
  socket.on('event:calendar-onekite.reservationUpdated', function () {
1825
1918
  try {
1826
1919
  const cal = window.oneKiteCalendar;
1920
+ // Avoid a visible "flash" on first load (e.g. returning from payment
1921
+ // while a live update is emitted right away).
1922
+ if (!hasLoadedEventsOnce) return;
1827
1923
  scheduleRefetch(cal);
1828
1924
  } catch (e) {}
1829
1925
  });