@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.
@@ -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;
@@ -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;
@@ -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.fromJSDate(intervalStart);
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 eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", start).where("eventTime.start", "<=", end);
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 eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", start).where("eventTime.start", "<=", end);
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) {
@@ -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.fromJSDate(intervalStart);
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 eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", start).where("eventTime.start", "<=", end);
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 eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", start).where("eventTime.start", "<=", end);
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.12.26",
4
+ "version": "1.12.27",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -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", ">=", start)
345
- .where("eventTime.start", "<=", end);
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.map((doc) => ({
350
- ...doc.data(),
351
- id: doc.id,
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", ">=", start)
398
- .where("eventTime.start", "<=", end);
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.map((doc) => ({
403
- ...doc.data(),
404
- id: doc.id,
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.fromJSDate(intervalStart);
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, connectFunctionsEmulator } from "firebase/functions";
20
+ import { getFunctions, httpsCallable } from "firebase/functions";
21
21
  import { BaseService } from "../base.service";
22
22
  import {
23
23
  Clinic,