@blackcode_sa/metaestetics-api 1.12.67 → 1.13.0

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.
Files changed (47) hide show
  1. package/dist/admin/index.d.mts +801 -2
  2. package/dist/admin/index.d.ts +801 -2
  3. package/dist/admin/index.js +2332 -153
  4. package/dist/admin/index.mjs +2321 -153
  5. package/dist/backoffice/index.d.mts +40 -0
  6. package/dist/backoffice/index.d.ts +40 -0
  7. package/dist/backoffice/index.js +118 -18
  8. package/dist/backoffice/index.mjs +118 -20
  9. package/dist/index.d.mts +1097 -2
  10. package/dist/index.d.ts +1097 -2
  11. package/dist/index.js +4224 -2091
  12. package/dist/index.mjs +3941 -1821
  13. package/package.json +1 -1
  14. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
  15. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  16. package/src/admin/analytics/index.ts +2 -0
  17. package/src/admin/index.ts +6 -0
  18. package/src/backoffice/services/README.md +17 -0
  19. package/src/backoffice/services/analytics.service.proposal.md +863 -0
  20. package/src/backoffice/services/analytics.service.summary.md +143 -0
  21. package/src/backoffice/services/category.service.ts +49 -6
  22. package/src/backoffice/services/subcategory.service.ts +50 -6
  23. package/src/backoffice/services/technology.service.ts +53 -6
  24. package/src/services/analytics/ARCHITECTURE.md +199 -0
  25. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  26. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  27. package/src/services/analytics/QUICK_START.md +393 -0
  28. package/src/services/analytics/README.md +287 -0
  29. package/src/services/analytics/SUMMARY.md +141 -0
  30. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  31. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  32. package/src/services/analytics/analytics.service.ts +1632 -0
  33. package/src/services/analytics/index.ts +3 -0
  34. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  35. package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
  36. package/src/services/analytics/utils/grouping.utils.ts +394 -0
  37. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  38. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  39. package/src/services/appointment/appointment.service.ts +50 -6
  40. package/src/services/index.ts +1 -0
  41. package/src/services/procedure/procedure.service.ts +3 -3
  42. package/src/types/analytics/analytics.types.ts +500 -0
  43. package/src/types/analytics/grouped-analytics.types.ts +148 -0
  44. package/src/types/analytics/index.ts +4 -0
  45. package/src/types/analytics/stored-analytics.types.ts +137 -0
  46. package/src/types/index.ts +3 -0
  47. package/src/types/notifications/index.ts +21 -0
