@blackcode_sa/metaestetics-api 1.12.26 → 1.12.27
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/dist/admin/index.d.mts +2 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/admin/index.js +31 -14
- package/dist/admin/index.mjs +31 -14
- package/package.json +1 -1
- package/src/admin/booking/booking.admin.ts +34 -12
- package/src/admin/booking/booking.calculator.ts +14 -9
- package/src/admin/booking/timezones-problem.md +49 -5
- package/src/services/clinic/clinic.service.ts +1 -1
package/dist/admin/index.d.mts
CHANGED
|
@@ -3196,6 +3196,7 @@ declare class BookingAvailabilityCalculator {
|
|
|
3196
3196
|
* @param intervals - Final available intervals
|
|
3197
3197
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
3198
3198
|
* @param durationMinutes - Procedure duration in minutes
|
|
3199
|
+
* @param tz - IANA timezone of the clinic
|
|
3199
3200
|
* @returns Array of available booking slots
|
|
3200
3201
|
*/
|
|
3201
3202
|
private static generateAvailableSlots;
|
|
@@ -3205,6 +3206,7 @@ declare class BookingAvailabilityCalculator {
|
|
|
3205
3206
|
* @param slotStart - Start time of the slot
|
|
3206
3207
|
* @param slotEnd - End time of the slot
|
|
3207
3208
|
* @param intervals - Available intervals
|
|
3209
|
+
* @param tz - IANA timezone of the clinic
|
|
3208
3210
|
* @returns True if the slot is fully contained within an available interval
|
|
3209
3211
|
*/
|
|
3210
3212
|
private static isSlotFullyAvailable;
|
package/dist/admin/index.d.ts
CHANGED
|
@@ -3196,6 +3196,7 @@ declare class BookingAvailabilityCalculator {
|
|
|
3196
3196
|
* @param intervals - Final available intervals
|
|
3197
3197
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
3198
3198
|
* @param durationMinutes - Procedure duration in minutes
|
|
3199
|
+
* @param tz - IANA timezone of the clinic
|
|
3199
3200
|
* @returns Array of available booking slots
|
|
3200
3201
|
*/
|
|
3201
3202
|
private static generateAvailableSlots;
|
|
@@ -3205,6 +3206,7 @@ declare class BookingAvailabilityCalculator {
|
|
|
3205
3206
|
* @param slotStart - Start time of the slot
|
|
3206
3207
|
* @param slotEnd - End time of the slot
|
|
3207
3208
|
* @param intervals - Available intervals
|
|
3209
|
+
* @param tz - IANA timezone of the clinic
|
|
3208
3210
|
* @returns True if the slot is fully contained within an available interval
|
|
3209
3211
|
*/
|
|
3210
3212
|
private static isSlotFullyAvailable;
|
package/dist/admin/index.js
CHANGED
|
@@ -6603,7 +6603,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
6603
6603
|
const availableSlots = this.generateAvailableSlots(
|
|
6604
6604
|
availableIntervals,
|
|
6605
6605
|
schedulingIntervalMinutes,
|
|
6606
|
-
procedureDurationMinutes
|
|
6606
|
+
procedureDurationMinutes,
|
|
6607
|
+
tz
|
|
6607
6608
|
);
|
|
6608
6609
|
return { availableSlots };
|
|
6609
6610
|
}
|
|
@@ -6855,19 +6856,20 @@ var BookingAvailabilityCalculator = class {
|
|
|
6855
6856
|
* @param intervals - Final available intervals
|
|
6856
6857
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
6857
6858
|
* @param durationMinutes - Procedure duration in minutes
|
|
6859
|
+
* @param tz - IANA timezone of the clinic
|
|
6858
6860
|
* @returns Array of available booking slots
|
|
6859
6861
|
*/
|
|
6860
|
-
static generateAvailableSlots(intervals, intervalMinutes, durationMinutes) {
|
|
6862
|
+
static generateAvailableSlots(intervals, intervalMinutes, durationMinutes, tz) {
|
|
6861
6863
|
const slots = [];
|
|
6862
6864
|
console.log(
|
|
6863
|
-
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure`
|
|
6865
|
+
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
6864
6866
|
);
|
|
6865
6867
|
const durationMs = durationMinutes * 60 * 1e3;
|
|
6866
6868
|
const intervalMs = intervalMinutes * 60 * 1e3;
|
|
6867
6869
|
for (const interval of intervals) {
|
|
6868
6870
|
const intervalStart = interval.start.toDate();
|
|
6869
6871
|
const intervalEnd = interval.end.toDate();
|
|
6870
|
-
let slotStart = import_luxon.DateTime.
|
|
6872
|
+
let slotStart = import_luxon.DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
6871
6873
|
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
6872
6874
|
const minutesRemainder = minutesIntoDay % intervalMinutes;
|
|
6873
6875
|
if (minutesRemainder > 0) {
|
|
@@ -6877,7 +6879,7 @@ var BookingAvailabilityCalculator = class {
|
|
|
6877
6879
|
}
|
|
6878
6880
|
while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
|
|
6879
6881
|
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
6880
|
-
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals)) {
|
|
6882
|
+
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
6881
6883
|
slots.push({
|
|
6882
6884
|
start: import_firestore2.Timestamp.fromMillis(slotStart.toMillis())
|
|
6883
6885
|
});
|
|
@@ -6894,12 +6896,13 @@ var BookingAvailabilityCalculator = class {
|
|
|
6894
6896
|
* @param slotStart - Start time of the slot
|
|
6895
6897
|
* @param slotEnd - End time of the slot
|
|
6896
6898
|
* @param intervals - Available intervals
|
|
6899
|
+
* @param tz - IANA timezone of the clinic
|
|
6897
6900
|
* @returns True if the slot is fully contained within an available interval
|
|
6898
6901
|
*/
|
|
6899
|
-
static isSlotFullyAvailable(slotStart, slotEnd, intervals) {
|
|
6902
|
+
static isSlotFullyAvailable(slotStart, slotEnd, intervals, tz) {
|
|
6900
6903
|
return intervals.some((interval) => {
|
|
6901
|
-
const intervalStart = import_luxon.DateTime.fromMillis(interval.start.toMillis());
|
|
6902
|
-
const intervalEnd = import_luxon.DateTime.fromMillis(interval.end.toMillis());
|
|
6904
|
+
const intervalStart = import_luxon.DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
6905
|
+
const intervalEnd = import_luxon.DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
6903
6906
|
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
6904
6907
|
});
|
|
6905
6908
|
}
|
|
@@ -7344,16 +7347,23 @@ var BookingAdmin = class {
|
|
|
7344
7347
|
startTime: start.toDate().toISOString(),
|
|
7345
7348
|
endTime: end.toDate().toISOString()
|
|
7346
7349
|
});
|
|
7347
|
-
const
|
|
7350
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7351
|
+
const queryStart = admin15.firestore.Timestamp.fromMillis(
|
|
7352
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7353
|
+
);
|
|
7354
|
+
const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7348
7355
|
const snapshot = await eventsRef.get();
|
|
7349
7356
|
const events = snapshot.docs.map((doc) => ({
|
|
7350
7357
|
...doc.data(),
|
|
7351
7358
|
id: doc.id
|
|
7352
|
-
}))
|
|
7359
|
+
})).filter((event) => {
|
|
7360
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7361
|
+
});
|
|
7353
7362
|
Logger.debug("[BookingAdmin] Retrieved clinic calendar events", {
|
|
7354
7363
|
clinicId,
|
|
7355
7364
|
eventsCount: events.length,
|
|
7356
|
-
eventsTypes: this.summarizeEventTypes(events)
|
|
7365
|
+
eventsTypes: this.summarizeEventTypes(events),
|
|
7366
|
+
queryStartTime: queryStart.toDate().toISOString()
|
|
7357
7367
|
});
|
|
7358
7368
|
return events;
|
|
7359
7369
|
} catch (error) {
|
|
@@ -7383,16 +7393,23 @@ var BookingAdmin = class {
|
|
|
7383
7393
|
startTime: start.toDate().toISOString(),
|
|
7384
7394
|
endTime: end.toDate().toISOString()
|
|
7385
7395
|
});
|
|
7386
|
-
const
|
|
7396
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7397
|
+
const queryStart = admin15.firestore.Timestamp.fromMillis(
|
|
7398
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7399
|
+
);
|
|
7400
|
+
const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7387
7401
|
const snapshot = await eventsRef.get();
|
|
7388
7402
|
const events = snapshot.docs.map((doc) => ({
|
|
7389
7403
|
...doc.data(),
|
|
7390
7404
|
id: doc.id
|
|
7391
|
-
}))
|
|
7405
|
+
})).filter((event) => {
|
|
7406
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7407
|
+
});
|
|
7392
7408
|
Logger.debug("[BookingAdmin] Retrieved practitioner calendar events", {
|
|
7393
7409
|
practitionerId,
|
|
7394
7410
|
eventsCount: events.length,
|
|
7395
|
-
eventsTypes: this.summarizeEventTypes(events)
|
|
7411
|
+
eventsTypes: this.summarizeEventTypes(events),
|
|
7412
|
+
queryStartTime: queryStart.toDate().toISOString()
|
|
7396
7413
|
});
|
|
7397
7414
|
return events;
|
|
7398
7415
|
} catch (error) {
|
package/dist/admin/index.mjs
CHANGED
|
@@ -6541,7 +6541,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
6541
6541
|
const availableSlots = this.generateAvailableSlots(
|
|
6542
6542
|
availableIntervals,
|
|
6543
6543
|
schedulingIntervalMinutes,
|
|
6544
|
-
procedureDurationMinutes
|
|
6544
|
+
procedureDurationMinutes,
|
|
6545
|
+
tz
|
|
6545
6546
|
);
|
|
6546
6547
|
return { availableSlots };
|
|
6547
6548
|
}
|
|
@@ -6793,19 +6794,20 @@ var BookingAvailabilityCalculator = class {
|
|
|
6793
6794
|
* @param intervals - Final available intervals
|
|
6794
6795
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
6795
6796
|
* @param durationMinutes - Procedure duration in minutes
|
|
6797
|
+
* @param tz - IANA timezone of the clinic
|
|
6796
6798
|
* @returns Array of available booking slots
|
|
6797
6799
|
*/
|
|
6798
|
-
static generateAvailableSlots(intervals, intervalMinutes, durationMinutes) {
|
|
6800
|
+
static generateAvailableSlots(intervals, intervalMinutes, durationMinutes, tz) {
|
|
6799
6801
|
const slots = [];
|
|
6800
6802
|
console.log(
|
|
6801
|
-
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure`
|
|
6803
|
+
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
6802
6804
|
);
|
|
6803
6805
|
const durationMs = durationMinutes * 60 * 1e3;
|
|
6804
6806
|
const intervalMs = intervalMinutes * 60 * 1e3;
|
|
6805
6807
|
for (const interval of intervals) {
|
|
6806
6808
|
const intervalStart = interval.start.toDate();
|
|
6807
6809
|
const intervalEnd = interval.end.toDate();
|
|
6808
|
-
let slotStart = DateTime.
|
|
6810
|
+
let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
6809
6811
|
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
6810
6812
|
const minutesRemainder = minutesIntoDay % intervalMinutes;
|
|
6811
6813
|
if (minutesRemainder > 0) {
|
|
@@ -6815,7 +6817,7 @@ var BookingAvailabilityCalculator = class {
|
|
|
6815
6817
|
}
|
|
6816
6818
|
while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
|
|
6817
6819
|
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
6818
|
-
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals)) {
|
|
6820
|
+
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
6819
6821
|
slots.push({
|
|
6820
6822
|
start: Timestamp.fromMillis(slotStart.toMillis())
|
|
6821
6823
|
});
|
|
@@ -6832,12 +6834,13 @@ var BookingAvailabilityCalculator = class {
|
|
|
6832
6834
|
* @param slotStart - Start time of the slot
|
|
6833
6835
|
* @param slotEnd - End time of the slot
|
|
6834
6836
|
* @param intervals - Available intervals
|
|
6837
|
+
* @param tz - IANA timezone of the clinic
|
|
6835
6838
|
* @returns True if the slot is fully contained within an available interval
|
|
6836
6839
|
*/
|
|
6837
|
-
static isSlotFullyAvailable(slotStart, slotEnd, intervals) {
|
|
6840
|
+
static isSlotFullyAvailable(slotStart, slotEnd, intervals, tz) {
|
|
6838
6841
|
return intervals.some((interval) => {
|
|
6839
|
-
const intervalStart = DateTime.fromMillis(interval.start.toMillis());
|
|
6840
|
-
const intervalEnd = DateTime.fromMillis(interval.end.toMillis());
|
|
6842
|
+
const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
6843
|
+
const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
6841
6844
|
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
6842
6845
|
});
|
|
6843
6846
|
}
|
|
@@ -7282,16 +7285,23 @@ var BookingAdmin = class {
|
|
|
7282
7285
|
startTime: start.toDate().toISOString(),
|
|
7283
7286
|
endTime: end.toDate().toISOString()
|
|
7284
7287
|
});
|
|
7285
|
-
const
|
|
7288
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7289
|
+
const queryStart = admin15.firestore.Timestamp.fromMillis(
|
|
7290
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7291
|
+
);
|
|
7292
|
+
const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7286
7293
|
const snapshot = await eventsRef.get();
|
|
7287
7294
|
const events = snapshot.docs.map((doc) => ({
|
|
7288
7295
|
...doc.data(),
|
|
7289
7296
|
id: doc.id
|
|
7290
|
-
}))
|
|
7297
|
+
})).filter((event) => {
|
|
7298
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7299
|
+
});
|
|
7291
7300
|
Logger.debug("[BookingAdmin] Retrieved clinic calendar events", {
|
|
7292
7301
|
clinicId,
|
|
7293
7302
|
eventsCount: events.length,
|
|
7294
|
-
eventsTypes: this.summarizeEventTypes(events)
|
|
7303
|
+
eventsTypes: this.summarizeEventTypes(events),
|
|
7304
|
+
queryStartTime: queryStart.toDate().toISOString()
|
|
7295
7305
|
});
|
|
7296
7306
|
return events;
|
|
7297
7307
|
} catch (error) {
|
|
@@ -7321,16 +7331,23 @@ var BookingAdmin = class {
|
|
|
7321
7331
|
startTime: start.toDate().toISOString(),
|
|
7322
7332
|
endTime: end.toDate().toISOString()
|
|
7323
7333
|
});
|
|
7324
|
-
const
|
|
7334
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7335
|
+
const queryStart = admin15.firestore.Timestamp.fromMillis(
|
|
7336
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7337
|
+
);
|
|
7338
|
+
const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7325
7339
|
const snapshot = await eventsRef.get();
|
|
7326
7340
|
const events = snapshot.docs.map((doc) => ({
|
|
7327
7341
|
...doc.data(),
|
|
7328
7342
|
id: doc.id
|
|
7329
|
-
}))
|
|
7343
|
+
})).filter((event) => {
|
|
7344
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7345
|
+
});
|
|
7330
7346
|
Logger.debug("[BookingAdmin] Retrieved practitioner calendar events", {
|
|
7331
7347
|
practitionerId,
|
|
7332
7348
|
eventsCount: events.length,
|
|
7333
|
-
eventsTypes: this.summarizeEventTypes(events)
|
|
7349
|
+
eventsTypes: this.summarizeEventTypes(events),
|
|
7350
|
+
queryStartTime: queryStart.toDate().toISOString()
|
|
7334
7351
|
});
|
|
7335
7352
|
return events;
|
|
7336
7353
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -339,22 +339,33 @@ export class BookingAdmin {
|
|
|
339
339
|
endTime: end.toDate().toISOString(),
|
|
340
340
|
});
|
|
341
341
|
|
|
342
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
343
|
+
const queryStart = admin.firestore.Timestamp.fromMillis(
|
|
344
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
345
|
+
);
|
|
346
|
+
|
|
342
347
|
const eventsRef = this.db
|
|
343
348
|
.collection(`clinics/${clinicId}/calendar`)
|
|
344
|
-
.where("eventTime.start", ">=",
|
|
345
|
-
.where("eventTime.start", "
|
|
349
|
+
.where("eventTime.start", ">=", queryStart)
|
|
350
|
+
.where("eventTime.start", "<", end)
|
|
351
|
+
.orderBy("eventTime.start");
|
|
346
352
|
|
|
347
353
|
const snapshot = await eventsRef.get();
|
|
348
354
|
|
|
349
|
-
const events = snapshot.docs
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
355
|
+
const events = snapshot.docs
|
|
356
|
+
.map((doc) => ({
|
|
357
|
+
...doc.data(),
|
|
358
|
+
id: doc.id,
|
|
359
|
+
}))
|
|
360
|
+
.filter((event: any) => {
|
|
361
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
362
|
+
});
|
|
353
363
|
|
|
354
364
|
Logger.debug("[BookingAdmin] Retrieved clinic calendar events", {
|
|
355
365
|
clinicId,
|
|
356
366
|
eventsCount: events.length,
|
|
357
367
|
eventsTypes: this.summarizeEventTypes(events),
|
|
368
|
+
queryStartTime: queryStart.toDate().toISOString(),
|
|
358
369
|
});
|
|
359
370
|
|
|
360
371
|
return events;
|
|
@@ -392,22 +403,33 @@ export class BookingAdmin {
|
|
|
392
403
|
endTime: end.toDate().toISOString(),
|
|
393
404
|
});
|
|
394
405
|
|
|
406
|
+
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1000;
|
|
407
|
+
const queryStart = admin.firestore.Timestamp.fromMillis(
|
|
408
|
+
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
409
|
+
);
|
|
410
|
+
|
|
395
411
|
const eventsRef = this.db
|
|
396
412
|
.collection(`practitioners/${practitionerId}/calendar`)
|
|
397
|
-
.where("eventTime.start", ">=",
|
|
398
|
-
.where("eventTime.start", "
|
|
413
|
+
.where("eventTime.start", ">=", queryStart)
|
|
414
|
+
.where("eventTime.start", "<", end)
|
|
415
|
+
.orderBy("eventTime.start");
|
|
399
416
|
|
|
400
417
|
const snapshot = await eventsRef.get();
|
|
401
418
|
|
|
402
|
-
const events = snapshot.docs
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
419
|
+
const events = snapshot.docs
|
|
420
|
+
.map((doc) => ({
|
|
421
|
+
...doc.data(),
|
|
422
|
+
id: doc.id,
|
|
423
|
+
}))
|
|
424
|
+
.filter((event: any) => {
|
|
425
|
+
return event.eventTime.end.toMillis() > start.toMillis();
|
|
426
|
+
});
|
|
406
427
|
|
|
407
428
|
Logger.debug("[BookingAdmin] Retrieved practitioner calendar events", {
|
|
408
429
|
practitionerId,
|
|
409
430
|
eventsCount: events.length,
|
|
410
431
|
eventsTypes: this.summarizeEventTypes(events),
|
|
432
|
+
queryStartTime: queryStart.toDate().toISOString(),
|
|
411
433
|
});
|
|
412
434
|
|
|
413
435
|
return events;
|
|
@@ -96,7 +96,8 @@ export class BookingAvailabilityCalculator {
|
|
|
96
96
|
const availableSlots = this.generateAvailableSlots(
|
|
97
97
|
availableIntervals,
|
|
98
98
|
schedulingIntervalMinutes,
|
|
99
|
-
procedureDurationMinutes
|
|
99
|
+
procedureDurationMinutes,
|
|
100
|
+
tz
|
|
100
101
|
);
|
|
101
102
|
|
|
102
103
|
return { availableSlots };
|
|
@@ -469,16 +470,18 @@ export class BookingAvailabilityCalculator {
|
|
|
469
470
|
* @param intervals - Final available intervals
|
|
470
471
|
* @param intervalMinutes - Scheduling interval in minutes
|
|
471
472
|
* @param durationMinutes - Procedure duration in minutes
|
|
473
|
+
* @param tz - IANA timezone of the clinic
|
|
472
474
|
* @returns Array of available booking slots
|
|
473
475
|
*/
|
|
474
476
|
private static generateAvailableSlots(
|
|
475
477
|
intervals: TimeInterval[],
|
|
476
478
|
intervalMinutes: number,
|
|
477
|
-
durationMinutes: number
|
|
479
|
+
durationMinutes: number,
|
|
480
|
+
tz: string
|
|
478
481
|
): AvailableSlot[] {
|
|
479
482
|
const slots: AvailableSlot[] = [];
|
|
480
483
|
console.log(
|
|
481
|
-
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure`
|
|
484
|
+
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
482
485
|
);
|
|
483
486
|
|
|
484
487
|
// Convert duration to milliseconds
|
|
@@ -492,8 +495,8 @@ export class BookingAvailabilityCalculator {
|
|
|
492
495
|
const intervalStart = interval.start.toDate();
|
|
493
496
|
const intervalEnd = interval.end.toDate();
|
|
494
497
|
|
|
495
|
-
// Start at the beginning of the interval
|
|
496
|
-
let slotStart = DateTime.
|
|
498
|
+
// Start at the beginning of the interval IN CLINIC TIMEZONE
|
|
499
|
+
let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
497
500
|
|
|
498
501
|
// Adjust slotStart to the nearest interval boundary if needed
|
|
499
502
|
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
@@ -511,7 +514,7 @@ export class BookingAvailabilityCalculator {
|
|
|
511
514
|
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
512
515
|
|
|
513
516
|
// Check if this slot fits entirely within one of our available intervals
|
|
514
|
-
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals)) {
|
|
517
|
+
if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
515
518
|
slots.push({
|
|
516
519
|
start: Timestamp.fromMillis(slotStart.toMillis()),
|
|
517
520
|
});
|
|
@@ -532,17 +535,19 @@ export class BookingAvailabilityCalculator {
|
|
|
532
535
|
* @param slotStart - Start time of the slot
|
|
533
536
|
* @param slotEnd - End time of the slot
|
|
534
537
|
* @param intervals - Available intervals
|
|
538
|
+
* @param tz - IANA timezone of the clinic
|
|
535
539
|
* @returns True if the slot is fully contained within an available interval
|
|
536
540
|
*/
|
|
537
541
|
private static isSlotFullyAvailable(
|
|
538
542
|
slotStart: DateTime,
|
|
539
543
|
slotEnd: DateTime,
|
|
540
|
-
intervals: TimeInterval[]
|
|
544
|
+
intervals: TimeInterval[],
|
|
545
|
+
tz: string
|
|
541
546
|
): boolean {
|
|
542
547
|
// Check if the slot is fully contained in any of the available intervals
|
|
543
548
|
return intervals.some((interval) => {
|
|
544
|
-
const intervalStart = DateTime.fromMillis(interval.start.toMillis());
|
|
545
|
-
const intervalEnd = DateTime.fromMillis(interval.end.toMillis());
|
|
549
|
+
const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
550
|
+
const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
546
551
|
|
|
547
552
|
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
548
553
|
});
|
|
@@ -122,11 +122,55 @@ Alignment expectation:
|
|
|
122
122
|
- Interval sizes that don’t divide 60 (e.g., 20 min), and variable procedure durations.
|
|
123
123
|
|
|
124
124
|
### Actionable checklist
|
|
125
|
-
- Make all DateTime constructions in slot generation and containment checks use `{ zone: tz }`.
|
|
126
|
-
- Replace `fromJSDate` with `fromMillis` in calculator paths.
|
|
127
|
-
- Update event fetching to include overlaps with the timeframe; union queries or adjust model.
|
|
128
|
-
- Re-assert the type contract: calculator receives only client `Timestamp` + `tz`.
|
|
129
|
-
- Document UI responsibility to convert wall-clock (clinic `tz`) → UTC Timestamp exactly once.
|
|
125
|
+
- ✅ Make all DateTime constructions in slot generation and containment checks use `{ zone: tz }`.
|
|
126
|
+
- ✅ Replace `fromJSDate` with `fromMillis` in calculator paths.
|
|
127
|
+
- ✅ Update event fetching to include overlaps with the timeframe; union queries or adjust model.
|
|
128
|
+
- ⏳ Re-assert the type contract: calculator receives only client `Timestamp` + `tz`.
|
|
129
|
+
- ⏳ Document UI responsibility to convert wall-clock (clinic `tz`) → UTC Timestamp exactly once.
|
|
130
|
+
|
|
131
|
+
### PHASE 1 COMPLETED: Backend Calculator Fixes ✅
|
|
132
|
+
|
|
133
|
+
**Date Completed:** October 1, 2025
|
|
134
|
+
|
|
135
|
+
**Changes Made:**
|
|
136
|
+
|
|
137
|
+
1. **booking.calculator.ts - generateAvailableSlots()**
|
|
138
|
+
- Added `tz: string` parameter
|
|
139
|
+
- Changed `DateTime.fromJSDate(intervalStart)` → `DateTime.fromMillis(intervalStart.getTime(), { zone: tz })`
|
|
140
|
+
- Now explicitly creates DateTime in clinic timezone for all slot calculations
|
|
141
|
+
|
|
142
|
+
2. **booking.calculator.ts - isSlotFullyAvailable()**
|
|
143
|
+
- Added `tz: string` parameter
|
|
144
|
+
- Changed `DateTime.fromMillis(interval.start.toMillis())` → `DateTime.fromMillis(interval.start.toMillis(), { zone: tz })`
|
|
145
|
+
- Interval boundary checks now use clinic timezone context
|
|
146
|
+
|
|
147
|
+
3. **booking.calculator.ts - calculateSlots()**
|
|
148
|
+
- Updated call to `generateAvailableSlots()` to pass `tz` parameter
|
|
149
|
+
- Updated call to `isSlotFullyAvailable()` to pass `tz` parameter
|
|
150
|
+
|
|
151
|
+
4. **booking.admin.ts - getClinicCalendarEvents()**
|
|
152
|
+
- Fixed event overlap logic with bounded query
|
|
153
|
+
- Added lower bound: `queryStart = start - 24 hours` to prevent querying all historical events
|
|
154
|
+
- Query: `eventTime.start >= queryStart AND eventTime.start < end`
|
|
155
|
+
- Added post-filter: `eventTime.end > start` to catch all overlapping events
|
|
156
|
+
- Prevents missing events that start before window but overlap into it
|
|
157
|
+
- Performance optimized: only queries ~24-48 hours of events instead of entire history
|
|
158
|
+
|
|
159
|
+
5. **booking.admin.ts - getPractitionerCalendarEvents()**
|
|
160
|
+
- Applied same overlap fix with 24-hour lookback window
|
|
161
|
+
- Ensures busy time subtraction includes all conflicting events
|
|
162
|
+
- Efficient: assumes no appointments longer than 24 hours
|
|
163
|
+
|
|
164
|
+
**Impact:**
|
|
165
|
+
- Slot generation now correctly happens in clinic timezone
|
|
166
|
+
- Slots are properly converted to UTC for storage/transmission
|
|
167
|
+
- Event blocking now catches all overlapping events, not just those starting within window
|
|
168
|
+
- Fixes timezone display issues for users in different timezones
|
|
169
|
+
|
|
170
|
+
**Next Steps:**
|
|
171
|
+
- Phase 2: Mobile app fixes (use original UTC timestamp from slots)
|
|
172
|
+
- Phase 3: ClinicApp fixes (same as Mobile)
|
|
173
|
+
- Phase 4: Testing across multiple timezones and DST boundaries
|
|
130
174
|
|
|
131
175
|
### Known risks
|
|
132
176
|
- DST transitions can produce days with 23/25 hours; rounding/iteration must not assume 24h.
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
arrayUnion,
|
|
18
18
|
arrayRemove,
|
|
19
19
|
} from "firebase/firestore";
|
|
20
|
-
import { getFunctions, httpsCallable
|
|
20
|
+
import { getFunctions, httpsCallable } from "firebase/functions";
|
|
21
21
|
import { BaseService } from "../base.service";
|
|
22
22
|
import {
|
|
23
23
|
Clinic,
|