@blackcode_sa/metaestetics-api 1.12.72 → 1.13.1
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 +872 -1
- package/dist/admin/index.d.ts +872 -1
- package/dist/admin/index.js +3604 -356
- package/dist/admin/index.mjs +3594 -357
- package/dist/index.d.mts +1349 -1
- package/dist/index.d.ts +1349 -1
- package/dist/index.js +5325 -2141
- package/dist/index.mjs +4939 -1767
- package/package.json +1 -1
- package/src/admin/analytics/analytics.admin.service.ts +278 -0
- package/src/admin/analytics/index.ts +2 -0
- package/src/admin/index.ts +6 -0
- package/src/backoffice/services/analytics.service.proposal.md +4 -0
- package/src/services/analytics/ARCHITECTURE.md +199 -0
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
- package/src/services/analytics/QUICK_START.md +393 -0
- package/src/services/analytics/README.md +304 -0
- package/src/services/analytics/SUMMARY.md +141 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/USAGE_GUIDE.md +518 -0
- package/src/services/analytics/analytics-cloud.service.ts +222 -0
- package/src/services/analytics/analytics.service.ts +2142 -0
- package/src/services/analytics/index.ts +4 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -0
- package/src/services/analytics/utils/grouping.utils.ts +434 -0
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/index.ts +1 -0
- package/src/types/analytics/analytics.types.ts +597 -0
- package/src/types/analytics/grouped-analytics.types.ts +173 -0
- package/src/types/analytics/index.ts +4 -0
- package/src/types/analytics/stored-analytics.types.ts +137 -0
- package/src/types/index.ts +3 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Firestore, Timestamp, doc, getDoc } from 'firebase/firestore';
|
|
2
|
+
import {
|
|
3
|
+
ANALYTICS_COLLECTION,
|
|
4
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
5
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
6
|
+
CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
7
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
8
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
9
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
10
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
11
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
12
|
+
AnalyticsPeriod,
|
|
13
|
+
ReadStoredAnalyticsOptions,
|
|
14
|
+
StoredPractitionerAnalytics,
|
|
15
|
+
StoredProcedureAnalytics,
|
|
16
|
+
StoredClinicAnalytics,
|
|
17
|
+
StoredDashboardAnalytics,
|
|
18
|
+
StoredTimeEfficiencyMetrics,
|
|
19
|
+
StoredCancellationMetrics,
|
|
20
|
+
StoredNoShowMetrics,
|
|
21
|
+
StoredRevenueMetrics,
|
|
22
|
+
} from '../../../types/analytics';
|
|
23
|
+
import { CLINICS_COLLECTION } from '../../../types/clinic';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Checks if stored analytics data is fresh enough to use
|
|
27
|
+
*
|
|
28
|
+
* @param computedAt - Timestamp when analytics were computed
|
|
29
|
+
* @param maxAgeHours - Maximum age in hours
|
|
30
|
+
* @returns True if data is fresh enough
|
|
31
|
+
*/
|
|
32
|
+
export function isAnalyticsDataFresh(computedAt: Timestamp, maxAgeHours: number): boolean {
|
|
33
|
+
const now = Timestamp.now();
|
|
34
|
+
const ageMs = now.toMillis() - computedAt.toMillis();
|
|
35
|
+
const ageHours = ageMs / (1000 * 60 * 60);
|
|
36
|
+
return ageHours <= maxAgeHours;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Reads stored analytics document
|
|
41
|
+
*
|
|
42
|
+
* @param db - Firestore instance
|
|
43
|
+
* @param clinicBranchId - Clinic branch ID
|
|
44
|
+
* @param subcollection - Analytics subcollection name
|
|
45
|
+
* @param documentId - Document ID
|
|
46
|
+
* @param period - Analytics period
|
|
47
|
+
* @returns Stored analytics document or null
|
|
48
|
+
*/
|
|
49
|
+
async function readStoredAnalytics<T>(
|
|
50
|
+
db: Firestore,
|
|
51
|
+
clinicBranchId: string,
|
|
52
|
+
subcollection: string,
|
|
53
|
+
documentId: string,
|
|
54
|
+
period: AnalyticsPeriod,
|
|
55
|
+
): Promise<T | null> {
|
|
56
|
+
try {
|
|
57
|
+
const docRef = doc(
|
|
58
|
+
db,
|
|
59
|
+
CLINICS_COLLECTION,
|
|
60
|
+
clinicBranchId,
|
|
61
|
+
ANALYTICS_COLLECTION,
|
|
62
|
+
subcollection,
|
|
63
|
+
period,
|
|
64
|
+
documentId,
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const docSnap = await getDoc(docRef);
|
|
68
|
+
if (!docSnap.exists()) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return docSnap.data() as T;
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reads stored practitioner analytics
|
|
81
|
+
*/
|
|
82
|
+
export async function readStoredPractitionerAnalytics(
|
|
83
|
+
db: Firestore,
|
|
84
|
+
clinicBranchId: string,
|
|
85
|
+
practitionerId: string,
|
|
86
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
87
|
+
): Promise<StoredPractitionerAnalytics | null> {
|
|
88
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
89
|
+
|
|
90
|
+
if (!useCache) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const stored = await readStoredAnalytics<StoredPractitionerAnalytics>(
|
|
95
|
+
db,
|
|
96
|
+
clinicBranchId,
|
|
97
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
98
|
+
practitionerId,
|
|
99
|
+
period,
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!stored) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if data is fresh enough
|
|
107
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return stored;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads stored procedure analytics
|
|
116
|
+
*/
|
|
117
|
+
export async function readStoredProcedureAnalytics(
|
|
118
|
+
db: Firestore,
|
|
119
|
+
clinicBranchId: string,
|
|
120
|
+
procedureId: string,
|
|
121
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
122
|
+
): Promise<StoredProcedureAnalytics | null> {
|
|
123
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
124
|
+
|
|
125
|
+
if (!useCache) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const stored = await readStoredAnalytics<StoredProcedureAnalytics>(
|
|
130
|
+
db,
|
|
131
|
+
clinicBranchId,
|
|
132
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
133
|
+
procedureId,
|
|
134
|
+
period,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (!stored) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return stored;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reads stored clinic analytics
|
|
150
|
+
*/
|
|
151
|
+
export async function readStoredClinicAnalytics(
|
|
152
|
+
db: Firestore,
|
|
153
|
+
clinicBranchId: string,
|
|
154
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
155
|
+
): Promise<StoredClinicAnalytics | null> {
|
|
156
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
157
|
+
|
|
158
|
+
if (!useCache) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const stored = await readStoredAnalytics<StoredClinicAnalytics>(
|
|
163
|
+
db,
|
|
164
|
+
clinicBranchId,
|
|
165
|
+
CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
166
|
+
'current',
|
|
167
|
+
period,
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (!stored) {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return stored;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reads stored dashboard analytics
|
|
183
|
+
*/
|
|
184
|
+
export async function readStoredDashboardAnalytics(
|
|
185
|
+
db: Firestore,
|
|
186
|
+
clinicBranchId: string,
|
|
187
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
188
|
+
): Promise<StoredDashboardAnalytics | null> {
|
|
189
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
190
|
+
|
|
191
|
+
if (!useCache) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const stored = await readStoredAnalytics<StoredDashboardAnalytics>(
|
|
196
|
+
db,
|
|
197
|
+
clinicBranchId,
|
|
198
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
199
|
+
'current',
|
|
200
|
+
period,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
if (!stored) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return stored;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Reads stored time efficiency metrics
|
|
216
|
+
*/
|
|
217
|
+
export async function readStoredTimeEfficiencyMetrics(
|
|
218
|
+
db: Firestore,
|
|
219
|
+
clinicBranchId: string,
|
|
220
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
221
|
+
): Promise<StoredTimeEfficiencyMetrics | null> {
|
|
222
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
223
|
+
|
|
224
|
+
if (!useCache) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const stored = await readStoredAnalytics<StoredTimeEfficiencyMetrics>(
|
|
229
|
+
db,
|
|
230
|
+
clinicBranchId,
|
|
231
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
232
|
+
'current',
|
|
233
|
+
period,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
if (!stored) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return stored;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Reads stored revenue metrics
|
|
249
|
+
*/
|
|
250
|
+
export async function readStoredRevenueMetrics(
|
|
251
|
+
db: Firestore,
|
|
252
|
+
clinicBranchId: string,
|
|
253
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
254
|
+
): Promise<StoredRevenueMetrics | null> {
|
|
255
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
256
|
+
|
|
257
|
+
if (!useCache) {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const stored = await readStoredAnalytics<StoredRevenueMetrics>(
|
|
262
|
+
db,
|
|
263
|
+
clinicBranchId,
|
|
264
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
265
|
+
'current',
|
|
266
|
+
period,
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
if (!stored) {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return stored;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Reads stored cancellation metrics
|
|
282
|
+
*/
|
|
283
|
+
export async function readStoredCancellationMetrics(
|
|
284
|
+
db: Firestore,
|
|
285
|
+
clinicBranchId: string,
|
|
286
|
+
entityType: string,
|
|
287
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
288
|
+
): Promise<StoredCancellationMetrics | null> {
|
|
289
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
290
|
+
|
|
291
|
+
if (!useCache) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const stored = await readStoredAnalytics<StoredCancellationMetrics>(
|
|
296
|
+
db,
|
|
297
|
+
clinicBranchId,
|
|
298
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
299
|
+
entityType,
|
|
300
|
+
period,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
if (!stored) {
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return stored;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Reads stored no-show metrics
|
|
316
|
+
*/
|
|
317
|
+
export async function readStoredNoShowMetrics(
|
|
318
|
+
db: Firestore,
|
|
319
|
+
clinicBranchId: string,
|
|
320
|
+
entityType: string,
|
|
321
|
+
options: ReadStoredAnalyticsOptions = {},
|
|
322
|
+
): Promise<StoredNoShowMetrics | null> {
|
|
323
|
+
const { useCache = true, maxCacheAgeHours = 12, period = 'all_time' } = options;
|
|
324
|
+
|
|
325
|
+
if (!useCache) {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const stored = await readStoredAnalytics<StoredNoShowMetrics>(
|
|
330
|
+
db,
|
|
331
|
+
clinicBranchId,
|
|
332
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
333
|
+
entityType,
|
|
334
|
+
period,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
if (!stored) {
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return stored;
|
|
346
|
+
}
|
|
347
|
+
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { Appointment, AppointmentStatus } from '../../../types/appointment';
|
|
2
|
+
import { Timestamp } from 'firebase/firestore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculates time efficiency metrics for an appointment
|
|
6
|
+
*
|
|
7
|
+
* @param appointment - The appointment to calculate metrics for
|
|
8
|
+
* @returns Time efficiency data or null if insufficient data
|
|
9
|
+
*/
|
|
10
|
+
export function calculateTimeEfficiency(appointment: Appointment): {
|
|
11
|
+
bookedDuration: number; // minutes
|
|
12
|
+
actualDuration: number; // minutes
|
|
13
|
+
efficiency: number; // percentage
|
|
14
|
+
overrun: number; // minutes (positive if actual > booked)
|
|
15
|
+
underutilization: number; // minutes (positive if booked > actual)
|
|
16
|
+
} | null {
|
|
17
|
+
const startTime = appointment.appointmentStartTime;
|
|
18
|
+
const endTime = appointment.appointmentEndTime;
|
|
19
|
+
|
|
20
|
+
if (!startTime || !endTime) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Calculate booked duration in minutes
|
|
25
|
+
const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
|
|
26
|
+
const bookedDuration = Math.round(bookedDurationMs / (1000 * 60));
|
|
27
|
+
|
|
28
|
+
// Use actual duration if available, otherwise use booked duration
|
|
29
|
+
const actualDuration = appointment.actualDurationMinutes || bookedDuration;
|
|
30
|
+
|
|
31
|
+
// Calculate efficiency percentage
|
|
32
|
+
const efficiency = bookedDuration > 0 ? (actualDuration / bookedDuration) * 100 : 100;
|
|
33
|
+
|
|
34
|
+
// Calculate overrun (positive if actual > booked)
|
|
35
|
+
const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
|
|
36
|
+
|
|
37
|
+
// Calculate underutilization (positive if booked > actual)
|
|
38
|
+
const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
bookedDuration,
|
|
42
|
+
actualDuration,
|
|
43
|
+
efficiency,
|
|
44
|
+
overrun,
|
|
45
|
+
underutilization,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Calculates average time metrics from an array of appointments
|
|
51
|
+
*
|
|
52
|
+
* @param appointments - Array of appointments
|
|
53
|
+
* @returns Average time metrics
|
|
54
|
+
*/
|
|
55
|
+
export function calculateAverageTimeMetrics(appointments: Appointment[]): {
|
|
56
|
+
averageBookedDuration: number;
|
|
57
|
+
averageActualDuration: number;
|
|
58
|
+
averageEfficiency: number;
|
|
59
|
+
totalOverrun: number;
|
|
60
|
+
totalUnderutilization: number;
|
|
61
|
+
averageOverrun: number;
|
|
62
|
+
averageUnderutilization: number;
|
|
63
|
+
appointmentsWithActualTime: number;
|
|
64
|
+
} {
|
|
65
|
+
if (appointments.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
averageBookedDuration: 0,
|
|
68
|
+
averageActualDuration: 0,
|
|
69
|
+
averageEfficiency: 0,
|
|
70
|
+
totalOverrun: 0,
|
|
71
|
+
totalUnderutilization: 0,
|
|
72
|
+
averageOverrun: 0,
|
|
73
|
+
averageUnderutilization: 0,
|
|
74
|
+
appointmentsWithActualTime: 0,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let totalBookedDuration = 0;
|
|
79
|
+
let totalActualDuration = 0;
|
|
80
|
+
let totalOverrun = 0;
|
|
81
|
+
let totalUnderutilization = 0;
|
|
82
|
+
let appointmentsWithActualTime = 0;
|
|
83
|
+
|
|
84
|
+
appointments.forEach(appointment => {
|
|
85
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
86
|
+
if (timeData) {
|
|
87
|
+
totalBookedDuration += timeData.bookedDuration;
|
|
88
|
+
totalActualDuration += timeData.actualDuration;
|
|
89
|
+
totalOverrun += timeData.overrun;
|
|
90
|
+
totalUnderutilization += timeData.underutilization;
|
|
91
|
+
|
|
92
|
+
if (appointment.actualDurationMinutes !== undefined) {
|
|
93
|
+
appointmentsWithActualTime++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const count = appointments.length;
|
|
99
|
+
const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
|
|
100
|
+
const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
|
|
101
|
+
const averageEfficiency = averageBookedDuration > 0 ? (averageActualDuration / averageBookedDuration) * 100 : 0;
|
|
102
|
+
const averageOverrun = count > 0 ? totalOverrun / count : 0;
|
|
103
|
+
const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
averageBookedDuration: Math.round(averageBookedDuration),
|
|
107
|
+
averageActualDuration: Math.round(averageActualDuration),
|
|
108
|
+
averageEfficiency: Math.round(averageEfficiency * 100) / 100,
|
|
109
|
+
totalOverrun,
|
|
110
|
+
totalUnderutilization,
|
|
111
|
+
averageOverrun: Math.round(averageOverrun),
|
|
112
|
+
averageUnderutilization: Math.round(averageUnderutilization),
|
|
113
|
+
appointmentsWithActualTime,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Groups appointments by efficiency ranges
|
|
119
|
+
*
|
|
120
|
+
* @param appointments - Array of appointments
|
|
121
|
+
* @returns Distribution of appointments by efficiency range
|
|
122
|
+
*/
|
|
123
|
+
export function calculateEfficiencyDistribution(appointments: Appointment[]): Array<{
|
|
124
|
+
range: string;
|
|
125
|
+
count: number;
|
|
126
|
+
percentage: number;
|
|
127
|
+
}> {
|
|
128
|
+
const ranges = [
|
|
129
|
+
{ label: '0-50%', min: 0, max: 50 },
|
|
130
|
+
{ label: '50-75%', min: 50, max: 75 },
|
|
131
|
+
{ label: '75-100%', min: 75, max: 100 },
|
|
132
|
+
{ label: '100%+', min: 100, max: Infinity },
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
const distribution = ranges.map(range => ({
|
|
136
|
+
range: range.label,
|
|
137
|
+
count: 0,
|
|
138
|
+
percentage: 0,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
let validCount = 0;
|
|
142
|
+
|
|
143
|
+
appointments.forEach(appointment => {
|
|
144
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
145
|
+
if (timeData) {
|
|
146
|
+
validCount++;
|
|
147
|
+
const efficiency = timeData.efficiency;
|
|
148
|
+
|
|
149
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
150
|
+
if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
|
|
151
|
+
distribution[i].count++;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Calculate percentages
|
|
159
|
+
if (validCount > 0) {
|
|
160
|
+
distribution.forEach(item => {
|
|
161
|
+
item.percentage = Math.round((item.count / validCount) * 100 * 100) / 100;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return distribution;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Calculates cancellation lead time in hours
|
|
170
|
+
*
|
|
171
|
+
* @param appointment - The appointment to calculate lead time for
|
|
172
|
+
* @returns Lead time in hours, or null if insufficient data
|
|
173
|
+
*/
|
|
174
|
+
export function calculateCancellationLeadTime(appointment: Appointment): number | null {
|
|
175
|
+
if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const cancellationTime = appointment.cancellationTime.toMillis();
|
|
180
|
+
const appointmentTime = appointment.appointmentStartTime.toMillis();
|
|
181
|
+
const diffMs = appointmentTime - cancellationTime;
|
|
182
|
+
|
|
183
|
+
// Return positive hours (time before appointment)
|
|
184
|
+
return Math.max(0, diffMs / (1000 * 60 * 60));
|
|
185
|
+
}
|
|
186
|
+
|