@@ -0,0 +1,1632 @@
1
+ import { Firestore, collection, query, where, getDocs, Timestamp } from 'firebase/firestore';
2
+ import { Auth } from 'firebase/auth';
3
+ import { FirebaseApp } from 'firebase/app';
4
+ import { BaseService } from '../base.service';
5
+ import { Appointment, AppointmentStatus, APPOINTMENTS_COLLECTION, PaymentStatus } from '../../types/appointment';
6
+ import { AppointmentService } from '../appointment/appointment.service';
7
+ import {
8
+ PractitionerAnalytics,
9
+ ProcedureAnalytics,
10
+ TimeEfficiencyMetrics,
11
+ CancellationMetrics,
12
+ NoShowMetrics,
13
+ RevenueMetrics,
14
+ RevenueTrend,
15
+ DurationTrend,
16
+ ProductUsageMetrics,
17
+ ProductRevenueMetrics,
18
+ ProductUsageByProcedure,
19
+ PatientAnalytics,
20
+ PatientLifetimeValueMetrics,
21
+ PatientRetentionMetrics,
22
+ CostPerPatientMetrics,
23
+ PaymentStatusBreakdown,
24
+ ClinicAnalytics,
25
+ ClinicComparisonMetrics,
26
+ ProcedurePopularity,
27
+ ProcedureProfitability,
28
+ CancellationReasonStats,
29
+ DashboardAnalytics,
30
+ AnalyticsDateRange,
31
+ AnalyticsFilters,
32
+ GroupingPeriod,
33
+ EntityType,
34
+ } from '../../types/analytics';
35
+
36
+ // Import utility functions
37
+ import { calculateAppointmentCost, calculateTotalRevenue, extractProductUsage } from './utils/cost-calculation.utils';
38
+ import {
39
+ calculateTimeEfficiency,
40
+ calculateAverageTimeMetrics,
41
+ calculateEfficiencyDistribution,
42
+ calculateCancellationLeadTime,
43
+ } from './utils/time-calculation.utils';
44
+ import {
45
+ filterByDateRange,
46
+ filterAppointments,
47
+ getCompletedAppointments,
48
+ getCanceledAppointments,
49
+ getNoShowAppointments,
50
+ getActiveAppointments,
51
+ calculatePercentage,
52
+ } from './utils/appointment-filtering.utils';
53
+ import {
54
+ readStoredPractitionerAnalytics,
55
+ readStoredProcedureAnalytics,
56
+ readStoredClinicAnalytics,
57
+ readStoredDashboardAnalytics,
58
+ readStoredTimeEfficiencyMetrics,
59
+ readStoredRevenueMetrics,
60
+ readStoredCancellationMetrics,
61
+ readStoredNoShowMetrics,
62
+ } from './utils/stored-analytics.utils';
63
+ import {
64
+ calculateGroupedRevenueMetrics,
65
+ calculateGroupedProductUsageMetrics,
66
+ calculateGroupedTimeEfficiencyMetrics,
67
+ calculateGroupedPatientBehaviorMetrics,
68
+ } from './utils/grouping.utils';
69
+ import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
70
+ import {
71
+ GroupedRevenueMetrics,
72
+ GroupedProductUsageMetrics,
73
+ GroupedTimeEfficiencyMetrics,
74
+ GroupedPatientBehaviorMetrics,
75
+ } from '../../types/analytics/grouped-analytics.types';
76
+
77
+ /**
78
+ * AnalyticsService provides comprehensive financial and analytical intelligence
79
+ * for the Clinic Admin app, including metrics about doctors, procedures,
80
+ * appointments, patients, products, and clinic operations.
81
+ */
82
+ export class AnalyticsService extends BaseService {
83
+ private appointmentService: AppointmentService;
84
+
85
+ /**
86
+ * Creates a new AnalyticsService instance.
87
+ *
88
+ * @param db Firestore instance
89
+ * @param auth Firebase Auth instance
90
+ * @param app Firebase App instance
91
+ * @param appointmentService Appointment service instance for querying appointments
92
+ */
93
+ constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
94
+ super(db, auth, app);
95
+ this.appointmentService = appointmentService;
96
+ }
97
+
98
+ /**
99
+ * Fetches appointments with optional filters
100
+ *
101
+ * @param filters - Optional filters
102
+ * @param dateRange - Optional date range
103
+ * @returns Array of appointments
104
+ */
105
+ private async fetchAppointments(
106
+ filters?: AnalyticsFilters,
107
+ dateRange?: AnalyticsDateRange,
108
+ ): Promise<Appointment[]> {
109
+ try {
110
+ // Build query constraints
111
+ const constraints: any[] = [];
112
+
113
+ if (filters?.clinicBranchId) {
114
+ constraints.push(where('clinicBranchId', '==', filters.clinicBranchId));
115
+ }
116
+ if (filters?.practitionerId) {
117
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
118
+ }
119
+ if (filters?.procedureId) {
120
+ constraints.push(where('procedureId', '==', filters.procedureId));
121
+ }
122
+ if (filters?.patientId) {
123
+ constraints.push(where('patientId', '==', filters.patientId));
124
+ }
125
+ if (dateRange) {
126
+ constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(dateRange.start)));
127
+ constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(dateRange.end)));
128
+ }
129
+
130
+ // Use AppointmentService to search appointments
131
+ const searchParams: any = {};
132
+ if (filters?.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
133
+ if (filters?.practitionerId) searchParams.practitionerId = filters.practitionerId;
134
+ if (filters?.procedureId) searchParams.procedureId = filters.procedureId;
135
+ if (filters?.patientId) searchParams.patientId = filters.patientId;
136
+ if (dateRange) {
137
+ searchParams.startDate = dateRange.start;
138
+ searchParams.endDate = dateRange.end;
139
+ }
140
+
141
+ const result = await this.appointmentService.searchAppointments(searchParams);
142
+
143
+ return result.appointments;
144
+ } catch (error) {
145
+ console.error('[AnalyticsService] Error fetching appointments:', error);
146
+ throw error;
147
+ }
148
+ }
149
+
150
+ // ==========================================
151
+ // Practitioner Analytics
152
+ // ==========================================
153
+
154
+ /**
155
+ * Get practitioner performance metrics
156
+ * First checks for stored analytics, then calculates if not available or stale
157
+ *
158
+ * @param practitionerId - ID of the practitioner
159
+ * @param dateRange - Optional date range filter
160
+ * @param options - Options for reading stored analytics
161
+ * @returns Practitioner analytics object
162
+ */
163
+ async getPractitionerAnalytics(
164
+ practitionerId: string,
165
+ dateRange?: AnalyticsDateRange,
166
+ options?: ReadStoredAnalyticsOptions,
167
+ ): Promise<PractitionerAnalytics> {
168
+ // Try to read from stored analytics first
169
+ if (dateRange && options?.useCache !== false) {
170
+ // Determine period from date range
171
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
172
+ const clinicBranchId = options?.clinicBranchId; // Would need to be passed or determined
173
+
174
+ if (clinicBranchId) {
175
+ const stored = await readStoredPractitionerAnalytics(
176
+ this.db,
177
+ clinicBranchId,
178
+ practitionerId,
179
+ { ...options, period },
180
+ );
181
+
182
+ if (stored) {
183
+ // Return stored data (without metadata)
184
+ const { metadata, ...analytics } = stored;
185
+ return analytics;
186
+ }
187
+ }
188
+ }
189
+
190
+ // Fall back to calculation
191
+ const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
192
+
193
+ const completed = getCompletedAppointments(appointments);
194
+ const canceled = getCanceledAppointments(appointments);
195
+ const noShow = getNoShowAppointments(appointments);
196
+ const pending = filterAppointments(appointments, { practitionerId }).filter(
197
+ a => a.status === AppointmentStatus.PENDING,
198
+ );
199
+ const confirmed = filterAppointments(appointments, { practitionerId }).filter(
200
+ a => a.status === AppointmentStatus.CONFIRMED,
201
+ );
202
+
203
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
204
+ const timeMetrics = calculateAverageTimeMetrics(completed);
205
+
206
+ // Get unique patients
207
+ const uniquePatients = new Set(appointments.map(a => a.patientId));
208
+ const returningPatients = new Set(
209
+ appointments
210
+ .filter(a => {
211
+ const patientAppointments = appointments.filter(ap => ap.patientId === a.patientId);
212
+ return patientAppointments.length > 1;
213
+ })
214
+ .map(a => a.patientId),
215
+ );
216
+
217
+ // Get top procedures
218
+ const procedureMap = new Map<string, { name: string; count: number; revenue: number }>();
219
+ completed.forEach(appointment => {
220
+ const procId = appointment.procedureId;
221
+ const procName = appointment.procedureInfo?.name || 'Unknown';
222
+ const cost = calculateAppointmentCost(appointment).cost;
223
+
224
+ if (procedureMap.has(procId)) {
225
+ const existing = procedureMap.get(procId)!;
226
+ existing.count++;
227
+ existing.revenue += cost;
228
+ } else {
229
+ procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
230
+ }
231
+ });
232
+
233
+ const topProcedures = Array.from(procedureMap.entries())
234
+ .map(([procedureId, data]) => ({
235
+ procedureId,
236
+ procedureName: data.name,
237
+ count: data.count,
238
+ revenue: data.revenue,
239
+ }))
240
+ .sort((a, b) => b.count - a.count)
241
+ .slice(0, 10);
242
+
243
+ const practitionerName =
244
+ appointments.length > 0
245
+ ? appointments[0].practitionerInfo?.name || 'Unknown'
246
+ : 'Unknown';
247
+
248
+ return {
249
+ total: appointments.length,
250
+ dateRange,
251
+ practitionerId,
252
+ practitionerName,
253
+ totalAppointments: appointments.length,
254
+ completedAppointments: completed.length,
255
+ canceledAppointments: canceled.length,
256
+ noShowAppointments: noShow.length,
257
+ pendingAppointments: pending.length,
258
+ confirmedAppointments: confirmed.length,
259
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
260
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
261
+ averageBookedTime: timeMetrics.averageBookedDuration,
262
+ averageActualTime: timeMetrics.averageActualDuration,
263
+ timeEfficiency: timeMetrics.averageEfficiency,
264
+ totalRevenue,
265
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
266
+ currency,
267
+ topProcedures,
268
+ patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
269
+ uniquePatients: uniquePatients.size,
270
+ };
271
+ }
272
+
273
+ // ==========================================
274
+ // Procedure Analytics
275
+ // ==========================================
276
+
277
+ /**
278
+ * Get procedure performance metrics
279
+ * First checks for stored analytics, then calculates if not available or stale
280
+ *
281
+ * @param procedureId - ID of the procedure (optional, if not provided returns all)
282
+ * @param dateRange - Optional date range filter
283
+ * @param options - Options for reading stored analytics
284
+ * @returns Procedure analytics object or array
285
+ */
286
+ async getProcedureAnalytics(
287
+ procedureId?: string,
288
+ dateRange?: AnalyticsDateRange,
289
+ options?: ReadStoredAnalyticsOptions,
290
+ ): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
291
+ // Try to read from stored analytics first (only for single procedure)
292
+ if (procedureId && dateRange && options?.useCache !== false) {
293
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
294
+ const clinicBranchId = options?.clinicBranchId;
295
+
296
+ if (clinicBranchId) {
297
+ const stored = await readStoredProcedureAnalytics(
298
+ this.db,
299
+ clinicBranchId,
300
+ procedureId,
301
+ { ...options, period },
302
+ );
303
+
304
+ if (stored) {
305
+ // Return stored data (without metadata)
306
+ const { metadata, ...analytics } = stored;
307
+ return analytics;
308
+ }
309
+ }
310
+ }
311
+
312
+ // Fall back to calculation
313
+ const appointments = await this.fetchAppointments(procedureId ? { procedureId } : undefined, dateRange);
314
+
315
+ if (procedureId) {
316
+ return this.calculateProcedureAnalytics(appointments, procedureId);
317
+ }
318
+
319
+ // Group by procedure
320
+ const procedureMap = new Map<string, Appointment[]>();
321
+ appointments.forEach(appointment => {
322
+ const procId = appointment.procedureId;
323
+ if (!procedureMap.has(procId)) {
324
+ procedureMap.set(procId, []);
325
+ }
326
+ procedureMap.get(procId)!.push(appointment);
327
+ });
328
+
329
+ return Array.from(procedureMap.entries()).map(([procId, procAppointments]) =>
330
+ this.calculateProcedureAnalytics(procAppointments, procId),
331
+ );
332
+ }
333
+
334
+ /**
335
+ * Calculate analytics for a specific procedure
336
+ *
337
+ * @param appointments - Appointments for the procedure
338
+ * @param procedureId - Procedure ID
339
+ * @returns Procedure analytics
340
+ */
341
+ private calculateProcedureAnalytics(
342
+ appointments: Appointment[],
343
+ procedureId: string,
344
+ ): ProcedureAnalytics {
345
+ const completed = getCompletedAppointments(appointments);
346
+ const canceled = getCanceledAppointments(appointments);
347
+ const noShow = getNoShowAppointments(appointments);
348
+
349
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
350
+ const timeMetrics = calculateAverageTimeMetrics(completed);
351
+
352
+ const firstAppointment = appointments[0];
353
+ const procedureInfo = firstAppointment?.procedureExtendedInfo || firstAppointment?.procedureInfo;
354
+
355
+ // Extract product usage
356
+ const productMap = new Map<
357
+ string,
358
+ { name: string; brandName: string; quantity: number; revenue: number; usageCount: number }
359
+ >();
360
+
361
+ completed.forEach(appointment => {
362
+ const products = extractProductUsage(appointment);
363
+ products.forEach(product => {
364
+ if (productMap.has(product.productId)) {
365
+ const existing = productMap.get(product.productId)!;
366
+ existing.quantity += product.quantity;
367
+ existing.revenue += product.subtotal;
368
+ existing.usageCount++;
369
+ } else {
370
+ productMap.set(product.productId, {
371
+ name: product.productName,
372
+ brandName: product.brandName,
373
+ quantity: product.quantity,
374
+ revenue: product.subtotal,
375
+ usageCount: 1,
376
+ });
377
+ }
378
+ });
379
+ });
380
+
381
+ const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
382
+ productId,
383
+ productName: data.name,
384
+ brandName: data.brandName,
385
+ totalQuantity: data.quantity,
386
+ totalRevenue: data.revenue,
387
+ usageCount: data.usageCount,
388
+ }));
389
+
390
+ return {
391
+ total: appointments.length,
392
+ procedureId,
393
+ procedureName: procedureInfo?.name || 'Unknown',
394
+ procedureFamily: procedureInfo?.procedureFamily || '',
395
+ categoryName: procedureInfo?.procedureCategoryName || '',
396
+ subcategoryName: procedureInfo?.procedureSubCategoryName || '',
397
+ technologyName: procedureInfo?.procedureTechnologyName || '',
398
+ totalAppointments: appointments.length,
399
+ completedAppointments: completed.length,
400
+ canceledAppointments: canceled.length,
401
+ noShowAppointments: noShow.length,
402
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
403
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
404
+ averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
405
+ totalRevenue,
406
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
407
+ currency,
408
+ averageBookedDuration: timeMetrics.averageBookedDuration,
409
+ averageActualDuration: timeMetrics.averageActualDuration,
410
+ productUsage,
411
+ };
412
+ }
413
+
414
+ /**
415
+ * Get procedure popularity metrics
416
+ *
417
+ * @param dateRange - Optional date range filter
418
+ * @param limit - Number of top procedures to return
419
+ * @returns Array of procedure popularity metrics
420
+ */
421
+ async getProcedurePopularity(
422
+ dateRange?: AnalyticsDateRange,
423
+ limit: number = 10,
424
+ ): Promise<ProcedurePopularity[]> {
425
+ const appointments = await this.fetchAppointments(undefined, dateRange);
426
+ const completed = getCompletedAppointments(appointments);
427
+
428
+ const procedureMap = new Map<string, { name: string; category: string; subcategory: string; technology: string; count: number }>();
429
+
430
+ completed.forEach(appointment => {
431
+ const procId = appointment.procedureId;
432
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
433
+
434
+ if (procedureMap.has(procId)) {
435
+ procedureMap.get(procId)!.count++;
436
+ } else {
437
+ procedureMap.set(procId, {
438
+ name: procInfo?.name || 'Unknown',
439
+ category: procInfo?.procedureCategoryName || '',
440
+ subcategory: procInfo?.procedureSubCategoryName || '',
441
+ technology: procInfo?.procedureTechnologyName || '',
442
+ count: 1,
443
+ });
444
+ }
445
+ });
446
+
447
+ return Array.from(procedureMap.entries())
448
+ .map(([procedureId, data]) => ({
449
+ procedureId,
450
+ procedureName: data.name,
451
+ categoryName: data.category,
452
+ subcategoryName: data.subcategory,
453
+ technologyName: data.technology,
454
+ appointmentCount: data.count,
455
+ completedCount: data.count,
456
+ rank: 0, // Will be set after sorting
457
+ }))
458
+ .sort((a, b) => b.appointmentCount - a.appointmentCount)
459
+ .slice(0, limit)
460
+ .map((item, index) => ({ ...item, rank: index + 1 }));
461
+ }
462
+
463
+ /**
464
+ * Get procedure profitability metrics
465
+ *
466
+ * @param dateRange - Optional date range filter
467
+ * @param limit - Number of top procedures to return
468
+ * @returns Array of procedure profitability metrics
469
+ */
470
+ async getProcedureProfitability(
471
+ dateRange?: AnalyticsDateRange,
472
+ limit: number = 10,
473
+ ): Promise<ProcedureProfitability[]> {
474
+ const appointments = await this.fetchAppointments(undefined, dateRange);
475
+ const completed = getCompletedAppointments(appointments);
476
+
477
+ const procedureMap = new Map<
478
+ string,
479
+ {
480
+ name: string;
481
+ category: string;
482
+ subcategory: string;
483
+ technology: string;
484
+ revenue: number;
485
+ count: number;
486
+ }
487
+ >();
488
+
489
+ completed.forEach(appointment => {
490
+ const procId = appointment.procedureId;
491
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
492
+ const cost = calculateAppointmentCost(appointment).cost;
493
+
494
+ if (procedureMap.has(procId)) {
495
+ const existing = procedureMap.get(procId)!;
496
+ existing.revenue += cost;
497
+ existing.count++;
498
+ } else {
499
+ procedureMap.set(procId, {
500
+ name: procInfo?.name || 'Unknown',
501
+ category: procInfo?.procedureCategoryName || '',
502
+ subcategory: procInfo?.procedureSubCategoryName || '',
503
+ technology: procInfo?.procedureTechnologyName || '',
504
+ revenue: cost,
505
+ count: 1,
506
+ });
507
+ }
508
+ });
509
+
510
+ return Array.from(procedureMap.entries())
511
+ .map(([procedureId, data]) => ({
512
+ procedureId,
513
+ procedureName: data.name,
514
+ categoryName: data.category,
515
+ subcategoryName: data.subcategory,
516
+ technologyName: data.technology,
517
+ totalRevenue: data.revenue,
518
+ averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
519
+ appointmentCount: data.count,
520
+ rank: 0, // Will be set after sorting
521
+ }))
522
+ .sort((a, b) => b.totalRevenue - a.totalRevenue)
523
+ .slice(0, limit)
524
+ .map((item, index) => ({ ...item, rank: index + 1 }));
525
+ }
526
+
527
+ // ==========================================
528
+ // Time Efficiency Analytics
529
+ // ==========================================
530
+
531
+ /**
532
+ * Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
533
+ *
534
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
535
+ * @param dateRange - Optional date range filter
536
+ * @param filters - Optional additional filters
537
+ * @returns Grouped time efficiency metrics
538
+ */
539
+ async getTimeEfficiencyMetricsByEntity(
540
+ groupBy: EntityType,
541
+ dateRange?: AnalyticsDateRange,
542
+ filters?: AnalyticsFilters,
543
+ ): Promise<GroupedTimeEfficiencyMetrics[]> {
544
+ const appointments = await this.fetchAppointments(filters, dateRange);
545
+ return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
546
+ }
547
+
548
+ /**
549
+ * Get time efficiency metrics for appointments
550
+ * First checks for stored analytics, then calculates if not available or stale
551
+ *
552
+ * @param filters - Optional filters
553
+ * @param dateRange - Optional date range filter
554
+ * @param options - Options for reading stored analytics
555
+ * @returns Time efficiency metrics
556
+ */
557
+ async getTimeEfficiencyMetrics(
558
+ filters?: AnalyticsFilters,
559
+ dateRange?: AnalyticsDateRange,
560
+ options?: ReadStoredAnalyticsOptions,
561
+ ): Promise<TimeEfficiencyMetrics> {
562
+ // Try to read from stored analytics first
563
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
564
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
565
+ const stored = await readStoredTimeEfficiencyMetrics(
566
+ this.db,
567
+ filters.clinicBranchId,
568
+ { ...options, period },
569
+ );
570
+
571
+ if (stored) {
572
+ // Return stored data (without metadata)
573
+ const { metadata, ...metrics } = stored;
574
+ return metrics;
575
+ }
576
+ }
577
+
578
+ // Fall back to calculation
579
+ const appointments = await this.fetchAppointments(filters, dateRange);
580
+ const completed = getCompletedAppointments(appointments);
581
+
582
+ const timeMetrics = calculateAverageTimeMetrics(completed);
583
+ const efficiencyDistribution = calculateEfficiencyDistribution(completed);
584
+
585
+ return {
586
+ totalAppointments: completed.length,
587
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
588
+ averageBookedDuration: timeMetrics.averageBookedDuration,
589
+ averageActualDuration: timeMetrics.averageActualDuration,
590
+ averageEfficiency: timeMetrics.averageEfficiency,
591
+ totalOverrun: timeMetrics.totalOverrun,
592
+ totalUnderutilization: timeMetrics.totalUnderutilization,
593
+ averageOverrun: timeMetrics.averageOverrun,
594
+ averageUnderutilization: timeMetrics.averageUnderutilization,
595
+ efficiencyDistribution,
596
+ };
597
+ }
598
+
599
+ // ==========================================
600
+ // Cancellation & No-Show Analytics
601
+ // ==========================================
602
+
603
+ /**
604
+ * Get cancellation metrics
605
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
606
+ *
607
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
608
+ * @param dateRange - Optional date range filter
609
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
610
+ * @returns Cancellation metrics grouped by specified entity
611
+ */
612
+ async getCancellationMetrics(
613
+ groupBy: EntityType,
614
+ dateRange?: AnalyticsDateRange,
615
+ options?: ReadStoredAnalyticsOptions,
616
+ ): Promise<CancellationMetrics | CancellationMetrics[]> {
617
+ // Try to read from stored analytics first (only for clinic-level grouping)
618
+ if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
619
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
620
+ const stored = await readStoredCancellationMetrics(
621
+ this.db,
622
+ options.clinicBranchId,
623
+ 'clinic',
624
+ { ...options, period },
625
+ );
626
+
627
+ if (stored) {
628
+ // Return stored data (without metadata)
629
+ const { metadata, ...metrics } = stored;
630
+ return metrics;
631
+ }
632
+ }
633
+
634
+ // Fall back to calculation
635
+ const appointments = await this.fetchAppointments(undefined, dateRange);
636
+ const canceled = getCanceledAppointments(appointments);
637
+
638
+ if (groupBy === 'clinic') {
639
+ return this.groupCancellationsByClinic(canceled, appointments);
640
+ } else if (groupBy === 'practitioner') {
641
+ return this.groupCancellationsByPractitioner(canceled, appointments);
642
+ } else if (groupBy === 'patient') {
643
+ return this.groupCancellationsByPatient(canceled, appointments);
644
+ } else if (groupBy === 'technology') {
645
+ return this.groupCancellationsByTechnology(canceled, appointments);
646
+ } else {
647
+ return this.groupCancellationsByProcedure(canceled, appointments);
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Group cancellations by clinic
653
+ */
654
+ private groupCancellationsByClinic(
655
+ canceled: Appointment[],
656
+ allAppointments: Appointment[],
657
+ ): CancellationMetrics[] {
658
+ const clinicMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
659
+
660
+ allAppointments.forEach(appointment => {
661
+ const clinicId = appointment.clinicBranchId;
662
+ const clinicName = appointment.clinicInfo?.name || 'Unknown';
663
+
664
+ if (!clinicMap.has(clinicId)) {
665
+ clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
666
+ }
667
+ clinicMap.get(clinicId)!.all.push(appointment);
668
+ });
669
+
670
+ canceled.forEach(appointment => {
671
+ const clinicId = appointment.clinicBranchId;
672
+ if (clinicMap.has(clinicId)) {
673
+ clinicMap.get(clinicId)!.canceled.push(appointment);
674
+ }
675
+ });
676
+
677
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) =>
678
+ this.calculateCancellationMetrics(clinicId, data.name, 'clinic', data.canceled, data.all),
679
+ );
680
+ }
681
+
682
+ /**
683
+ * Group cancellations by practitioner
684
+ */
685
+ private groupCancellationsByPractitioner(
686
+ canceled: Appointment[],
687
+ allAppointments: Appointment[],
688
+ ): CancellationMetrics[] {
689
+ const practitionerMap = new Map<
690
+ string,
691
+ { name: string; canceled: Appointment[]; all: Appointment[] }
692
+ >();
693
+
694
+ allAppointments.forEach(appointment => {
695
+ const practitionerId = appointment.practitionerId;
696
+ const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
697
+
698
+ if (!practitionerMap.has(practitionerId)) {
699
+ practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
700
+ }
701
+ practitionerMap.get(practitionerId)!.all.push(appointment);
702
+ });
703
+
704
+ canceled.forEach(appointment => {
705
+ const practitionerId = appointment.practitionerId;
706
+ if (practitionerMap.has(practitionerId)) {
707
+ practitionerMap.get(practitionerId)!.canceled.push(appointment);
708
+ }
709
+ });
710
+
711
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) =>
712
+ this.calculateCancellationMetrics(
713
+ practitionerId,
714
+ data.name,
715
+ 'practitioner',
716
+ data.canceled,
717
+ data.all,
718
+ ),
719
+ );
720
+ }
721
+
722
+ /**
723
+ * Group cancellations by patient
724
+ */
725
+ private groupCancellationsByPatient(
726
+ canceled: Appointment[],
727
+ allAppointments: Appointment[],
728
+ ): CancellationMetrics[] {
729
+ const patientMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
730
+
731
+ allAppointments.forEach(appointment => {
732
+ const patientId = appointment.patientId;
733
+ const patientName = appointment.patientInfo?.fullName || 'Unknown';
734
+
735
+ if (!patientMap.has(patientId)) {
736
+ patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
737
+ }
738
+ patientMap.get(patientId)!.all.push(appointment);
739
+ });
740
+
741
+ canceled.forEach(appointment => {
742
+ const patientId = appointment.patientId;
743
+ if (patientMap.has(patientId)) {
744
+ patientMap.get(patientId)!.canceled.push(appointment);
745
+ }
746
+ });
747
+
748
+ return Array.from(patientMap.entries()).map(([patientId, data]) =>
749
+ this.calculateCancellationMetrics(patientId, data.name, 'patient', data.canceled, data.all),
750
+ );
751
+ }
752
+
753
+ /**
754
+ * Group cancellations by procedure
755
+ */
756
+ private groupCancellationsByProcedure(
757
+ canceled: Appointment[],
758
+ allAppointments: Appointment[],
759
+ ): CancellationMetrics[] {
760
+ const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
761
+
762
+ allAppointments.forEach(appointment => {
763
+ const procedureId = appointment.procedureId;
764
+ const procedureName = appointment.procedureInfo?.name || 'Unknown';
765
+
766
+ if (!procedureMap.has(procedureId)) {
767
+ procedureMap.set(procedureId, { name: procedureName, canceled: [], all: [] });
768
+ }
769
+ procedureMap.get(procedureId)!.all.push(appointment);
770
+ });
771
+
772
+ canceled.forEach(appointment => {
773
+ const procedureId = appointment.procedureId;
774
+ if (procedureMap.has(procedureId)) {
775
+ procedureMap.get(procedureId)!.canceled.push(appointment);
776
+ }
777
+ });
778
+
779
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) =>
780
+ this.calculateCancellationMetrics(
781
+ procedureId,
782
+ data.name,
783
+ 'procedure',
784
+ data.canceled,
785
+ data.all,
786
+ ),
787
+ );
788
+ }
789
+
790
+ /**
791
+ * Group cancellations by technology
792
+ * Aggregates all procedures using the same technology across all doctors
793
+ */
794
+ private groupCancellationsByTechnology(
795
+ canceled: Appointment[],
796
+ allAppointments: Appointment[],
797
+ ): CancellationMetrics[] {
798
+ const technologyMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
799
+
800
+ allAppointments.forEach(appointment => {
801
+ const technologyId =
802
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
803
+ const technologyName =
804
+ appointment.procedureExtendedInfo?.procedureTechnologyName ||
805
+ appointment.procedureInfo?.technologyName ||
806
+ 'Unknown';
807
+
808
+ if (!technologyMap.has(technologyId)) {
809
+ technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
810
+ }
811
+ technologyMap.get(technologyId)!.all.push(appointment);
812
+ });
813
+
814
+ canceled.forEach(appointment => {
815
+ const technologyId =
816
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
817
+ if (technologyMap.has(technologyId)) {
818
+ technologyMap.get(technologyId)!.canceled.push(appointment);
819
+ }
820
+ });
821
+
822
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) =>
823
+ this.calculateCancellationMetrics(
824
+ technologyId,
825
+ data.name,
826
+ 'technology',
827
+ data.canceled,
828
+ data.all,
829
+ ),
830
+ );
831
+ }
832
+
833
+ /**
834
+ * Calculate cancellation metrics for a specific entity
835
+ */
836
+ private calculateCancellationMetrics(
837
+ entityId: string,
838
+ entityName: string,
839
+ entityType: EntityType,
840
+ canceled: Appointment[],
841
+ all: Appointment[],
842
+ ): CancellationMetrics {
843
+ const canceledByPatient = canceled.filter(
844
+ a => a.status === AppointmentStatus.CANCELED_PATIENT,
845
+ ).length;
846
+ const canceledByClinic = canceled.filter(
847
+ a => a.status === AppointmentStatus.CANCELED_CLINIC,
848
+ ).length;
849
+ const canceledRescheduled = canceled.filter(
850
+ a => a.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
851
+ ).length;
852
+
853
+ // Calculate average cancellation lead time
854
+ const leadTimes = canceled
855
+ .map(a => calculateCancellationLeadTime(a))
856
+ .filter((lt): lt is number => lt !== null);
857
+ const averageLeadTime =
858
+ leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
859
+
860
+ // Group cancellation reasons
861
+ const reasonMap = new Map<string, number>();
862
+ canceled.forEach(appointment => {
863
+ const reason = appointment.cancellationReason || 'No reason provided';
864
+ reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
865
+ });
866
+
867
+ const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
868
+ reason,
869
+ count,
870
+ percentage: calculatePercentage(count, canceled.length),
871
+ }));
872
+
873
+ return {
874
+ entityId,
875
+ entityName,
876
+ entityType,
877
+ totalAppointments: all.length,
878
+ canceledAppointments: canceled.length,
879
+ cancellationRate: calculatePercentage(canceled.length, all.length),
880
+ canceledByPatient,
881
+ canceledByClinic,
882
+ canceledByPractitioner: 0, // Not tracked in current status enum
883
+ canceledRescheduled,
884
+ averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
885
+ cancellationReasons,
886
+ };
887
+ }
888
+
889
+ /**
890
+ * Get no-show metrics
891
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
892
+ *
893
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
894
+ * @param dateRange - Optional date range filter
895
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
896
+ * @returns No-show metrics grouped by specified entity
897
+ */
898
+ async getNoShowMetrics(
899
+ groupBy: EntityType,
900
+ dateRange?: AnalyticsDateRange,
901
+ options?: ReadStoredAnalyticsOptions,
902
+ ): Promise<NoShowMetrics | NoShowMetrics[]> {
903
+ // Try to read from stored analytics first (only for clinic-level grouping)
904
+ if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
905
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
906
+ const stored = await readStoredNoShowMetrics(
907
+ this.db,
908
+ options.clinicBranchId,
909
+ 'clinic',
910
+ { ...options, period },
911
+ );
912
+
913
+ if (stored) {
914
+ // Return stored data (without metadata)
915
+ const { metadata, ...metrics } = stored;
916
+ return metrics;
917
+ }
918
+ }
919
+
920
+ // Fall back to calculation
921
+ const appointments = await this.fetchAppointments(undefined, dateRange);
922
+ const noShow = getNoShowAppointments(appointments);
923
+
924
+ if (groupBy === 'clinic') {
925
+ return this.groupNoShowsByClinic(noShow, appointments);
926
+ } else if (groupBy === 'practitioner') {
927
+ return this.groupNoShowsByPractitioner(noShow, appointments);
928
+ } else if (groupBy === 'patient') {
929
+ return this.groupNoShowsByPatient(noShow, appointments);
930
+ } else if (groupBy === 'technology') {
931
+ return this.groupNoShowsByTechnology(noShow, appointments);
932
+ } else {
933
+ return this.groupNoShowsByProcedure(noShow, appointments);
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Group no-shows by clinic
939
+ */
940
+ private groupNoShowsByClinic(
941
+ noShow: Appointment[],
942
+ allAppointments: Appointment[],
943
+ ): NoShowMetrics[] {
944
+ const clinicMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
945
+
946
+ allAppointments.forEach(appointment => {
947
+ const clinicId = appointment.clinicBranchId;
948
+ const clinicName = appointment.clinicInfo?.name || 'Unknown';
949
+ if (!clinicMap.has(clinicId)) {
950
+ clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
951
+ }
952
+ clinicMap.get(clinicId)!.all.push(appointment);
953
+ });
954
+
955
+ noShow.forEach(appointment => {
956
+ const clinicId = appointment.clinicBranchId;
957
+ if (clinicMap.has(clinicId)) {
958
+ clinicMap.get(clinicId)!.noShow.push(appointment);
959
+ }
960
+ });
961
+
962
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
963
+ entityId: clinicId,
964
+ entityName: data.name,
965
+ entityType: 'clinic' as EntityType,
966
+ totalAppointments: data.all.length,
967
+ noShowAppointments: data.noShow.length,
968
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
969
+ }));
970
+ }
971
+
972
+ /**
973
+ * Group no-shows by practitioner
974
+ */
975
+ private groupNoShowsByPractitioner(
976
+ noShow: Appointment[],
977
+ allAppointments: Appointment[],
978
+ ): NoShowMetrics[] {
979
+ const practitionerMap = new Map<
980
+ string,
981
+ { name: string; noShow: Appointment[]; all: Appointment[] }
982
+ >();
983
+
984
+ allAppointments.forEach(appointment => {
985
+ const practitionerId = appointment.practitionerId;
986
+ const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
987
+
988
+ if (!practitionerMap.has(practitionerId)) {
989
+ practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
990
+ }
991
+ practitionerMap.get(practitionerId)!.all.push(appointment);
992
+ });
993
+
994
+ noShow.forEach(appointment => {
995
+ const practitionerId = appointment.practitionerId;
996
+ if (practitionerMap.has(practitionerId)) {
997
+ practitionerMap.get(practitionerId)!.noShow.push(appointment);
998
+ }
999
+ });
1000
+
1001
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
1002
+ entityId: practitionerId,
1003
+ entityName: data.name,
1004
+ entityType: 'practitioner' as EntityType,
1005
+ totalAppointments: data.all.length,
1006
+ noShowAppointments: data.noShow.length,
1007
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1008
+ }));
1009
+ }
1010
+
1011
+ /**
1012
+ * Group no-shows by patient
1013
+ */
1014
+ private groupNoShowsByPatient(
1015
+ noShow: Appointment[],
1016
+ allAppointments: Appointment[],
1017
+ ): NoShowMetrics[] {
1018
+ const patientMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1019
+
1020
+ allAppointments.forEach(appointment => {
1021
+ const patientId = appointment.patientId;
1022
+ const patientName = appointment.patientInfo?.fullName || 'Unknown';
1023
+
1024
+ if (!patientMap.has(patientId)) {
1025
+ patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
1026
+ }
1027
+ patientMap.get(patientId)!.all.push(appointment);
1028
+ });
1029
+
1030
+ noShow.forEach(appointment => {
1031
+ const patientId = appointment.patientId;
1032
+ if (patientMap.has(patientId)) {
1033
+ patientMap.get(patientId)!.noShow.push(appointment);
1034
+ }
1035
+ });
1036
+
1037
+ return Array.from(patientMap.entries()).map(([patientId, data]) => ({
1038
+ entityId: patientId,
1039
+ entityName: data.name,
1040
+ entityType: 'patient' as EntityType,
1041
+ totalAppointments: data.all.length,
1042
+ noShowAppointments: data.noShow.length,
1043
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1044
+ }));
1045
+ }
1046
+
1047
+ /**
1048
+ * Group no-shows by procedure
1049
+ */
1050
+ private groupNoShowsByProcedure(
1051
+ noShow: Appointment[],
1052
+ allAppointments: Appointment[],
1053
+ ): NoShowMetrics[] {
1054
+ const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1055
+
1056
+ allAppointments.forEach(appointment => {
1057
+ const procedureId = appointment.procedureId;
1058
+ const procedureName = appointment.procedureInfo?.name || 'Unknown';
1059
+
1060
+ if (!procedureMap.has(procedureId)) {
1061
+ procedureMap.set(procedureId, { name: procedureName, noShow: [], all: [] });
1062
+ }
1063
+ procedureMap.get(procedureId)!.all.push(appointment);
1064
+ });
1065
+
1066
+ noShow.forEach(appointment => {
1067
+ const procedureId = appointment.procedureId;
1068
+ if (procedureMap.has(procedureId)) {
1069
+ procedureMap.get(procedureId)!.noShow.push(appointment);
1070
+ }
1071
+ });
1072
+
1073
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
1074
+ entityId: procedureId,
1075
+ entityName: data.name,
1076
+ entityType: 'procedure' as EntityType,
1077
+ totalAppointments: data.all.length,
1078
+ noShowAppointments: data.noShow.length,
1079
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1080
+ }));
1081
+ }
1082
+
1083
+ /**
1084
+ * Group no-shows by technology
1085
+ * Aggregates all procedures using the same technology across all doctors
1086
+ */
1087
+ private groupNoShowsByTechnology(
1088
+ noShow: Appointment[],
1089
+ allAppointments: Appointment[],
1090
+ ): NoShowMetrics[] {
1091
+ const technologyMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1092
+
1093
+ allAppointments.forEach(appointment => {
1094
+ const technologyId =
1095
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
1096
+ const technologyName =
1097
+ appointment.procedureExtendedInfo?.procedureTechnologyName ||
1098
+ appointment.procedureInfo?.technologyName ||
1099
+ 'Unknown';
1100
+
1101
+ if (!technologyMap.has(technologyId)) {
1102
+ technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
1103
+ }
1104
+ technologyMap.get(technologyId)!.all.push(appointment);
1105
+ });
1106
+
1107
+ noShow.forEach(appointment => {
1108
+ const technologyId =
1109
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
1110
+ if (technologyMap.has(technologyId)) {
1111
+ technologyMap.get(technologyId)!.noShow.push(appointment);
1112
+ }
1113
+ });
1114
+
1115
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
1116
+ entityId: technologyId,
1117
+ entityName: data.name,
1118
+ entityType: 'technology' as EntityType,
1119
+ totalAppointments: data.all.length,
1120
+ noShowAppointments: data.noShow.length,
1121
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1122
+ }));
1123
+ }
1124
+
1125
+ // ==========================================
1126
+ // Financial Analytics
1127
+ // ==========================================
1128
+
1129
+ /**
1130
+ * Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
1131
+ *
1132
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
1133
+ * @param dateRange - Optional date range filter
1134
+ * @param filters - Optional additional filters
1135
+ * @returns Grouped revenue metrics
1136
+ */
1137
+ async getRevenueMetricsByEntity(
1138
+ groupBy: EntityType,
1139
+ dateRange?: AnalyticsDateRange,
1140
+ filters?: AnalyticsFilters,
1141
+ ): Promise<GroupedRevenueMetrics[]> {
1142
+ const appointments = await this.fetchAppointments(filters, dateRange);
1143
+ return calculateGroupedRevenueMetrics(appointments, groupBy);
1144
+ }
1145
+
1146
+ /**
1147
+ * Get revenue metrics
1148
+ * First checks for stored analytics, then calculates if not available or stale
1149
+ *
1150
+ * @param filters - Optional filters
1151
+ * @param dateRange - Optional date range filter
1152
+ * @param options - Options for reading stored analytics
1153
+ * @returns Revenue metrics
1154
+ */
1155
+ async getRevenueMetrics(
1156
+ filters?: AnalyticsFilters,
1157
+ dateRange?: AnalyticsDateRange,
1158
+ options?: ReadStoredAnalyticsOptions,
1159
+ ): Promise<RevenueMetrics> {
1160
+ // Try to read from stored analytics first
1161
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
1162
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
1163
+ const stored = await readStoredRevenueMetrics(
1164
+ this.db,
1165
+ filters.clinicBranchId,
1166
+ { ...options, period },
1167
+ );
1168
+
1169
+ if (stored) {
1170
+ // Return stored data (without metadata)
1171
+ const { metadata, ...metrics } = stored;
1172
+ return metrics;
1173
+ }
1174
+ }
1175
+
1176
+ // Fall back to calculation
1177
+ const appointments = await this.fetchAppointments(filters, dateRange);
1178
+ const completed = getCompletedAppointments(appointments);
1179
+
1180
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1181
+
1182
+ // Calculate revenue by status
1183
+ const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
1184
+ Object.values(AppointmentStatus).forEach(status => {
1185
+ const statusAppointments = appointments.filter(a => a.status === status);
1186
+ const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
1187
+ revenueByStatus[status] = statusRevenue;
1188
+ });
1189
+
1190
+ // Calculate revenue by payment status
1191
+ const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
1192
+ Object.values(PaymentStatus).forEach(paymentStatus => {
1193
+ const paymentAppointments = completed.filter(a => a.paymentStatus === paymentStatus);
1194
+ const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
1195
+ revenueByPaymentStatus[paymentStatus] = paymentRevenue;
1196
+ });
1197
+
1198
+ const unpaid = completed.filter(a => a.paymentStatus === PaymentStatus.UNPAID);
1199
+ const refunded = completed.filter(a => a.paymentStatus === PaymentStatus.REFUNDED);
1200
+
1201
+ const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
1202
+ const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
1203
+
1204
+ // Calculate tax and subtotal from finalbilling if available
1205
+ let totalTax = 0;
1206
+ let totalSubtotal = 0;
1207
+ completed.forEach(appointment => {
1208
+ const costData = calculateAppointmentCost(appointment);
1209
+ if (costData.source === 'finalbilling') {
1210
+ totalTax += costData.tax || 0;
1211
+ totalSubtotal += costData.subtotal || 0;
1212
+ } else {
1213
+ totalSubtotal += costData.cost;
1214
+ }
1215
+ });
1216
+
1217
+ return {
1218
+ totalRevenue,
1219
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1220
+ totalAppointments: appointments.length,
1221
+ completedAppointments: completed.length,
1222
+ currency,
1223
+ revenueByStatus,
1224
+ revenueByPaymentStatus,
1225
+ unpaidRevenue,
1226
+ refundedRevenue,
1227
+ totalTax,
1228
+ totalSubtotal,
1229
+ };
1230
+ }
1231
+
1232
+ // ==========================================
1233
+ // Product Usage Analytics
1234
+ // ==========================================
1235
+
1236
+ /**
1237
+ * Get product usage metrics grouped by clinic, practitioner, procedure, or patient
1238
+ *
1239
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
1240
+ * @param dateRange - Optional date range filter
1241
+ * @param filters - Optional additional filters
1242
+ * @returns Grouped product usage metrics
1243
+ */
1244
+ async getProductUsageMetricsByEntity(
1245
+ groupBy: EntityType,
1246
+ dateRange?: AnalyticsDateRange,
1247
+ filters?: AnalyticsFilters,
1248
+ ): Promise<GroupedProductUsageMetrics[]> {
1249
+ const appointments = await this.fetchAppointments(filters, dateRange);
1250
+ return calculateGroupedProductUsageMetrics(appointments, groupBy);
1251
+ }
1252
+
1253
+ /**
1254
+ * Get product usage metrics
1255
+ *
1256
+ * @param productId - Optional product ID (if not provided, returns all products)
1257
+ * @param dateRange - Optional date range filter
1258
+ * @returns Product usage metrics
1259
+ */
1260
+ async getProductUsageMetrics(
1261
+ productId?: string,
1262
+ dateRange?: AnalyticsDateRange,
1263
+ ): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
1264
+ const appointments = await this.fetchAppointments(undefined, dateRange);
1265
+ const completed = getCompletedAppointments(appointments);
1266
+
1267
+ const productMap = new Map<
1268
+ string,
1269
+ {
1270
+ name: string;
1271
+ brandId: string;
1272
+ brandName: string;
1273
+ quantity: number;
1274
+ revenue: number;
1275
+ usageCount: number;
1276
+ procedureMap: Map<string, { name: string; count: number; quantity: number }>;
1277
+ }
1278
+ >();
1279
+
1280
+ completed.forEach(appointment => {
1281
+ const products = extractProductUsage(appointment);
1282
+ products.forEach(product => {
1283
+ if (productId && product.productId !== productId) {
1284
+ return;
1285
+ }
1286
+
1287
+ if (!productMap.has(product.productId)) {
1288
+ productMap.set(product.productId, {
1289
+ name: product.productName,
1290
+ brandId: product.brandId,
1291
+ brandName: product.brandName,
1292
+ quantity: 0,
1293
+ revenue: 0,
1294
+ usageCount: 0,
1295
+ procedureMap: new Map(),
1296
+ });
1297
+ }
1298
+
1299
+ const productData = productMap.get(product.productId)!;
1300
+ productData.quantity += product.quantity;
1301
+ productData.revenue += product.subtotal;
1302
+ productData.usageCount++;
1303
+
1304
+ // Track usage by procedure
1305
+ const procId = appointment.procedureId;
1306
+ const procName = appointment.procedureInfo?.name || 'Unknown';
1307
+ if (productData.procedureMap.has(procId)) {
1308
+ const procData = productData.procedureMap.get(procId)!;
1309
+ procData.count++;
1310
+ procData.quantity += product.quantity;
1311
+ } else {
1312
+ productData.procedureMap.set(procId, {
1313
+ name: procName,
1314
+ count: 1,
1315
+ quantity: product.quantity,
1316
+ });
1317
+ }
1318
+ });
1319
+ });
1320
+
1321
+ const results = Array.from(productMap.entries()).map(([productId, data]) => ({
1322
+ productId,
1323
+ productName: data.name,
1324
+ brandId: data.brandId,
1325
+ brandName: data.brandName,
1326
+ totalQuantity: data.quantity,
1327
+ totalRevenue: data.revenue,
1328
+ averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
1329
+ currency: 'CHF', // Could be extracted from products
1330
+ usageCount: data.usageCount,
1331
+ averageQuantityPerAppointment:
1332
+ data.usageCount > 0 ? data.quantity / data.usageCount : 0,
1333
+ usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
1334
+ procedureId: procId,
1335
+ procedureName: procData.name,
1336
+ count: procData.count,
1337
+ totalQuantity: procData.quantity,
1338
+ })),
1339
+ }));
1340
+
1341
+ return productId ? results[0] : results;
1342
+ }
1343
+
1344
+ // ==========================================
1345
+ // Patient Analytics
1346
+ // ==========================================
1347
+
1348
+ /**
1349
+ * Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
1350
+ * Shows patient no-show and cancellation patterns per entity
1351
+ *
1352
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
1353
+ * @param dateRange - Optional date range filter
1354
+ * @param filters - Optional additional filters
1355
+ * @returns Grouped patient behavior metrics
1356
+ */
1357
+ async getPatientBehaviorMetricsByEntity(
1358
+ groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
1359
+ dateRange?: AnalyticsDateRange,
1360
+ filters?: AnalyticsFilters,
1361
+ ): Promise<GroupedPatientBehaviorMetrics[]> {
1362
+ const appointments = await this.fetchAppointments(filters, dateRange);
1363
+ return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
1364
+ }
1365
+
1366
+ /**
1367
+ * Get patient analytics
1368
+ *
1369
+ * @param patientId - Optional patient ID (if not provided, returns aggregate)
1370
+ * @param dateRange - Optional date range filter
1371
+ * @returns Patient analytics
1372
+ */
1373
+ async getPatientAnalytics(
1374
+ patientId?: string,
1375
+ dateRange?: AnalyticsDateRange,
1376
+ ): Promise<PatientAnalytics | PatientAnalytics[]> {
1377
+ const appointments = await this.fetchAppointments(patientId ? { patientId } : undefined, dateRange);
1378
+
1379
+ if (patientId) {
1380
+ return this.calculatePatientAnalytics(appointments, patientId);
1381
+ }
1382
+
1383
+ // Group by patient
1384
+ const patientMap = new Map<string, Appointment[]>();
1385
+ appointments.forEach(appointment => {
1386
+ const patId = appointment.patientId;
1387
+ if (!patientMap.has(patId)) {
1388
+ patientMap.set(patId, []);
1389
+ }
1390
+ patientMap.get(patId)!.push(appointment);
1391
+ });
1392
+
1393
+ return Array.from(patientMap.entries()).map(([patId, patAppointments]) =>
1394
+ this.calculatePatientAnalytics(patAppointments, patId),
1395
+ );
1396
+ }
1397
+
1398
+ /**
1399
+ * Calculate analytics for a specific patient
1400
+ */
1401
+ private calculatePatientAnalytics(appointments: Appointment[], patientId: string): PatientAnalytics {
1402
+ const completed = getCompletedAppointments(appointments);
1403
+ const canceled = getCanceledAppointments(appointments);
1404
+ const noShow = getNoShowAppointments(appointments);
1405
+
1406
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1407
+
1408
+ // Get appointment dates
1409
+ const appointmentDates = appointments
1410
+ .map(a => a.appointmentStartTime.toDate())
1411
+ .sort((a, b) => a.getTime() - b.getTime());
1412
+
1413
+ const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
1414
+ const lastAppointmentDate =
1415
+ appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
1416
+
1417
+ // Calculate average days between appointments
1418
+ let averageDaysBetween = null;
1419
+ if (appointmentDates.length > 1) {
1420
+ const intervals: number[] = [];
1421
+ for (let i = 1; i < appointmentDates.length; i++) {
1422
+ const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
1423
+ intervals.push(diffMs / (1000 * 60 * 60 * 24));
1424
+ }
1425
+ averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1426
+ }
1427
+
1428
+ // Get unique practitioners and clinics
1429
+ const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
1430
+ const uniqueClinics = new Set(appointments.map(a => a.clinicBranchId));
1431
+
1432
+ // Get favorite procedures
1433
+ const procedureMap = new Map<string, { name: string; count: number }>();
1434
+ completed.forEach(appointment => {
1435
+ const procId = appointment.procedureId;
1436
+ const procName = appointment.procedureInfo?.name || 'Unknown';
1437
+ procedureMap.set(procId, {
1438
+ name: procName,
1439
+ count: (procedureMap.get(procId)?.count || 0) + 1,
1440
+ });
1441
+ });
1442
+
1443
+ const favoriteProcedures = Array.from(procedureMap.entries())
1444
+ .map(([procedureId, data]) => ({
1445
+ procedureId,
1446
+ procedureName: data.name,
1447
+ count: data.count,
1448
+ }))
1449
+ .sort((a, b) => b.count - a.count)
1450
+ .slice(0, 5);
1451
+
1452
+ const patientName = appointments.length > 0 ? appointments[0].patientInfo?.fullName || 'Unknown' : 'Unknown';
1453
+
1454
+ return {
1455
+ patientId,
1456
+ patientName,
1457
+ totalAppointments: appointments.length,
1458
+ completedAppointments: completed.length,
1459
+ canceledAppointments: canceled.length,
1460
+ noShowAppointments: noShow.length,
1461
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
1462
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
1463
+ totalRevenue,
1464
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1465
+ currency,
1466
+ lifetimeValue: totalRevenue,
1467
+ firstAppointmentDate,
1468
+ lastAppointmentDate,
1469
+ averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
1470
+ uniquePractitioners: uniquePractitioners.size,
1471
+ uniqueClinics: uniqueClinics.size,
1472
+ favoriteProcedures,
1473
+ };
1474
+ }
1475
+
1476
+ // ==========================================
1477
+ // Dashboard Analytics
1478
+ // ==========================================
1479
+
1480
+ /**
1481
+ * Determines analytics period from date range
1482
+ */
1483
+ private determinePeriodFromDateRange(dateRange: AnalyticsDateRange): AnalyticsPeriod {
1484
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
1485
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
1486
+
1487
+ if (diffDays <= 1) return 'daily';
1488
+ if (diffDays <= 7) return 'weekly';
1489
+ if (diffDays <= 31) return 'monthly';
1490
+ if (diffDays <= 365) return 'yearly';
1491
+ return 'all_time';
1492
+ }
1493
+
1494
+ /**
1495
+ * Get comprehensive dashboard data
1496
+ * First checks for stored analytics, then calculates if not available or stale
1497
+ *
1498
+ * @param filters - Optional filters
1499
+ * @param dateRange - Optional date range filter
1500
+ * @param options - Options for reading stored analytics
1501
+ * @returns Complete dashboard analytics
1502
+ */
1503
+ async getDashboardData(
1504
+ filters?: AnalyticsFilters,
1505
+ dateRange?: AnalyticsDateRange,
1506
+ options?: ReadStoredAnalyticsOptions,
1507
+ ): Promise<DashboardAnalytics> {
1508
+ // Try to read from stored analytics first
1509
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
1510
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
1511
+ const stored = await readStoredDashboardAnalytics(
1512
+ this.db,
1513
+ filters.clinicBranchId,
1514
+ { ...options, period },
1515
+ );
1516
+
1517
+ if (stored) {
1518
+ const { metadata, ...analytics } = stored;
1519
+ return analytics;
1520
+ }
1521
+ }
1522
+
1523
+ // Fall back to calculation
1524
+ const appointments = await this.fetchAppointments(filters, dateRange);
1525
+
1526
+ const completed = getCompletedAppointments(appointments);
1527
+ const canceled = getCanceledAppointments(appointments);
1528
+ const noShow = getNoShowAppointments(appointments);
1529
+ const pending = appointments.filter(a => a.status === AppointmentStatus.PENDING);
1530
+ const confirmed = appointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
1531
+
1532
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1533
+
1534
+ // Get unique counts
1535
+ const uniquePatients = new Set(appointments.map(a => a.patientId));
1536
+ const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
1537
+ const uniqueProcedures = new Set(appointments.map(a => a.procedureId));
1538
+
1539
+ // Get top practitioners (limit to 5)
1540
+ const practitionerMetrics = await Promise.all(
1541
+ Array.from(uniquePractitioners)
1542
+ .slice(0, 5)
1543
+ .map(practitionerId => this.getPractitionerAnalytics(practitionerId, dateRange)),
1544
+ );
1545
+
1546
+ // Get top procedures (limit to 5)
1547
+ const procedureMetricsResults = await Promise.all(
1548
+ Array.from(uniqueProcedures)
1549
+ .slice(0, 5)
1550
+ .map(procedureId => this.getProcedureAnalytics(procedureId, dateRange)),
1551
+ );
1552
+ // Filter out arrays and ensure we have ProcedureAnalytics objects
1553
+ const procedureMetrics = procedureMetricsResults.filter(
1554
+ (result): result is ProcedureAnalytics => !Array.isArray(result),
1555
+ );
1556
+
1557
+ // Get cancellation and no-show metrics (aggregated)
1558
+ const cancellationMetrics = await this.getCancellationMetrics('clinic', dateRange);
1559
+ const noShowMetrics = await this.getNoShowMetrics('clinic', dateRange);
1560
+
1561
+ // Get time efficiency
1562
+ const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
1563
+
1564
+ // Get top products
1565
+ const productMetrics = await this.getProductUsageMetrics(undefined, dateRange);
1566
+ const topProducts = Array.isArray(productMetrics)
1567
+ ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5)
1568
+ : [];
1569
+
1570
+ // Get recent activity (last 10 appointments)
1571
+ const recentActivity = appointments
1572
+ .sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis())
1573
+ .slice(0, 10)
1574
+ .map(appointment => {
1575
+ let type: 'appointment' | 'cancellation' | 'completion' | 'no_show' = 'appointment';
1576
+ let description = '';
1577
+
1578
+ if (appointment.status === AppointmentStatus.COMPLETED) {
1579
+ type = 'completion';
1580
+ description = `Appointment completed: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1581
+ } else if (
1582
+ appointment.status === AppointmentStatus.CANCELED_PATIENT ||
1583
+ appointment.status === AppointmentStatus.CANCELED_CLINIC ||
1584
+ appointment.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
1585
+ ) {
1586
+ type = 'cancellation';
1587
+ description = `Appointment canceled: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1588
+ } else if (appointment.status === AppointmentStatus.NO_SHOW) {
1589
+ type = 'no_show';
1590
+ description = `No-show: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1591
+ } else {
1592
+ description = `Appointment ${appointment.status}: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1593
+ }
1594
+
1595
+ return {
1596
+ type,
1597
+ date: appointment.appointmentStartTime.toDate(),
1598
+ description,
1599
+ entityId: appointment.practitionerId,
1600
+ entityName: appointment.practitionerInfo?.name || 'Unknown',
1601
+ };
1602
+ });
1603
+
1604
+ return {
1605
+ overview: {
1606
+ totalAppointments: appointments.length,
1607
+ completedAppointments: completed.length,
1608
+ canceledAppointments: canceled.length,
1609
+ noShowAppointments: noShow.length,
1610
+ pendingAppointments: pending.length,
1611
+ confirmedAppointments: confirmed.length,
1612
+ totalRevenue,
1613
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1614
+ currency,
1615
+ uniquePatients: uniquePatients.size,
1616
+ uniquePractitioners: uniquePractitioners.size,
1617
+ uniqueProcedures: uniqueProcedures.size,
1618
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
1619
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
1620
+ },
1621
+ practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
1622
+ procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
1623
+ cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
1624
+ noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
1625
+ revenueTrends: [], // TODO: Implement revenue trends
1626
+ timeEfficiency,
1627
+ topProducts,
1628
+ recentActivity,
1629
+ };
1630
+ }
1631
+ }
1632
+