nodebb-plugin-onekite-calendar 2.0.1 → 2.0.3

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,16 @@
1
1
  # Changelog – calendar-onekite
2
2
 
3
+ ## 1.2.10
4
+ - HelloAsso : calcul des jours 100% fiable (différence en jours calendaires Y/M/J, sans dépendance aux heures/au fuseau/DST)
5
+ - FullCalendar : endDate traitée comme exclusive partout (UI + checkout)
6
+ - HelloAsso : montant du checkout recalculé côté serveur à partir du catalogue (prix/jour × nbJours)
7
+
8
+ ## 1.2.9
9
+ - Modale réservation : retour du grisé des matériels indisponibles (API events expose à nouveau itemIds)
10
+
11
+ ## 1.2.8
12
+ - Popup réservation : correction « Durée rapide » (la période envoyée correspond bien à la durée sélectionnée)
13
+
3
14
  ## 1.2.7
4
15
  - UI : suppression du bouton flottant mobile « + Réserver »
5
16
  - Client : nettoyage du code associé (suppression du bloc FAB)
package/lib/api.js CHANGED
@@ -226,6 +226,24 @@ function toTs(v) {
226
226
  return d.getTime();
227
227
  }
228
228
 
229
+ // Calendar-day difference (end exclusive) computed purely from Y/M/D.
230
+ // Uses UTC midnights to avoid any dependency on local timezone or DST.
231
+ function calendarDaysExclusiveYmd(startYmd, endYmd) {
232
+ try {
233
+ const m1 = /^\d{4}-\d{2}-\d{2}$/.exec(String(startYmd || '').trim());
234
+ const m2 = /^\d{4}-\d{2}-\d{2}$/.exec(String(endYmd || '').trim());
235
+ if (!m1 || !m2) return null;
236
+ const [sy, sm, sd] = startYmd.split('-').map((x) => parseInt(x, 10));
237
+ const [ey, em, ed] = endYmd.split('-').map((x) => parseInt(x, 10));
238
+ const sUtc = Date.UTC(sy, sm - 1, sd);
239
+ const eUtc = Date.UTC(ey, em - 1, ed);
240
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
241
+ return Math.max(1, diff);
242
+ } catch (e) {
243
+ return null;
244
+ }
245
+ }
246
+
229
247
  function yearFromTs(ts) {
230
248
  const d = new Date(Number(ts));
231
249
  return Number.isFinite(d.getTime()) ? d.getFullYear() : new Date().getFullYear();
@@ -468,6 +486,10 @@ api.getEvents = async function (req, res) {
468
486
  rid: p.rid,
469
487
  status: p.status,
470
488
  uid: p.uid,
489
+ // Needed client-side to gray out unavailable items in the reservation modal.
490
+ // Not sensitive: it is already visible in event titles and prevents double booking.
491
+ itemIds: Array.isArray(p.itemIds) ? p.itemIds.map(String) : [],
492
+ itemIdLine: p.itemIdLine ? String(p.itemIdLine) : '',
471
493
  canModerate: canMod,
472
494
  ...(widgetMode ? { reservedByUsername: String(r.username || '') } : {}),
473
495
  },
@@ -725,12 +747,19 @@ api.createReservation = async function (req, res) {
725
747
  const ok = await canRequest(uid, settings, startPreview);
726
748
  if (!ok) return res.status(403).json({ error: 'not-allowed' });
727
749
 
728
- const start = parseInt(toTs(req.body.start), 10);
729
- const end = parseInt(toTs(req.body.end), 10);
750
+ const startRaw = req.body.start;
751
+ const endRaw = req.body.end;
752
+ const start = parseInt(toTs(startRaw), 10);
753
+ const end = parseInt(toTs(endRaw), 10);
730
754
  if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
731
755
  return res.status(400).json({ error: "bad-dates" });
732
756
  }
733
757
 
758
+ // Keep the original date-only strings when available. This allows
759
+ // calendar-day calculations that are 100% independent of hours/timezones/DST.
760
+ const startDate = (typeof startRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(startRaw.trim())) ? startRaw.trim() : null;
761
+ const endDate = (typeof endRaw === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(endRaw.trim())) ? endRaw.trim() : null;
762
+
734
763
  // Business rule: a reservation cannot start on the current day or in the past.
735
764
  // We compare against server-local midnight. (Front-end also prevents it.)
736
765
  try {
@@ -798,6 +827,8 @@ api.createReservation = async function (req, res) {
798
827
  itemName: (itemNames[0] || itemIds[0]),
799
828
  start,
800
829
  end,
830
+ startDate,
831
+ endDate,
801
832
  status: 'pending',
802
833
  createdAt: now,
803
834
  total: isNaN(total) ? 0 : total,
@@ -916,6 +947,33 @@ api.approveReservation = async function (req, res) {
916
947
  const token = await helloasso.getAccessToken({ env: settings2.helloassoEnv || 'prod', clientId: settings2.helloassoClientId, clientSecret: settings2.helloassoClientSecret });
917
948
  const payer = await user.getUserFields(r.uid, ['email']);
918
949
  const year = yearFromTs(r.start);
950
+
951
+ // Reliable calendar-day count (end is EXCLUSIVE, FullCalendar rule)
952
+ const days = calendarDaysExclusiveYmd(r.startDate, r.endDate) || 1;
953
+
954
+ // Recompute total from HelloAsso catalog to avoid any dependency on hours/DST
955
+ // and to ensure checkout amount is always consistent.
956
+ let recomputedTotalCents = null;
957
+ try {
958
+ const { items: catalog } = await helloasso.listCatalogItems({
959
+ env: settings2.helloassoEnv,
960
+ token,
961
+ organizationSlug: settings2.helloassoOrganizationSlug,
962
+ formType: settings2.helloassoFormType,
963
+ formSlug: autoFormSlugForYear(year),
964
+ });
965
+ const byId = new Map((catalog || []).map((it) => [String(it.id), (typeof it.price === 'number' ? it.price : 0)]));
966
+ const ids = (Array.isArray(r.itemIds) ? r.itemIds : (r.itemId ? [r.itemId] : [])).map(String);
967
+ const sumCentsPerDay = ids.reduce((acc, id) => acc + (byId.get(String(id)) || 0), 0);
968
+ if (sumCentsPerDay > 0) {
969
+ recomputedTotalCents = Math.max(0, Math.round(sumCentsPerDay * days));
970
+ // Keep stored total in sync (euros) for emails/UX.
971
+ r.total = recomputedTotalCents / 100;
972
+ }
973
+ } catch (e) {
974
+ // ignore recompute failures; fallback to stored total
975
+ }
976
+
919
977
  const intent = await helloasso.createCheckoutIntent({
920
978
  env: settings2.helloassoEnv,
921
979
  token,
@@ -925,7 +983,9 @@ api.approveReservation = async function (req, res) {
925
983
  formSlug: autoFormSlugForYear(year),
926
984
  // r.total is stored as an estimated total in euros; HelloAsso expects cents.
927
985
  totalAmount: (() => {
928
- const cents = Math.max(0, Math.round((Number(r.total) || 0) * 100));
986
+ const cents = (typeof recomputedTotalCents === 'number')
987
+ ? recomputedTotalCents
988
+ : Math.max(0, Math.round((Number(r.total) || 0) * 100));
929
989
  if (!cents) {
930
990
  console.warn('[calendar-onekite] HelloAsso totalAmount is 0 (approve API)', { rid, total: r.total });
931
991
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nodebb-plugin-onekite-calendar",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
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/plugin.json CHANGED
@@ -39,5 +39,5 @@
39
39
  "acpScripts": [
40
40
  "public/admin.js"
41
41
  ],
42
- "version": "2.0.1"
42
+ "version": "2.0.3"
43
43
  }
package/public/client.js CHANGED
@@ -806,6 +806,17 @@ function attachAddressAutocomplete(inputEl, onPick) {
806
806
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
807
807
  }
808
808
 
809
+ // Calendar-day difference with end treated as EXCLUSIVE (FullCalendar rule).
810
+ // Ignores hours/timezones/DST by projecting local Y/M/D onto UTC midnights.
811
+ function calendarDaysExclusive(startDate, endDate) {
812
+ const s = new Date(startDate);
813
+ const e = new Date(endDate);
814
+ const sUtc = Date.UTC(s.getFullYear(), s.getMonth(), s.getDate());
815
+ const eUtc = Date.UTC(e.getFullYear(), e.getMonth(), e.getDate());
816
+ const diff = Math.floor((eUtc - sUtc) / (24 * 60 * 60 * 1000));
817
+ return Math.max(1, diff);
818
+ }
819
+
809
820
  function toDatetimeLocalValue(date) {
810
821
  const d = new Date(date);
811
822
  const pad = (n) => String(n).padStart(2, '0');
@@ -816,9 +827,9 @@ function toDatetimeLocalValue(date) {
816
827
  const start = selectionInfo.start;
817
828
  let end = selectionInfo.end;
818
829
 
819
- // days (end is exclusive in FullCalendar)
820
- const msPerDay = 24 * 60 * 60 * 1000;
821
- let days = Math.max(1, Math.round((end.getTime() - start.getTime()) / msPerDay));
830
+ // Days (end is exclusive in FullCalendar) — compute in calendar days only
831
+ // (no dependency on hours, timezone or DST).
832
+ let days = calendarDaysExclusive(start, end);
822
833
 
823
834
  // Fetch existing events overlapping the selection to disable already reserved items.
824
835
  let blocked = new Set();
@@ -923,7 +934,9 @@ function toDatetimeLocalValue(date) {
923
934
  const itemNames = cbs.map(cb => cb.getAttribute('data-name'));
924
935
  const sum = cbs.reduce((acc, cb) => acc + (parseFloat(cb.getAttribute('data-price') || '0') || 0), 0);
925
936
  const total = (sum / 100) * days;
926
- resolve({ itemIds, itemNames, total, days });
937
+ // Return the effective end date (exclusive) because duration shortcuts can
938
+ // change the range without updating the original FullCalendar selection.
939
+ resolve({ itemIds, itemNames, total, days, endDate: toLocalYmd(end) });
927
940
  },
928
941
  },
929
942
  },
@@ -995,7 +1008,7 @@ function toDatetimeLocalValue(date) {
995
1008
  // end is exclusive -> add nd days to start
996
1009
  end = new Date(start);
997
1010
  end.setDate(end.getDate() + nd);
998
- days = nd;
1011
+ days = calendarDaysExclusive(start, end);
999
1012
  if (daysEl) {
1000
1013
  daysEl.textContent = `(${days} jour${days > 1 ? 's' : ''})`;
1001
1014
  }
@@ -1258,7 +1271,10 @@ function toDatetimeLocalValue(date) {
1258
1271
  }
1259
1272
  // Send date strings (no hours) so reservations are day-based.
1260
1273
  const startDate = toLocalYmd(info.start);
1261
- const endDate = toLocalYmd(info.end);
1274
+ // NOTE: FullCalendar's `info.end` reflects the original selection.
1275
+ // If the user used "Durée rapide", the effective end date is held
1276
+ // inside the dialog (returned as `chosen.endDate`).
1277
+ const endDate = (chosen && chosen.endDate) ? String(chosen.endDate) : toLocalYmd(info.end);
1262
1278
  await requestReservation({
1263
1279
  start: startDate,
1264
1280
  end: endDate,