@blackcode_sa/metaestetics-api 1.12.62 → 1.12.63
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 +4 -2
- package/dist/admin/index.d.ts +4 -2
- package/dist/admin/index.js +4 -45
- package/dist/admin/index.mjs +4 -45
- package/dist/backoffice/index.d.mts +9 -0
- package/dist/backoffice/index.d.ts +9 -0
- package/dist/backoffice/index.js +11 -0
- package/dist/backoffice/index.mjs +11 -0
- package/dist/index.d.mts +99 -3
- package/dist/index.d.ts +99 -3
- package/dist/index.js +545 -281
- package/dist/index.mjs +867 -603
- package/package.json +119 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +641 -689
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +75 -75
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +40 -40
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +318 -318
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +8 -8
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +395 -395
- package/src/backoffice/services/technology.service.ts +1083 -1070
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +62 -62
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +163 -161
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -163
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2505 -2082
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +13 -13
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +1682 -1682
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +636 -683
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/appointment/index.ts +481 -453
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +489 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +44 -44
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +265 -265
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -273
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +130 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +493 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -216
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +189 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,712 +1,712 @@
|
|
|
1
|
-
import { Timestamp } from "firebase/firestore";
|
|
2
|
-
import { DateTime } from "luxon";
|
|
3
|
-
import {
|
|
4
|
-
BookingAvailabilityRequest,
|
|
5
|
-
BookingAvailabilityResponse,
|
|
6
|
-
AvailableSlot,
|
|
7
|
-
TimeInterval,
|
|
8
|
-
} from "./booking.types";
|
|
9
|
-
import {
|
|
10
|
-
CalendarEvent,
|
|
11
|
-
CalendarEventStatus,
|
|
12
|
-
CalendarEventType,
|
|
13
|
-
} from "../../types/calendar";
|
|
14
|
-
import { PractitionerClinicWorkingHours } from "../../types/practitioner";
|
|
15
|
-
import { Clinic } from "../../types/clinic";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Calculator for determining available booking slots
|
|
19
|
-
* This class handles the complex logic of determining when appointments can be scheduled
|
|
20
|
-
* based on clinic working hours, practitioner availability, and existing calendar events.
|
|
21
|
-
*/
|
|
22
|
-
export class BookingAvailabilityCalculator {
|
|
23
|
-
/** Default scheduling interval in minutes if not specified by the clinic */
|
|
24
|
-
private static readonly DEFAULT_INTERVAL_MINUTES = 15;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Calculate available booking slots based on the provided data
|
|
28
|
-
*
|
|
29
|
-
* @param request - The request containing all necessary data for calculation
|
|
30
|
-
* @returns Response with available booking slots
|
|
31
|
-
*/
|
|
32
|
-
public static calculateSlots(
|
|
33
|
-
request: BookingAvailabilityRequest
|
|
34
|
-
): BookingAvailabilityResponse {
|
|
35
|
-
// Extract necessary data from the request
|
|
36
|
-
const {
|
|
37
|
-
clinic,
|
|
38
|
-
practitioner,
|
|
39
|
-
procedure,
|
|
40
|
-
timeframe,
|
|
41
|
-
clinicCalendarEvents,
|
|
42
|
-
practitionerCalendarEvents,
|
|
43
|
-
tz,
|
|
44
|
-
} = request;
|
|
45
|
-
|
|
46
|
-
// Get scheduling interval (default to 15 minutes if not specified)
|
|
47
|
-
const schedulingIntervalMinutes =
|
|
48
|
-
(clinic as any).schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
|
|
49
|
-
|
|
50
|
-
// Get procedure duration in minutes
|
|
51
|
-
const procedureDurationMinutes = procedure.duration;
|
|
52
|
-
|
|
53
|
-
console.log(
|
|
54
|
-
`Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
|
|
55
|
-
);
|
|
56
|
-
|
|
57
|
-
// Start with the full timeframe as initially available
|
|
58
|
-
let availableIntervals: TimeInterval[] = [
|
|
59
|
-
{ start: timeframe.start, end: timeframe.end },
|
|
60
|
-
];
|
|
61
|
-
|
|
62
|
-
// Step 1: Apply clinic working hours
|
|
63
|
-
availableIntervals = this.applyClinicWorkingHours(
|
|
64
|
-
availableIntervals,
|
|
65
|
-
clinic.workingHours,
|
|
66
|
-
timeframe,
|
|
67
|
-
tz
|
|
68
|
-
);
|
|
69
|
-
|
|
70
|
-
// Step 2: Subtract clinic blocking events
|
|
71
|
-
availableIntervals = this.subtractBlockingEvents(
|
|
72
|
-
availableIntervals,
|
|
73
|
-
clinicCalendarEvents
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
// Step 3: Apply practitioner's working hours for this clinic
|
|
77
|
-
availableIntervals = this.applyPractitionerWorkingHours(
|
|
78
|
-
availableIntervals,
|
|
79
|
-
practitioner,
|
|
80
|
-
clinic.id,
|
|
81
|
-
timeframe,
|
|
82
|
-
tz
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
// Step 4: Subtract practitioner's busy times
|
|
86
|
-
availableIntervals = this.subtractPractitionerBusyTimes(
|
|
87
|
-
availableIntervals,
|
|
88
|
-
practitionerCalendarEvents
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
console.log(
|
|
92
|
-
`After all filters, have ${availableIntervals.length} available intervals`
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
// Step 5: Generate available slots based on scheduling interval and procedure duration
|
|
96
|
-
const availableSlots = this.generateAvailableSlots(
|
|
97
|
-
availableIntervals,
|
|
98
|
-
schedulingIntervalMinutes,
|
|
99
|
-
procedureDurationMinutes,
|
|
100
|
-
tz
|
|
101
|
-
);
|
|
102
|
-
|
|
103
|
-
return { availableSlots };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Apply clinic working hours to available intervals
|
|
108
|
-
*
|
|
109
|
-
* @param intervals - Current available intervals
|
|
110
|
-
* @param workingHours - Clinic working hours
|
|
111
|
-
* @param timeframe - Overall timeframe being considered
|
|
112
|
-
* @param tz - IANA timezone of the clinic
|
|
113
|
-
* @returns Intervals filtered by clinic working hours
|
|
114
|
-
*/
|
|
115
|
-
private static applyClinicWorkingHours(
|
|
116
|
-
intervals: TimeInterval[],
|
|
117
|
-
workingHours: any, // Using 'any' for now since we're working with the existing type structure
|
|
118
|
-
timeframe: { start: Timestamp; end: Timestamp },
|
|
119
|
-
tz: string
|
|
120
|
-
): TimeInterval[] {
|
|
121
|
-
if (!intervals.length) return [];
|
|
122
|
-
console.log(
|
|
123
|
-
`Applying clinic working hours to ${intervals.length} intervals`
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// Create working intervals for each day in the timeframe based on clinic's working hours
|
|
127
|
-
const workingIntervals = this.createWorkingHoursIntervals(
|
|
128
|
-
workingHours,
|
|
129
|
-
timeframe.start.toDate(),
|
|
130
|
-
timeframe.end.toDate(),
|
|
131
|
-
tz
|
|
132
|
-
);
|
|
133
|
-
|
|
134
|
-
// Intersect the available intervals with working hours intervals
|
|
135
|
-
return this.intersectIntervals(intervals, workingIntervals);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Create time intervals for working hours across multiple days
|
|
140
|
-
*
|
|
141
|
-
* @param workingHours - Working hours definition
|
|
142
|
-
* @param startDate - Start date of the overall timeframe
|
|
143
|
-
* @param endDate - End date of the overall timeframe
|
|
144
|
-
* @param tz - IANA timezone of the clinic
|
|
145
|
-
* @returns Array of time intervals representing working hours
|
|
146
|
-
*/
|
|
147
|
-
private static createWorkingHoursIntervals(
|
|
148
|
-
workingHours: any,
|
|
149
|
-
startDate: Date,
|
|
150
|
-
endDate: Date,
|
|
151
|
-
tz: string
|
|
152
|
-
): TimeInterval[] {
|
|
153
|
-
const workingIntervals: TimeInterval[] = [];
|
|
154
|
-
// FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
|
|
155
|
-
let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
|
|
156
|
-
const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
|
|
157
|
-
|
|
158
|
-
while (start <= end) {
|
|
159
|
-
const dayOfWeek = start.weekday; // 1 for Monday, 7 for Sunday
|
|
160
|
-
const dayName = [
|
|
161
|
-
"monday",
|
|
162
|
-
"tuesday",
|
|
163
|
-
"wednesday",
|
|
164
|
-
"thursday",
|
|
165
|
-
"friday",
|
|
166
|
-
"saturday",
|
|
167
|
-
"sunday",
|
|
168
|
-
][dayOfWeek - 1];
|
|
169
|
-
|
|
170
|
-
if (dayName && workingHours[dayName]) {
|
|
171
|
-
const daySchedule = workingHours[dayName];
|
|
172
|
-
if (daySchedule) {
|
|
173
|
-
const [openHours, openMinutes] = daySchedule.open
|
|
174
|
-
.split(":")
|
|
175
|
-
.map(Number);
|
|
176
|
-
const [closeHours, closeMinutes] = daySchedule.close
|
|
177
|
-
.split(":")
|
|
178
|
-
.map(Number);
|
|
179
|
-
|
|
180
|
-
let workStart = start.set({
|
|
181
|
-
hour: openHours,
|
|
182
|
-
minute: openMinutes,
|
|
183
|
-
second: 0,
|
|
184
|
-
millisecond: 0,
|
|
185
|
-
});
|
|
186
|
-
let workEnd = start.set({
|
|
187
|
-
hour: closeHours,
|
|
188
|
-
minute: closeMinutes,
|
|
189
|
-
second: 0,
|
|
190
|
-
millisecond: 0,
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
if (
|
|
194
|
-
workEnd.toMillis() > startDate.getTime() &&
|
|
195
|
-
workStart.toMillis() < endDate.getTime()
|
|
196
|
-
) {
|
|
197
|
-
// FIXED: Use fromMillis instead of fromJSDate
|
|
198
|
-
const intervalStart =
|
|
199
|
-
workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
200
|
-
? DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
201
|
-
: workStart;
|
|
202
|
-
const intervalEnd =
|
|
203
|
-
workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
204
|
-
? DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
205
|
-
: workEnd;
|
|
206
|
-
|
|
207
|
-
workingIntervals.push({
|
|
208
|
-
start: Timestamp.fromMillis(intervalStart.toMillis()),
|
|
209
|
-
end: Timestamp.fromMillis(intervalEnd.toMillis()),
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
if (daySchedule.breaks && daySchedule.breaks.length > 0) {
|
|
213
|
-
for (const breakTime of daySchedule.breaks) {
|
|
214
|
-
const [breakStartHours, breakStartMinutes] = breakTime.start
|
|
215
|
-
.split(":")
|
|
216
|
-
.map(Number);
|
|
217
|
-
const [breakEndHours, breakEndMinutes] = breakTime.end
|
|
218
|
-
.split(":")
|
|
219
|
-
.map(Number);
|
|
220
|
-
|
|
221
|
-
const breakStart = start.set({
|
|
222
|
-
hour: breakStartHours,
|
|
223
|
-
minute: breakStartMinutes,
|
|
224
|
-
});
|
|
225
|
-
const breakEnd = start.set({
|
|
226
|
-
hour: breakEndHours,
|
|
227
|
-
minute: breakEndMinutes,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
workingIntervals.splice(
|
|
231
|
-
-1,
|
|
232
|
-
1,
|
|
233
|
-
...this.subtractInterval(
|
|
234
|
-
workingIntervals[workingIntervals.length - 1],
|
|
235
|
-
{
|
|
236
|
-
start: Timestamp.fromMillis(breakStart.toMillis()),
|
|
237
|
-
end: Timestamp.fromMillis(breakEnd.toMillis()),
|
|
238
|
-
}
|
|
239
|
-
)
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
start = start.plus({ days: 1 });
|
|
247
|
-
}
|
|
248
|
-
return workingIntervals;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* Subtract blocking events from available intervals
|
|
253
|
-
*
|
|
254
|
-
* @param intervals - Current available intervals
|
|
255
|
-
* @param events - Calendar events to subtract
|
|
256
|
-
* @returns Available intervals after removing blocking events
|
|
257
|
-
*/
|
|
258
|
-
private static subtractBlockingEvents(
|
|
259
|
-
intervals: TimeInterval[],
|
|
260
|
-
events: CalendarEvent[]
|
|
261
|
-
): TimeInterval[] {
|
|
262
|
-
if (!intervals.length) return [];
|
|
263
|
-
console.log(`Subtracting ${events.length} blocking events`);
|
|
264
|
-
|
|
265
|
-
// Filter only blocking-type events
|
|
266
|
-
const blockingEvents = events.filter(
|
|
267
|
-
(event) =>
|
|
268
|
-
event.eventType === CalendarEventType.BLOCKING ||
|
|
269
|
-
event.eventType === CalendarEventType.BREAK ||
|
|
270
|
-
event.eventType === CalendarEventType.FREE_DAY
|
|
271
|
-
);
|
|
272
|
-
|
|
273
|
-
let result = [...intervals];
|
|
274
|
-
|
|
275
|
-
// For each blocking event, subtract its time from the available intervals
|
|
276
|
-
for (const event of blockingEvents) {
|
|
277
|
-
const { start, end } = event.eventTime;
|
|
278
|
-
const blockingInterval = { start, end };
|
|
279
|
-
|
|
280
|
-
// Create a new result array by subtracting the blocking interval from each available interval
|
|
281
|
-
const newResult: TimeInterval[] = [];
|
|
282
|
-
|
|
283
|
-
for (const interval of result) {
|
|
284
|
-
const remainingIntervals = this.subtractInterval(
|
|
285
|
-
interval,
|
|
286
|
-
blockingInterval
|
|
287
|
-
);
|
|
288
|
-
newResult.push(...remainingIntervals);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
result = newResult;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
return result;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* Apply practitioner's specific working hours for the given clinic
|
|
299
|
-
*
|
|
300
|
-
* @param intervals - Current available intervals
|
|
301
|
-
* @param practitioner - Practitioner object
|
|
302
|
-
* @param clinicId - ID of the clinic
|
|
303
|
-
* @param timeframe - Overall timeframe being considered
|
|
304
|
-
* @param tz - IANA timezone of the clinic
|
|
305
|
-
* @returns Intervals filtered by practitioner's working hours
|
|
306
|
-
*/
|
|
307
|
-
private static applyPractitionerWorkingHours(
|
|
308
|
-
intervals: TimeInterval[],
|
|
309
|
-
practitioner: any,
|
|
310
|
-
clinicId: string,
|
|
311
|
-
timeframe: { start: Timestamp; end: Timestamp },
|
|
312
|
-
tz: string
|
|
313
|
-
): TimeInterval[] {
|
|
314
|
-
if (!intervals.length) return [];
|
|
315
|
-
console.log(`Applying practitioner working hours for clinic ${clinicId}`);
|
|
316
|
-
|
|
317
|
-
// Find practitioner's working hours for this specific clinic
|
|
318
|
-
const clinicWorkingHours = practitioner.clinicWorkingHours.find(
|
|
319
|
-
(hours: PractitionerClinicWorkingHours) =>
|
|
320
|
-
hours.clinicId === clinicId && hours.isActive
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
// If no specific working hours are found, practitioner isn't available at this clinic
|
|
324
|
-
if (!clinicWorkingHours) {
|
|
325
|
-
console.log(
|
|
326
|
-
`No working hours found for practitioner at clinic ${clinicId}`
|
|
327
|
-
);
|
|
328
|
-
return [];
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Create working intervals for each day in the timeframe based on practitioner's working hours
|
|
332
|
-
const workingIntervals = this.createPractitionerWorkingHoursIntervals(
|
|
333
|
-
clinicWorkingHours.workingHours,
|
|
334
|
-
timeframe.start.toDate(),
|
|
335
|
-
timeframe.end.toDate(),
|
|
336
|
-
tz
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
// Intersect the available intervals with practitioner's working hours intervals
|
|
340
|
-
return this.intersectIntervals(intervals, workingIntervals);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* Create time intervals for practitioner's working hours across multiple days
|
|
345
|
-
*
|
|
346
|
-
* @param workingHours - Practitioner's working hours definition
|
|
347
|
-
* @param startDate - Start date of the overall timeframe
|
|
348
|
-
* @param endDate - End date of the overall timeframe
|
|
349
|
-
* @param tz - IANA timezone of the clinic
|
|
350
|
-
* @returns Array of time intervals representing practitioner's working hours
|
|
351
|
-
*/
|
|
352
|
-
private static createPractitionerWorkingHoursIntervals(
|
|
353
|
-
workingHours: any,
|
|
354
|
-
startDate: Date,
|
|
355
|
-
endDate: Date,
|
|
356
|
-
tz: string
|
|
357
|
-
): TimeInterval[] {
|
|
358
|
-
const workingIntervals: TimeInterval[] = [];
|
|
359
|
-
// FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
|
|
360
|
-
let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
|
|
361
|
-
const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
|
|
362
|
-
|
|
363
|
-
while (start <= end) {
|
|
364
|
-
const dayOfWeek = start.weekday;
|
|
365
|
-
const dayName = [
|
|
366
|
-
"monday",
|
|
367
|
-
"tuesday",
|
|
368
|
-
"wednesday",
|
|
369
|
-
"thursday",
|
|
370
|
-
"friday",
|
|
371
|
-
"saturday",
|
|
372
|
-
"sunday",
|
|
373
|
-
][dayOfWeek - 1];
|
|
374
|
-
|
|
375
|
-
if (dayName && workingHours[dayName]) {
|
|
376
|
-
const daySchedule = workingHours[dayName];
|
|
377
|
-
if (daySchedule) {
|
|
378
|
-
const [startHours, startMinutes] = daySchedule.start
|
|
379
|
-
.split(":")
|
|
380
|
-
.map(Number);
|
|
381
|
-
const [endHours, endMinutes] = daySchedule.end.split(":").map(Number);
|
|
382
|
-
|
|
383
|
-
const workStart = start.set({
|
|
384
|
-
hour: startHours,
|
|
385
|
-
minute: startMinutes,
|
|
386
|
-
});
|
|
387
|
-
const workEnd = start.set({ hour: endHours, minute: endMinutes });
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
workEnd.toMillis() > startDate.getTime() &&
|
|
391
|
-
workStart.toMillis() < endDate.getTime()
|
|
392
|
-
) {
|
|
393
|
-
// FIXED: Use fromMillis instead of fromJSDate
|
|
394
|
-
const intervalStart =
|
|
395
|
-
workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
396
|
-
? DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
397
|
-
: workStart;
|
|
398
|
-
const intervalEnd =
|
|
399
|
-
workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
400
|
-
? DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
401
|
-
: workEnd;
|
|
402
|
-
|
|
403
|
-
workingIntervals.push({
|
|
404
|
-
start: Timestamp.fromMillis(intervalStart.toMillis()),
|
|
405
|
-
end: Timestamp.fromMillis(intervalEnd.toMillis()),
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
start = start.plus({ days: 1 });
|
|
411
|
-
}
|
|
412
|
-
return workingIntervals;
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* Subtract practitioner's busy times from available intervals
|
|
417
|
-
*
|
|
418
|
-
* @param intervals - Current available intervals
|
|
419
|
-
* @param events - Practitioner's calendar events
|
|
420
|
-
* @returns Available intervals after removing busy times
|
|
421
|
-
*/
|
|
422
|
-
private static subtractPractitionerBusyTimes(
|
|
423
|
-
intervals: TimeInterval[],
|
|
424
|
-
events: CalendarEvent[]
|
|
425
|
-
): TimeInterval[] {
|
|
426
|
-
if (!intervals.length) return [];
|
|
427
|
-
console.log(`Subtracting ${events.length} practitioner events`);
|
|
428
|
-
|
|
429
|
-
// Filter events that make the practitioner busy (pending, confirmed, blocking)
|
|
430
|
-
const busyEvents = events.filter(
|
|
431
|
-
(event) =>
|
|
432
|
-
// Include all blocking events
|
|
433
|
-
event.eventType === CalendarEventType.BLOCKING ||
|
|
434
|
-
event.eventType === CalendarEventType.BREAK ||
|
|
435
|
-
event.eventType === CalendarEventType.FREE_DAY ||
|
|
436
|
-
// Include appointments that are pending, confirmed, or rescheduled
|
|
437
|
-
(event.eventType === CalendarEventType.APPOINTMENT &&
|
|
438
|
-
(event.status === CalendarEventStatus.PENDING ||
|
|
439
|
-
event.status === CalendarEventStatus.CONFIRMED ||
|
|
440
|
-
event.status === CalendarEventStatus.RESCHEDULED))
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
let result = [...intervals];
|
|
444
|
-
|
|
445
|
-
// For each busy event, subtract its time from the available intervals
|
|
446
|
-
for (const event of busyEvents) {
|
|
447
|
-
const { start, end } = event.eventTime;
|
|
448
|
-
const busyInterval = { start, end };
|
|
449
|
-
|
|
450
|
-
// Create a new result array by subtracting the busy interval from each available interval
|
|
451
|
-
const newResult: TimeInterval[] = [];
|
|
452
|
-
|
|
453
|
-
for (const interval of result) {
|
|
454
|
-
const remainingIntervals = this.subtractInterval(
|
|
455
|
-
interval,
|
|
456
|
-
busyInterval
|
|
457
|
-
);
|
|
458
|
-
newResult.push(...remainingIntervals);
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
result = newResult;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return result;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Generate available booking slots based on the final available intervals
|
|
469
|
-
*
|
|
470
|
-
* @param intervals - Final available intervals
|
|
471
|
-
* @param intervalMinutes - Scheduling interval in minutes
|
|
472
|
-
* @param durationMinutes - Procedure duration in minutes
|
|
473
|
-
* @param tz - IANA timezone of the clinic
|
|
474
|
-
* @returns Array of available booking slots
|
|
475
|
-
*/
|
|
476
|
-
private static generateAvailableSlots(
|
|
477
|
-
intervals: TimeInterval[],
|
|
478
|
-
intervalMinutes: number,
|
|
479
|
-
durationMinutes: number,
|
|
480
|
-
tz: string
|
|
481
|
-
): AvailableSlot[] {
|
|
482
|
-
const slots: AvailableSlot[] = [];
|
|
483
|
-
console.log(
|
|
484
|
-
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
485
|
-
);
|
|
486
|
-
|
|
487
|
-
// Get current time in clinic timezone
|
|
488
|
-
const nowInClinicTz = DateTime.now().setZone(tz);
|
|
489
|
-
// Add minimum booking window (15 minutes from now)
|
|
490
|
-
const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
|
|
491
|
-
const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
|
|
492
|
-
|
|
493
|
-
console.log(
|
|
494
|
-
`Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
// Convert duration to milliseconds
|
|
498
|
-
const durationMs = durationMinutes * 60 * 1000;
|
|
499
|
-
// Convert interval to milliseconds
|
|
500
|
-
const intervalMs = intervalMinutes * 60 * 1000;
|
|
501
|
-
|
|
502
|
-
// For each available interval
|
|
503
|
-
for (const interval of intervals) {
|
|
504
|
-
// Convert timestamps to JS Date objects for easier manipulation
|
|
505
|
-
const intervalStart = interval.start.toDate();
|
|
506
|
-
const intervalEnd = interval.end.toDate();
|
|
507
|
-
|
|
508
|
-
// Start at the beginning of the interval IN CLINIC TIMEZONE
|
|
509
|
-
let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
510
|
-
|
|
511
|
-
// Adjust slotStart to the nearest interval boundary if needed
|
|
512
|
-
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
513
|
-
const minutesRemainder = minutesIntoDay % intervalMinutes;
|
|
514
|
-
|
|
515
|
-
if (minutesRemainder > 0) {
|
|
516
|
-
slotStart = slotStart.plus({
|
|
517
|
-
minutes: intervalMinutes - minutesRemainder,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Iterate through potential start times
|
|
522
|
-
while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
|
|
523
|
-
// Calculate potential end time
|
|
524
|
-
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
525
|
-
|
|
526
|
-
// ✅ CRITICAL FIX: Filter out past slots and slots too close to now
|
|
527
|
-
const isInFuture = slotStart >= earliestBookableTime;
|
|
528
|
-
|
|
529
|
-
// Check if this slot fits entirely within one of our available intervals AND is in the future
|
|
530
|
-
if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
531
|
-
slots.push({
|
|
532
|
-
start: Timestamp.fromMillis(slotStart.toMillis()),
|
|
533
|
-
});
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
// Move to the next potential start time
|
|
537
|
-
slotStart = slotStart.plus({ minutes: intervalMinutes });
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
|
|
542
|
-
return slots;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* Check if a time slot is fully available within the given intervals
|
|
547
|
-
*
|
|
548
|
-
* @param slotStart - Start time of the slot
|
|
549
|
-
* @param slotEnd - End time of the slot
|
|
550
|
-
* @param intervals - Available intervals
|
|
551
|
-
* @param tz - IANA timezone of the clinic
|
|
552
|
-
* @returns True if the slot is fully contained within an available interval
|
|
553
|
-
*/
|
|
554
|
-
private static isSlotFullyAvailable(
|
|
555
|
-
slotStart: DateTime,
|
|
556
|
-
slotEnd: DateTime,
|
|
557
|
-
intervals: TimeInterval[],
|
|
558
|
-
tz: string
|
|
559
|
-
): boolean {
|
|
560
|
-
// Check if the slot is fully contained in any of the available intervals
|
|
561
|
-
return intervals.some((interval) => {
|
|
562
|
-
const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
563
|
-
const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
564
|
-
|
|
565
|
-
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Intersect two sets of time intervals
|
|
571
|
-
*
|
|
572
|
-
* @param intervalsA - First set of intervals
|
|
573
|
-
* @param intervalsB - Second set of intervals
|
|
574
|
-
* @returns Intersection of the two sets of intervals
|
|
575
|
-
*/
|
|
576
|
-
private static intersectIntervals(
|
|
577
|
-
intervalsA: TimeInterval[],
|
|
578
|
-
intervalsB: TimeInterval[]
|
|
579
|
-
): TimeInterval[] {
|
|
580
|
-
const result: TimeInterval[] = [];
|
|
581
|
-
|
|
582
|
-
// For each pair of intervals, find their intersection
|
|
583
|
-
for (const intervalA of intervalsA) {
|
|
584
|
-
for (const intervalB of intervalsB) {
|
|
585
|
-
// Find the later of the two start times
|
|
586
|
-
const intersectionStart =
|
|
587
|
-
intervalA.start.toMillis() > intervalB.start.toMillis()
|
|
588
|
-
? intervalA.start
|
|
589
|
-
: intervalB.start;
|
|
590
|
-
|
|
591
|
-
// Find the earlier of the two end times
|
|
592
|
-
const intersectionEnd =
|
|
593
|
-
intervalA.end.toMillis() < intervalB.end.toMillis()
|
|
594
|
-
? intervalA.end
|
|
595
|
-
: intervalB.end;
|
|
596
|
-
|
|
597
|
-
// If there is a valid intersection, add it to the result
|
|
598
|
-
if (intersectionStart.toMillis() < intersectionEnd.toMillis()) {
|
|
599
|
-
result.push({
|
|
600
|
-
start: intersectionStart,
|
|
601
|
-
end: intersectionEnd,
|
|
602
|
-
});
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
return this.mergeOverlappingIntervals(result);
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* Subtract one interval from another, potentially resulting in 0, 1, or 2 intervals
|
|
612
|
-
*
|
|
613
|
-
* @param interval - Interval to subtract from
|
|
614
|
-
* @param subtrahend - Interval to subtract
|
|
615
|
-
* @returns Array of remaining intervals after subtraction
|
|
616
|
-
*/
|
|
617
|
-
private static subtractInterval(
|
|
618
|
-
interval: TimeInterval,
|
|
619
|
-
subtrahend: TimeInterval
|
|
620
|
-
): TimeInterval[] {
|
|
621
|
-
// Case 1: No overlap - return the original interval
|
|
622
|
-
if (
|
|
623
|
-
interval.end.toMillis() <= subtrahend.start.toMillis() ||
|
|
624
|
-
interval.start.toMillis() >= subtrahend.end.toMillis()
|
|
625
|
-
) {
|
|
626
|
-
return [interval];
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
// Case 2: Subtrahend covers the entire interval - return empty array
|
|
630
|
-
if (
|
|
631
|
-
subtrahend.start.toMillis() <= interval.start.toMillis() &&
|
|
632
|
-
subtrahend.end.toMillis() >= interval.end.toMillis()
|
|
633
|
-
) {
|
|
634
|
-
return [];
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
// Case 3: Subtrahend splits the interval - return two intervals
|
|
638
|
-
if (
|
|
639
|
-
subtrahend.start.toMillis() > interval.start.toMillis() &&
|
|
640
|
-
subtrahend.end.toMillis() < interval.end.toMillis()
|
|
641
|
-
) {
|
|
642
|
-
return [
|
|
643
|
-
{
|
|
644
|
-
start: interval.start,
|
|
645
|
-
end: subtrahend.start,
|
|
646
|
-
},
|
|
647
|
-
{
|
|
648
|
-
start: subtrahend.end,
|
|
649
|
-
end: interval.end,
|
|
650
|
-
},
|
|
651
|
-
];
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
// Case 4: Subtrahend overlaps only the start - return the remaining end portion
|
|
655
|
-
if (
|
|
656
|
-
subtrahend.start.toMillis() <= interval.start.toMillis() &&
|
|
657
|
-
subtrahend.end.toMillis() > interval.start.toMillis()
|
|
658
|
-
) {
|
|
659
|
-
return [
|
|
660
|
-
{
|
|
661
|
-
start: subtrahend.end,
|
|
662
|
-
end: interval.end,
|
|
663
|
-
},
|
|
664
|
-
];
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
// Case 5: Subtrahend overlaps only the end - return the remaining start portion
|
|
668
|
-
return [
|
|
669
|
-
{
|
|
670
|
-
start: interval.start,
|
|
671
|
-
end: subtrahend.start,
|
|
672
|
-
},
|
|
673
|
-
];
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/**
|
|
677
|
-
* Merge overlapping intervals to simplify the result
|
|
678
|
-
*
|
|
679
|
-
* @param intervals - Intervals to merge
|
|
680
|
-
* @returns Merged intervals
|
|
681
|
-
*/
|
|
682
|
-
private static mergeOverlappingIntervals(
|
|
683
|
-
intervals: TimeInterval[]
|
|
684
|
-
): TimeInterval[] {
|
|
685
|
-
if (intervals.length <= 1) return intervals;
|
|
686
|
-
|
|
687
|
-
// Sort intervals by start time
|
|
688
|
-
const sorted = [...intervals].sort(
|
|
689
|
-
(a, b) => a.start.toMillis() - b.start.toMillis()
|
|
690
|
-
);
|
|
691
|
-
|
|
692
|
-
const result: TimeInterval[] = [sorted[0]];
|
|
693
|
-
|
|
694
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
695
|
-
const current = sorted[i];
|
|
696
|
-
const lastResult = result[result.length - 1];
|
|
697
|
-
|
|
698
|
-
// If current interval overlaps with the last result interval, merge them
|
|
699
|
-
if (current.start.toMillis() <= lastResult.end.toMillis()) {
|
|
700
|
-
// Update the end time of the last result to be the maximum of the two end times
|
|
701
|
-
if (current.end.toMillis() > lastResult.end.toMillis()) {
|
|
702
|
-
lastResult.end = current.end;
|
|
703
|
-
}
|
|
704
|
-
} else {
|
|
705
|
-
// No overlap, add the current interval to the result
|
|
706
|
-
result.push(current);
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
return result;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
1
|
+
import { Timestamp } from "firebase/firestore";
|
|
2
|
+
import { DateTime } from "luxon";
|
|
3
|
+
import {
|
|
4
|
+
BookingAvailabilityRequest,
|
|
5
|
+
BookingAvailabilityResponse,
|
|
6
|
+
AvailableSlot,
|
|
7
|
+
TimeInterval,
|
|
8
|
+
} from "./booking.types";
|
|
9
|
+
import {
|
|
10
|
+
CalendarEvent,
|
|
11
|
+
CalendarEventStatus,
|
|
12
|
+
CalendarEventType,
|
|
13
|
+
} from "../../types/calendar";
|
|
14
|
+
import { PractitionerClinicWorkingHours } from "../../types/practitioner";
|
|
15
|
+
import { Clinic } from "../../types/clinic";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Calculator for determining available booking slots
|
|
19
|
+
* This class handles the complex logic of determining when appointments can be scheduled
|
|
20
|
+
* based on clinic working hours, practitioner availability, and existing calendar events.
|
|
21
|
+
*/
|
|
22
|
+
export class BookingAvailabilityCalculator {
|
|
23
|
+
/** Default scheduling interval in minutes if not specified by the clinic */
|
|
24
|
+
private static readonly DEFAULT_INTERVAL_MINUTES = 15;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Calculate available booking slots based on the provided data
|
|
28
|
+
*
|
|
29
|
+
* @param request - The request containing all necessary data for calculation
|
|
30
|
+
* @returns Response with available booking slots
|
|
31
|
+
*/
|
|
32
|
+
public static calculateSlots(
|
|
33
|
+
request: BookingAvailabilityRequest
|
|
34
|
+
): BookingAvailabilityResponse {
|
|
35
|
+
// Extract necessary data from the request
|
|
36
|
+
const {
|
|
37
|
+
clinic,
|
|
38
|
+
practitioner,
|
|
39
|
+
procedure,
|
|
40
|
+
timeframe,
|
|
41
|
+
clinicCalendarEvents,
|
|
42
|
+
practitionerCalendarEvents,
|
|
43
|
+
tz,
|
|
44
|
+
} = request;
|
|
45
|
+
|
|
46
|
+
// Get scheduling interval (default to 15 minutes if not specified)
|
|
47
|
+
const schedulingIntervalMinutes =
|
|
48
|
+
(clinic as any).schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
|
|
49
|
+
|
|
50
|
+
// Get procedure duration in minutes
|
|
51
|
+
const procedureDurationMinutes = procedure.duration;
|
|
52
|
+
|
|
53
|
+
console.log(
|
|
54
|
+
`Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Start with the full timeframe as initially available
|
|
58
|
+
let availableIntervals: TimeInterval[] = [
|
|
59
|
+
{ start: timeframe.start, end: timeframe.end },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
// Step 1: Apply clinic working hours
|
|
63
|
+
availableIntervals = this.applyClinicWorkingHours(
|
|
64
|
+
availableIntervals,
|
|
65
|
+
clinic.workingHours,
|
|
66
|
+
timeframe,
|
|
67
|
+
tz
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// Step 2: Subtract clinic blocking events
|
|
71
|
+
availableIntervals = this.subtractBlockingEvents(
|
|
72
|
+
availableIntervals,
|
|
73
|
+
clinicCalendarEvents
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
// Step 3: Apply practitioner's working hours for this clinic
|
|
77
|
+
availableIntervals = this.applyPractitionerWorkingHours(
|
|
78
|
+
availableIntervals,
|
|
79
|
+
practitioner,
|
|
80
|
+
clinic.id,
|
|
81
|
+
timeframe,
|
|
82
|
+
tz
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
// Step 4: Subtract practitioner's busy times
|
|
86
|
+
availableIntervals = this.subtractPractitionerBusyTimes(
|
|
87
|
+
availableIntervals,
|
|
88
|
+
practitionerCalendarEvents
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
console.log(
|
|
92
|
+
`After all filters, have ${availableIntervals.length} available intervals`
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Step 5: Generate available slots based on scheduling interval and procedure duration
|
|
96
|
+
const availableSlots = this.generateAvailableSlots(
|
|
97
|
+
availableIntervals,
|
|
98
|
+
schedulingIntervalMinutes,
|
|
99
|
+
procedureDurationMinutes,
|
|
100
|
+
tz
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return { availableSlots };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Apply clinic working hours to available intervals
|
|
108
|
+
*
|
|
109
|
+
* @param intervals - Current available intervals
|
|
110
|
+
* @param workingHours - Clinic working hours
|
|
111
|
+
* @param timeframe - Overall timeframe being considered
|
|
112
|
+
* @param tz - IANA timezone of the clinic
|
|
113
|
+
* @returns Intervals filtered by clinic working hours
|
|
114
|
+
*/
|
|
115
|
+
private static applyClinicWorkingHours(
|
|
116
|
+
intervals: TimeInterval[],
|
|
117
|
+
workingHours: any, // Using 'any' for now since we're working with the existing type structure
|
|
118
|
+
timeframe: { start: Timestamp; end: Timestamp },
|
|
119
|
+
tz: string
|
|
120
|
+
): TimeInterval[] {
|
|
121
|
+
if (!intervals.length) return [];
|
|
122
|
+
console.log(
|
|
123
|
+
`Applying clinic working hours to ${intervals.length} intervals`
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
// Create working intervals for each day in the timeframe based on clinic's working hours
|
|
127
|
+
const workingIntervals = this.createWorkingHoursIntervals(
|
|
128
|
+
workingHours,
|
|
129
|
+
timeframe.start.toDate(),
|
|
130
|
+
timeframe.end.toDate(),
|
|
131
|
+
tz
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Intersect the available intervals with working hours intervals
|
|
135
|
+
return this.intersectIntervals(intervals, workingIntervals);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Create time intervals for working hours across multiple days
|
|
140
|
+
*
|
|
141
|
+
* @param workingHours - Working hours definition
|
|
142
|
+
* @param startDate - Start date of the overall timeframe
|
|
143
|
+
* @param endDate - End date of the overall timeframe
|
|
144
|
+
* @param tz - IANA timezone of the clinic
|
|
145
|
+
* @returns Array of time intervals representing working hours
|
|
146
|
+
*/
|
|
147
|
+
private static createWorkingHoursIntervals(
|
|
148
|
+
workingHours: any,
|
|
149
|
+
startDate: Date,
|
|
150
|
+
endDate: Date,
|
|
151
|
+
tz: string
|
|
152
|
+
): TimeInterval[] {
|
|
153
|
+
const workingIntervals: TimeInterval[] = [];
|
|
154
|
+
// FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
|
|
155
|
+
let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
|
|
156
|
+
const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
|
|
157
|
+
|
|
158
|
+
while (start <= end) {
|
|
159
|
+
const dayOfWeek = start.weekday; // 1 for Monday, 7 for Sunday
|
|
160
|
+
const dayName = [
|
|
161
|
+
"monday",
|
|
162
|
+
"tuesday",
|
|
163
|
+
"wednesday",
|
|
164
|
+
"thursday",
|
|
165
|
+
"friday",
|
|
166
|
+
"saturday",
|
|
167
|
+
"sunday",
|
|
168
|
+
][dayOfWeek - 1];
|
|
169
|
+
|
|
170
|
+
if (dayName && workingHours[dayName]) {
|
|
171
|
+
const daySchedule = workingHours[dayName];
|
|
172
|
+
if (daySchedule) {
|
|
173
|
+
const [openHours, openMinutes] = daySchedule.open
|
|
174
|
+
.split(":")
|
|
175
|
+
.map(Number);
|
|
176
|
+
const [closeHours, closeMinutes] = daySchedule.close
|
|
177
|
+
.split(":")
|
|
178
|
+
.map(Number);
|
|
179
|
+
|
|
180
|
+
let workStart = start.set({
|
|
181
|
+
hour: openHours,
|
|
182
|
+
minute: openMinutes,
|
|
183
|
+
second: 0,
|
|
184
|
+
millisecond: 0,
|
|
185
|
+
});
|
|
186
|
+
let workEnd = start.set({
|
|
187
|
+
hour: closeHours,
|
|
188
|
+
minute: closeMinutes,
|
|
189
|
+
second: 0,
|
|
190
|
+
millisecond: 0,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (
|
|
194
|
+
workEnd.toMillis() > startDate.getTime() &&
|
|
195
|
+
workStart.toMillis() < endDate.getTime()
|
|
196
|
+
) {
|
|
197
|
+
// FIXED: Use fromMillis instead of fromJSDate
|
|
198
|
+
const intervalStart =
|
|
199
|
+
workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
200
|
+
? DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
201
|
+
: workStart;
|
|
202
|
+
const intervalEnd =
|
|
203
|
+
workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
204
|
+
? DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
205
|
+
: workEnd;
|
|
206
|
+
|
|
207
|
+
workingIntervals.push({
|
|
208
|
+
start: Timestamp.fromMillis(intervalStart.toMillis()),
|
|
209
|
+
end: Timestamp.fromMillis(intervalEnd.toMillis()),
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
if (daySchedule.breaks && daySchedule.breaks.length > 0) {
|
|
213
|
+
for (const breakTime of daySchedule.breaks) {
|
|
214
|
+
const [breakStartHours, breakStartMinutes] = breakTime.start
|
|
215
|
+
.split(":")
|
|
216
|
+
.map(Number);
|
|
217
|
+
const [breakEndHours, breakEndMinutes] = breakTime.end
|
|
218
|
+
.split(":")
|
|
219
|
+
.map(Number);
|
|
220
|
+
|
|
221
|
+
const breakStart = start.set({
|
|
222
|
+
hour: breakStartHours,
|
|
223
|
+
minute: breakStartMinutes,
|
|
224
|
+
});
|
|
225
|
+
const breakEnd = start.set({
|
|
226
|
+
hour: breakEndHours,
|
|
227
|
+
minute: breakEndMinutes,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
workingIntervals.splice(
|
|
231
|
+
-1,
|
|
232
|
+
1,
|
|
233
|
+
...this.subtractInterval(
|
|
234
|
+
workingIntervals[workingIntervals.length - 1],
|
|
235
|
+
{
|
|
236
|
+
start: Timestamp.fromMillis(breakStart.toMillis()),
|
|
237
|
+
end: Timestamp.fromMillis(breakEnd.toMillis()),
|
|
238
|
+
}
|
|
239
|
+
)
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
start = start.plus({ days: 1 });
|
|
247
|
+
}
|
|
248
|
+
return workingIntervals;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Subtract blocking events from available intervals
|
|
253
|
+
*
|
|
254
|
+
* @param intervals - Current available intervals
|
|
255
|
+
* @param events - Calendar events to subtract
|
|
256
|
+
* @returns Available intervals after removing blocking events
|
|
257
|
+
*/
|
|
258
|
+
private static subtractBlockingEvents(
|
|
259
|
+
intervals: TimeInterval[],
|
|
260
|
+
events: CalendarEvent[]
|
|
261
|
+
): TimeInterval[] {
|
|
262
|
+
if (!intervals.length) return [];
|
|
263
|
+
console.log(`Subtracting ${events.length} blocking events`);
|
|
264
|
+
|
|
265
|
+
// Filter only blocking-type events
|
|
266
|
+
const blockingEvents = events.filter(
|
|
267
|
+
(event) =>
|
|
268
|
+
event.eventType === CalendarEventType.BLOCKING ||
|
|
269
|
+
event.eventType === CalendarEventType.BREAK ||
|
|
270
|
+
event.eventType === CalendarEventType.FREE_DAY
|
|
271
|
+
);
|
|
272
|
+
|
|
273
|
+
let result = [...intervals];
|
|
274
|
+
|
|
275
|
+
// For each blocking event, subtract its time from the available intervals
|
|
276
|
+
for (const event of blockingEvents) {
|
|
277
|
+
const { start, end } = event.eventTime;
|
|
278
|
+
const blockingInterval = { start, end };
|
|
279
|
+
|
|
280
|
+
// Create a new result array by subtracting the blocking interval from each available interval
|
|
281
|
+
const newResult: TimeInterval[] = [];
|
|
282
|
+
|
|
283
|
+
for (const interval of result) {
|
|
284
|
+
const remainingIntervals = this.subtractInterval(
|
|
285
|
+
interval,
|
|
286
|
+
blockingInterval
|
|
287
|
+
);
|
|
288
|
+
newResult.push(...remainingIntervals);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
result = newResult;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return result;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Apply practitioner's specific working hours for the given clinic
|
|
299
|
+
*
|
|
300
|
+
* @param intervals - Current available intervals
|
|
301
|
+
* @param practitioner - Practitioner object
|
|
302
|
+
* @param clinicId - ID of the clinic
|
|
303
|
+
* @param timeframe - Overall timeframe being considered
|
|
304
|
+
* @param tz - IANA timezone of the clinic
|
|
305
|
+
* @returns Intervals filtered by practitioner's working hours
|
|
306
|
+
*/
|
|
307
|
+
private static applyPractitionerWorkingHours(
|
|
308
|
+
intervals: TimeInterval[],
|
|
309
|
+
practitioner: any,
|
|
310
|
+
clinicId: string,
|
|
311
|
+
timeframe: { start: Timestamp; end: Timestamp },
|
|
312
|
+
tz: string
|
|
313
|
+
): TimeInterval[] {
|
|
314
|
+
if (!intervals.length) return [];
|
|
315
|
+
console.log(`Applying practitioner working hours for clinic ${clinicId}`);
|
|
316
|
+
|
|
317
|
+
// Find practitioner's working hours for this specific clinic
|
|
318
|
+
const clinicWorkingHours = practitioner.clinicWorkingHours.find(
|
|
319
|
+
(hours: PractitionerClinicWorkingHours) =>
|
|
320
|
+
hours.clinicId === clinicId && hours.isActive
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
// If no specific working hours are found, practitioner isn't available at this clinic
|
|
324
|
+
if (!clinicWorkingHours) {
|
|
325
|
+
console.log(
|
|
326
|
+
`No working hours found for practitioner at clinic ${clinicId}`
|
|
327
|
+
);
|
|
328
|
+
return [];
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Create working intervals for each day in the timeframe based on practitioner's working hours
|
|
332
|
+
const workingIntervals = this.createPractitionerWorkingHoursIntervals(
|
|
333
|
+
clinicWorkingHours.workingHours,
|
|
334
|
+
timeframe.start.toDate(),
|
|
335
|
+
timeframe.end.toDate(),
|
|
336
|
+
tz
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// Intersect the available intervals with practitioner's working hours intervals
|
|
340
|
+
return this.intersectIntervals(intervals, workingIntervals);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Create time intervals for practitioner's working hours across multiple days
|
|
345
|
+
*
|
|
346
|
+
* @param workingHours - Practitioner's working hours definition
|
|
347
|
+
* @param startDate - Start date of the overall timeframe
|
|
348
|
+
* @param endDate - End date of the overall timeframe
|
|
349
|
+
* @param tz - IANA timezone of the clinic
|
|
350
|
+
* @returns Array of time intervals representing practitioner's working hours
|
|
351
|
+
*/
|
|
352
|
+
private static createPractitionerWorkingHoursIntervals(
|
|
353
|
+
workingHours: any,
|
|
354
|
+
startDate: Date,
|
|
355
|
+
endDate: Date,
|
|
356
|
+
tz: string
|
|
357
|
+
): TimeInterval[] {
|
|
358
|
+
const workingIntervals: TimeInterval[] = [];
|
|
359
|
+
// FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
|
|
360
|
+
let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
|
|
361
|
+
const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
|
|
362
|
+
|
|
363
|
+
while (start <= end) {
|
|
364
|
+
const dayOfWeek = start.weekday;
|
|
365
|
+
const dayName = [
|
|
366
|
+
"monday",
|
|
367
|
+
"tuesday",
|
|
368
|
+
"wednesday",
|
|
369
|
+
"thursday",
|
|
370
|
+
"friday",
|
|
371
|
+
"saturday",
|
|
372
|
+
"sunday",
|
|
373
|
+
][dayOfWeek - 1];
|
|
374
|
+
|
|
375
|
+
if (dayName && workingHours[dayName]) {
|
|
376
|
+
const daySchedule = workingHours[dayName];
|
|
377
|
+
if (daySchedule) {
|
|
378
|
+
const [startHours, startMinutes] = daySchedule.start
|
|
379
|
+
.split(":")
|
|
380
|
+
.map(Number);
|
|
381
|
+
const [endHours, endMinutes] = daySchedule.end.split(":").map(Number);
|
|
382
|
+
|
|
383
|
+
const workStart = start.set({
|
|
384
|
+
hour: startHours,
|
|
385
|
+
minute: startMinutes,
|
|
386
|
+
});
|
|
387
|
+
const workEnd = start.set({ hour: endHours, minute: endMinutes });
|
|
388
|
+
|
|
389
|
+
if (
|
|
390
|
+
workEnd.toMillis() > startDate.getTime() &&
|
|
391
|
+
workStart.toMillis() < endDate.getTime()
|
|
392
|
+
) {
|
|
393
|
+
// FIXED: Use fromMillis instead of fromJSDate
|
|
394
|
+
const intervalStart =
|
|
395
|
+
workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
396
|
+
? DateTime.fromMillis(startDate.getTime(), { zone: tz })
|
|
397
|
+
: workStart;
|
|
398
|
+
const intervalEnd =
|
|
399
|
+
workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
400
|
+
? DateTime.fromMillis(endDate.getTime(), { zone: tz })
|
|
401
|
+
: workEnd;
|
|
402
|
+
|
|
403
|
+
workingIntervals.push({
|
|
404
|
+
start: Timestamp.fromMillis(intervalStart.toMillis()),
|
|
405
|
+
end: Timestamp.fromMillis(intervalEnd.toMillis()),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
start = start.plus({ days: 1 });
|
|
411
|
+
}
|
|
412
|
+
return workingIntervals;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Subtract practitioner's busy times from available intervals
|
|
417
|
+
*
|
|
418
|
+
* @param intervals - Current available intervals
|
|
419
|
+
* @param events - Practitioner's calendar events
|
|
420
|
+
* @returns Available intervals after removing busy times
|
|
421
|
+
*/
|
|
422
|
+
private static subtractPractitionerBusyTimes(
|
|
423
|
+
intervals: TimeInterval[],
|
|
424
|
+
events: CalendarEvent[]
|
|
425
|
+
): TimeInterval[] {
|
|
426
|
+
if (!intervals.length) return [];
|
|
427
|
+
console.log(`Subtracting ${events.length} practitioner events`);
|
|
428
|
+
|
|
429
|
+
// Filter events that make the practitioner busy (pending, confirmed, blocking)
|
|
430
|
+
const busyEvents = events.filter(
|
|
431
|
+
(event) =>
|
|
432
|
+
// Include all blocking events
|
|
433
|
+
event.eventType === CalendarEventType.BLOCKING ||
|
|
434
|
+
event.eventType === CalendarEventType.BREAK ||
|
|
435
|
+
event.eventType === CalendarEventType.FREE_DAY ||
|
|
436
|
+
// Include appointments that are pending, confirmed, or rescheduled
|
|
437
|
+
(event.eventType === CalendarEventType.APPOINTMENT &&
|
|
438
|
+
(event.status === CalendarEventStatus.PENDING ||
|
|
439
|
+
event.status === CalendarEventStatus.CONFIRMED ||
|
|
440
|
+
event.status === CalendarEventStatus.RESCHEDULED))
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
let result = [...intervals];
|
|
444
|
+
|
|
445
|
+
// For each busy event, subtract its time from the available intervals
|
|
446
|
+
for (const event of busyEvents) {
|
|
447
|
+
const { start, end } = event.eventTime;
|
|
448
|
+
const busyInterval = { start, end };
|
|
449
|
+
|
|
450
|
+
// Create a new result array by subtracting the busy interval from each available interval
|
|
451
|
+
const newResult: TimeInterval[] = [];
|
|
452
|
+
|
|
453
|
+
for (const interval of result) {
|
|
454
|
+
const remainingIntervals = this.subtractInterval(
|
|
455
|
+
interval,
|
|
456
|
+
busyInterval
|
|
457
|
+
);
|
|
458
|
+
newResult.push(...remainingIntervals);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
result = newResult;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return result;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Generate available booking slots based on the final available intervals
|
|
469
|
+
*
|
|
470
|
+
* @param intervals - Final available intervals
|
|
471
|
+
* @param intervalMinutes - Scheduling interval in minutes
|
|
472
|
+
* @param durationMinutes - Procedure duration in minutes
|
|
473
|
+
* @param tz - IANA timezone of the clinic
|
|
474
|
+
* @returns Array of available booking slots
|
|
475
|
+
*/
|
|
476
|
+
private static generateAvailableSlots(
|
|
477
|
+
intervals: TimeInterval[],
|
|
478
|
+
intervalMinutes: number,
|
|
479
|
+
durationMinutes: number,
|
|
480
|
+
tz: string
|
|
481
|
+
): AvailableSlot[] {
|
|
482
|
+
const slots: AvailableSlot[] = [];
|
|
483
|
+
console.log(
|
|
484
|
+
`Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
// Get current time in clinic timezone
|
|
488
|
+
const nowInClinicTz = DateTime.now().setZone(tz);
|
|
489
|
+
// Add minimum booking window (15 minutes from now)
|
|
490
|
+
const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
|
|
491
|
+
const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
|
|
492
|
+
|
|
493
|
+
console.log(
|
|
494
|
+
`Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
// Convert duration to milliseconds
|
|
498
|
+
const durationMs = durationMinutes * 60 * 1000;
|
|
499
|
+
// Convert interval to milliseconds
|
|
500
|
+
const intervalMs = intervalMinutes * 60 * 1000;
|
|
501
|
+
|
|
502
|
+
// For each available interval
|
|
503
|
+
for (const interval of intervals) {
|
|
504
|
+
// Convert timestamps to JS Date objects for easier manipulation
|
|
505
|
+
const intervalStart = interval.start.toDate();
|
|
506
|
+
const intervalEnd = interval.end.toDate();
|
|
507
|
+
|
|
508
|
+
// Start at the beginning of the interval IN CLINIC TIMEZONE
|
|
509
|
+
let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
|
|
510
|
+
|
|
511
|
+
// Adjust slotStart to the nearest interval boundary if needed
|
|
512
|
+
const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
|
|
513
|
+
const minutesRemainder = minutesIntoDay % intervalMinutes;
|
|
514
|
+
|
|
515
|
+
if (minutesRemainder > 0) {
|
|
516
|
+
slotStart = slotStart.plus({
|
|
517
|
+
minutes: intervalMinutes - minutesRemainder,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Iterate through potential start times
|
|
522
|
+
while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
|
|
523
|
+
// Calculate potential end time
|
|
524
|
+
const slotEnd = slotStart.plus({ minutes: durationMinutes });
|
|
525
|
+
|
|
526
|
+
// ✅ CRITICAL FIX: Filter out past slots and slots too close to now
|
|
527
|
+
const isInFuture = slotStart >= earliestBookableTime;
|
|
528
|
+
|
|
529
|
+
// Check if this slot fits entirely within one of our available intervals AND is in the future
|
|
530
|
+
if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
531
|
+
slots.push({
|
|
532
|
+
start: Timestamp.fromMillis(slotStart.toMillis()),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Move to the next potential start time
|
|
537
|
+
slotStart = slotStart.plus({ minutes: intervalMinutes });
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
|
|
542
|
+
return slots;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Check if a time slot is fully available within the given intervals
|
|
547
|
+
*
|
|
548
|
+
* @param slotStart - Start time of the slot
|
|
549
|
+
* @param slotEnd - End time of the slot
|
|
550
|
+
* @param intervals - Available intervals
|
|
551
|
+
* @param tz - IANA timezone of the clinic
|
|
552
|
+
* @returns True if the slot is fully contained within an available interval
|
|
553
|
+
*/
|
|
554
|
+
private static isSlotFullyAvailable(
|
|
555
|
+
slotStart: DateTime,
|
|
556
|
+
slotEnd: DateTime,
|
|
557
|
+
intervals: TimeInterval[],
|
|
558
|
+
tz: string
|
|
559
|
+
): boolean {
|
|
560
|
+
// Check if the slot is fully contained in any of the available intervals
|
|
561
|
+
return intervals.some((interval) => {
|
|
562
|
+
const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
|
|
563
|
+
const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
|
|
564
|
+
|
|
565
|
+
return slotStart >= intervalStart && slotEnd <= intervalEnd;
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Intersect two sets of time intervals
|
|
571
|
+
*
|
|
572
|
+
* @param intervalsA - First set of intervals
|
|
573
|
+
* @param intervalsB - Second set of intervals
|
|
574
|
+
* @returns Intersection of the two sets of intervals
|
|
575
|
+
*/
|
|
576
|
+
private static intersectIntervals(
|
|
577
|
+
intervalsA: TimeInterval[],
|
|
578
|
+
intervalsB: TimeInterval[]
|
|
579
|
+
): TimeInterval[] {
|
|
580
|
+
const result: TimeInterval[] = [];
|
|
581
|
+
|
|
582
|
+
// For each pair of intervals, find their intersection
|
|
583
|
+
for (const intervalA of intervalsA) {
|
|
584
|
+
for (const intervalB of intervalsB) {
|
|
585
|
+
// Find the later of the two start times
|
|
586
|
+
const intersectionStart =
|
|
587
|
+
intervalA.start.toMillis() > intervalB.start.toMillis()
|
|
588
|
+
? intervalA.start
|
|
589
|
+
: intervalB.start;
|
|
590
|
+
|
|
591
|
+
// Find the earlier of the two end times
|
|
592
|
+
const intersectionEnd =
|
|
593
|
+
intervalA.end.toMillis() < intervalB.end.toMillis()
|
|
594
|
+
? intervalA.end
|
|
595
|
+
: intervalB.end;
|
|
596
|
+
|
|
597
|
+
// If there is a valid intersection, add it to the result
|
|
598
|
+
if (intersectionStart.toMillis() < intersectionEnd.toMillis()) {
|
|
599
|
+
result.push({
|
|
600
|
+
start: intersectionStart,
|
|
601
|
+
end: intersectionEnd,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
return this.mergeOverlappingIntervals(result);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Subtract one interval from another, potentially resulting in 0, 1, or 2 intervals
|
|
612
|
+
*
|
|
613
|
+
* @param interval - Interval to subtract from
|
|
614
|
+
* @param subtrahend - Interval to subtract
|
|
615
|
+
* @returns Array of remaining intervals after subtraction
|
|
616
|
+
*/
|
|
617
|
+
private static subtractInterval(
|
|
618
|
+
interval: TimeInterval,
|
|
619
|
+
subtrahend: TimeInterval
|
|
620
|
+
): TimeInterval[] {
|
|
621
|
+
// Case 1: No overlap - return the original interval
|
|
622
|
+
if (
|
|
623
|
+
interval.end.toMillis() <= subtrahend.start.toMillis() ||
|
|
624
|
+
interval.start.toMillis() >= subtrahend.end.toMillis()
|
|
625
|
+
) {
|
|
626
|
+
return [interval];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Case 2: Subtrahend covers the entire interval - return empty array
|
|
630
|
+
if (
|
|
631
|
+
subtrahend.start.toMillis() <= interval.start.toMillis() &&
|
|
632
|
+
subtrahend.end.toMillis() >= interval.end.toMillis()
|
|
633
|
+
) {
|
|
634
|
+
return [];
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Case 3: Subtrahend splits the interval - return two intervals
|
|
638
|
+
if (
|
|
639
|
+
subtrahend.start.toMillis() > interval.start.toMillis() &&
|
|
640
|
+
subtrahend.end.toMillis() < interval.end.toMillis()
|
|
641
|
+
) {
|
|
642
|
+
return [
|
|
643
|
+
{
|
|
644
|
+
start: interval.start,
|
|
645
|
+
end: subtrahend.start,
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
start: subtrahend.end,
|
|
649
|
+
end: interval.end,
|
|
650
|
+
},
|
|
651
|
+
];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Case 4: Subtrahend overlaps only the start - return the remaining end portion
|
|
655
|
+
if (
|
|
656
|
+
subtrahend.start.toMillis() <= interval.start.toMillis() &&
|
|
657
|
+
subtrahend.end.toMillis() > interval.start.toMillis()
|
|
658
|
+
) {
|
|
659
|
+
return [
|
|
660
|
+
{
|
|
661
|
+
start: subtrahend.end,
|
|
662
|
+
end: interval.end,
|
|
663
|
+
},
|
|
664
|
+
];
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Case 5: Subtrahend overlaps only the end - return the remaining start portion
|
|
668
|
+
return [
|
|
669
|
+
{
|
|
670
|
+
start: interval.start,
|
|
671
|
+
end: subtrahend.start,
|
|
672
|
+
},
|
|
673
|
+
];
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Merge overlapping intervals to simplify the result
|
|
678
|
+
*
|
|
679
|
+
* @param intervals - Intervals to merge
|
|
680
|
+
* @returns Merged intervals
|
|
681
|
+
*/
|
|
682
|
+
private static mergeOverlappingIntervals(
|
|
683
|
+
intervals: TimeInterval[]
|
|
684
|
+
): TimeInterval[] {
|
|
685
|
+
if (intervals.length <= 1) return intervals;
|
|
686
|
+
|
|
687
|
+
// Sort intervals by start time
|
|
688
|
+
const sorted = [...intervals].sort(
|
|
689
|
+
(a, b) => a.start.toMillis() - b.start.toMillis()
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
const result: TimeInterval[] = [sorted[0]];
|
|
693
|
+
|
|
694
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
695
|
+
const current = sorted[i];
|
|
696
|
+
const lastResult = result[result.length - 1];
|
|
697
|
+
|
|
698
|
+
// If current interval overlaps with the last result interval, merge them
|
|
699
|
+
if (current.start.toMillis() <= lastResult.end.toMillis()) {
|
|
700
|
+
// Update the end time of the last result to be the maximum of the two end times
|
|
701
|
+
if (current.end.toMillis() > lastResult.end.toMillis()) {
|
|
702
|
+
lastResult.end = current.end;
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
// No overlap, add the current interval to the result
|
|
706
|
+
result.push(current);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return result;
|
|
711
|
+
}
|
|
712
|
+
}
|