@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.
Files changed (37) hide show
  1. package/dist/admin/index.d.mts +872 -1
  2. package/dist/admin/index.d.ts +872 -1
  3. package/dist/admin/index.js +3604 -356
  4. package/dist/admin/index.mjs +3594 -357
  5. package/dist/index.d.mts +1349 -1
  6. package/dist/index.d.ts +1349 -1
  7. package/dist/index.js +5325 -2141
  8. package/dist/index.mjs +4939 -1767
  9. package/package.json +1 -1
  10. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  11. package/src/admin/analytics/index.ts +2 -0
  12. package/src/admin/index.ts +6 -0
  13. package/src/backoffice/services/analytics.service.proposal.md +4 -0
  14. package/src/services/analytics/ARCHITECTURE.md +199 -0
  15. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  16. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  17. package/src/services/analytics/QUICK_START.md +393 -0
  18. package/src/services/analytics/README.md +304 -0
  19. package/src/services/analytics/SUMMARY.md +141 -0
  20. package/src/services/analytics/TRENDS.md +380 -0
  21. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  22. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  23. package/src/services/analytics/analytics.service.ts +2142 -0
  24. package/src/services/analytics/index.ts +4 -0
  25. package/src/services/analytics/review-analytics.service.ts +941 -0
  26. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  27. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -0
  28. package/src/services/analytics/utils/grouping.utils.ts +434 -0
  29. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  30. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  31. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
  32. package/src/services/index.ts +1 -0
  33. package/src/types/analytics/analytics.types.ts +597 -0
  34. package/src/types/analytics/grouped-analytics.types.ts +173 -0
  35. package/src/types/analytics/index.ts +4 -0
  36. package/src/types/analytics/stored-analytics.types.ts +137 -0
  37. package/src/types/index.ts +3 -0
@@ -0,0 +1,2142 @@
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
+ AppointmentTrend,
17
+ CancellationRateTrend,
18
+ ProductUsageMetrics,
19
+ ProductRevenueMetrics,
20
+ ProductUsageByProcedure,
21
+ PatientAnalytics,
22
+ PatientLifetimeValueMetrics,
23
+ PatientRetentionMetrics,
24
+ CostPerPatientMetrics,
25
+ PaymentStatusBreakdown,
26
+ ClinicAnalytics,
27
+ ClinicComparisonMetrics,
28
+ ProcedurePopularity,
29
+ ProcedureProfitability,
30
+ CancellationReasonStats,
31
+ DashboardAnalytics,
32
+ AnalyticsDateRange,
33
+ AnalyticsFilters,
34
+ GroupingPeriod,
35
+ TrendPeriod,
36
+ EntityType,
37
+ } from '../../types/analytics';
38
+
39
+ // Import utility functions
40
+ import { calculateAppointmentCost, calculateTotalRevenue, extractProductUsage } from './utils/cost-calculation.utils';
41
+ import {
42
+ calculateTimeEfficiency,
43
+ calculateAverageTimeMetrics,
44
+ calculateEfficiencyDistribution,
45
+ calculateCancellationLeadTime,
46
+ } from './utils/time-calculation.utils';
47
+ import {
48
+ filterByDateRange,
49
+ filterAppointments,
50
+ getCompletedAppointments,
51
+ getCanceledAppointments,
52
+ getNoShowAppointments,
53
+ getActiveAppointments,
54
+ calculatePercentage,
55
+ } from './utils/appointment-filtering.utils';
56
+ import {
57
+ readStoredPractitionerAnalytics,
58
+ readStoredProcedureAnalytics,
59
+ readStoredClinicAnalytics,
60
+ readStoredDashboardAnalytics,
61
+ readStoredTimeEfficiencyMetrics,
62
+ readStoredRevenueMetrics,
63
+ readStoredCancellationMetrics,
64
+ readStoredNoShowMetrics,
65
+ } from './utils/stored-analytics.utils';
66
+ import {
67
+ calculateGroupedRevenueMetrics,
68
+ calculateGroupedProductUsageMetrics,
69
+ calculateGroupedTimeEfficiencyMetrics,
70
+ calculateGroupedPatientBehaviorMetrics,
71
+ } from './utils/grouping.utils';
72
+ import {
73
+ groupAppointmentsByPeriod,
74
+ generatePeriods,
75
+ getTrendChange,
76
+ type TrendPeriod as TrendPeriodType,
77
+ } from './utils/trend-calculation.utils';
78
+ import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
79
+ import {
80
+ GroupedRevenueMetrics,
81
+ GroupedProductUsageMetrics,
82
+ GroupedTimeEfficiencyMetrics,
83
+ GroupedPatientBehaviorMetrics,
84
+ } from '../../types/analytics/grouped-analytics.types';
85
+ import { ReviewAnalyticsService, ReviewAnalyticsMetrics, OverallReviewAverages, ReviewDetail } from './review-analytics.service';
86
+ import { ReviewTrend } from '../../types/analytics';
87
+
88
+ /**
89
+ * AnalyticsService provides comprehensive financial and analytical intelligence
90
+ * for the Clinic Admin app, including metrics about doctors, procedures,
91
+ * appointments, patients, products, and clinic operations.
92
+ */
93
+ export class AnalyticsService extends BaseService {
94
+ private appointmentService: AppointmentService;
95
+ private reviewAnalyticsService: ReviewAnalyticsService;
96
+
97
+ /**
98
+ * Creates a new AnalyticsService instance.
99
+ *
100
+ * @param db Firestore instance
101
+ * @param auth Firebase Auth instance
102
+ * @param app Firebase App instance
103
+ * @param appointmentService Appointment service instance for querying appointments
104
+ */
105
+ constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
106
+ super(db, auth, app);
107
+ this.appointmentService = appointmentService;
108
+ this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
109
+ }
110
+
111
+ /**
112
+ * Fetches appointments with optional filters
113
+ *
114
+ * @param filters - Optional filters
115
+ * @param dateRange - Optional date range
116
+ * @returns Array of appointments
117
+ */
118
+ private async fetchAppointments(
119
+ filters?: AnalyticsFilters,
120
+ dateRange?: AnalyticsDateRange,
121
+ ): Promise<Appointment[]> {
122
+ try {
123
+ // Build query constraints
124
+ const constraints: any[] = [];
125
+
126
+ if (filters?.clinicBranchId) {
127
+ constraints.push(where('clinicBranchId', '==', filters.clinicBranchId));
128
+ }
129
+ if (filters?.practitionerId) {
130
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
131
+ }
132
+ if (filters?.procedureId) {
133
+ constraints.push(where('procedureId', '==', filters.procedureId));
134
+ }
135
+ if (filters?.patientId) {
136
+ constraints.push(where('patientId', '==', filters.patientId));
137
+ }
138
+ if (dateRange) {
139
+ constraints.push(where('appointmentStartTime', '>=', Timestamp.fromDate(dateRange.start)));
140
+ constraints.push(where('appointmentStartTime', '<=', Timestamp.fromDate(dateRange.end)));
141
+ }
142
+
143
+ // Use AppointmentService to search appointments
144
+ const searchParams: any = {};
145
+ if (filters?.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
146
+ if (filters?.practitionerId) searchParams.practitionerId = filters.practitionerId;
147
+ if (filters?.procedureId) searchParams.procedureId = filters.procedureId;
148
+ if (filters?.patientId) searchParams.patientId = filters.patientId;
149
+ if (dateRange) {
150
+ searchParams.startDate = dateRange.start;
151
+ searchParams.endDate = dateRange.end;
152
+ }
153
+
154
+ const result = await this.appointmentService.searchAppointments(searchParams);
155
+
156
+ return result.appointments;
157
+ } catch (error) {
158
+ console.error('[AnalyticsService] Error fetching appointments:', error);
159
+ throw error;
160
+ }
161
+ }
162
+
163
+ // ==========================================
164
+ // Practitioner Analytics
165
+ // ==========================================
166
+
167
+ /**
168
+ * Get practitioner performance metrics
169
+ * First checks for stored analytics, then calculates if not available or stale
170
+ *
171
+ * @param practitionerId - ID of the practitioner
172
+ * @param dateRange - Optional date range filter
173
+ * @param options - Options for reading stored analytics
174
+ * @returns Practitioner analytics object
175
+ */
176
+ async getPractitionerAnalytics(
177
+ practitionerId: string,
178
+ dateRange?: AnalyticsDateRange,
179
+ options?: ReadStoredAnalyticsOptions,
180
+ ): Promise<PractitionerAnalytics> {
181
+ // Try to read from stored analytics first
182
+ if (dateRange && options?.useCache !== false) {
183
+ // Determine period from date range
184
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
185
+ const clinicBranchId = options?.clinicBranchId; // Would need to be passed or determined
186
+
187
+ if (clinicBranchId) {
188
+ const stored = await readStoredPractitionerAnalytics(
189
+ this.db,
190
+ clinicBranchId,
191
+ practitionerId,
192
+ { ...options, period },
193
+ );
194
+
195
+ if (stored) {
196
+ // Return stored data (without metadata)
197
+ const { metadata, ...analytics } = stored;
198
+ return analytics;
199
+ }
200
+ }
201
+ }
202
+
203
+ // Fall back to calculation
204
+ const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
205
+
206
+ const completed = getCompletedAppointments(appointments);
207
+ const canceled = getCanceledAppointments(appointments);
208
+ const noShow = getNoShowAppointments(appointments);
209
+ const pending = filterAppointments(appointments, { practitionerId }).filter(
210
+ a => a.status === AppointmentStatus.PENDING,
211
+ );
212
+ const confirmed = filterAppointments(appointments, { practitionerId }).filter(
213
+ a => a.status === AppointmentStatus.CONFIRMED,
214
+ );
215
+
216
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
217
+ const timeMetrics = calculateAverageTimeMetrics(completed);
218
+
219
+ // Get unique patients
220
+ const uniquePatients = new Set(appointments.map(a => a.patientId));
221
+ const returningPatients = new Set(
222
+ appointments
223
+ .filter(a => {
224
+ const patientAppointments = appointments.filter(ap => ap.patientId === a.patientId);
225
+ return patientAppointments.length > 1;
226
+ })
227
+ .map(a => a.patientId),
228
+ );
229
+
230
+ // Get top procedures
231
+ const procedureMap = new Map<string, { name: string; count: number; revenue: number }>();
232
+ completed.forEach(appointment => {
233
+ const procId = appointment.procedureId;
234
+ const procName = appointment.procedureInfo?.name || 'Unknown';
235
+ const cost = calculateAppointmentCost(appointment).cost;
236
+
237
+ if (procedureMap.has(procId)) {
238
+ const existing = procedureMap.get(procId)!;
239
+ existing.count++;
240
+ existing.revenue += cost;
241
+ } else {
242
+ procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
243
+ }
244
+ });
245
+
246
+ const topProcedures = Array.from(procedureMap.entries())
247
+ .map(([procedureId, data]) => ({
248
+ procedureId,
249
+ procedureName: data.name,
250
+ count: data.count,
251
+ revenue: data.revenue,
252
+ }))
253
+ .sort((a, b) => b.count - a.count)
254
+ .slice(0, 10);
255
+
256
+ const practitionerName =
257
+ appointments.length > 0
258
+ ? appointments[0].practitionerInfo?.name || 'Unknown'
259
+ : 'Unknown';
260
+
261
+ return {
262
+ total: appointments.length,
263
+ dateRange,
264
+ practitionerId,
265
+ practitionerName,
266
+ totalAppointments: appointments.length,
267
+ completedAppointments: completed.length,
268
+ canceledAppointments: canceled.length,
269
+ noShowAppointments: noShow.length,
270
+ pendingAppointments: pending.length,
271
+ confirmedAppointments: confirmed.length,
272
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
273
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
274
+ averageBookedTime: timeMetrics.averageBookedDuration,
275
+ averageActualTime: timeMetrics.averageActualDuration,
276
+ timeEfficiency: timeMetrics.averageEfficiency,
277
+ totalRevenue,
278
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
279
+ currency,
280
+ topProcedures,
281
+ patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
282
+ uniquePatients: uniquePatients.size,
283
+ };
284
+ }
285
+
286
+ // ==========================================
287
+ // Procedure Analytics
288
+ // ==========================================
289
+
290
+ /**
291
+ * Get procedure performance metrics
292
+ * First checks for stored analytics, then calculates if not available or stale
293
+ *
294
+ * @param procedureId - ID of the procedure (optional, if not provided returns all)
295
+ * @param dateRange - Optional date range filter
296
+ * @param options - Options for reading stored analytics
297
+ * @returns Procedure analytics object or array
298
+ */
299
+ async getProcedureAnalytics(
300
+ procedureId?: string,
301
+ dateRange?: AnalyticsDateRange,
302
+ options?: ReadStoredAnalyticsOptions,
303
+ ): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
304
+ // Try to read from stored analytics first (only for single procedure)
305
+ if (procedureId && dateRange && options?.useCache !== false) {
306
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
307
+ const clinicBranchId = options?.clinicBranchId;
308
+
309
+ if (clinicBranchId) {
310
+ const stored = await readStoredProcedureAnalytics(
311
+ this.db,
312
+ clinicBranchId,
313
+ procedureId,
314
+ { ...options, period },
315
+ );
316
+
317
+ if (stored) {
318
+ // Return stored data (without metadata)
319
+ const { metadata, ...analytics } = stored;
320
+ return analytics;
321
+ }
322
+ }
323
+ }
324
+
325
+ // Fall back to calculation
326
+ const appointments = await this.fetchAppointments(procedureId ? { procedureId } : undefined, dateRange);
327
+
328
+ if (procedureId) {
329
+ return this.calculateProcedureAnalytics(appointments, procedureId);
330
+ }
331
+
332
+ // Group by procedure
333
+ const procedureMap = new Map<string, Appointment[]>();
334
+ appointments.forEach(appointment => {
335
+ const procId = appointment.procedureId;
336
+ if (!procedureMap.has(procId)) {
337
+ procedureMap.set(procId, []);
338
+ }
339
+ procedureMap.get(procId)!.push(appointment);
340
+ });
341
+
342
+ return Array.from(procedureMap.entries()).map(([procId, procAppointments]) =>
343
+ this.calculateProcedureAnalytics(procAppointments, procId),
344
+ );
345
+ }
346
+
347
+ /**
348
+ * Calculate analytics for a specific procedure
349
+ *
350
+ * @param appointments - Appointments for the procedure
351
+ * @param procedureId - Procedure ID
352
+ * @returns Procedure analytics
353
+ */
354
+ private calculateProcedureAnalytics(
355
+ appointments: Appointment[],
356
+ procedureId: string,
357
+ ): ProcedureAnalytics {
358
+ const completed = getCompletedAppointments(appointments);
359
+ const canceled = getCanceledAppointments(appointments);
360
+ const noShow = getNoShowAppointments(appointments);
361
+
362
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
363
+ const timeMetrics = calculateAverageTimeMetrics(completed);
364
+
365
+ const firstAppointment = appointments[0];
366
+ const procedureInfo = firstAppointment?.procedureExtendedInfo || firstAppointment?.procedureInfo;
367
+
368
+ // Extract product usage
369
+ const productMap = new Map<
370
+ string,
371
+ { name: string; brandName: string; quantity: number; revenue: number; usageCount: number }
372
+ >();
373
+
374
+ completed.forEach(appointment => {
375
+ const products = extractProductUsage(appointment);
376
+ products.forEach(product => {
377
+ if (productMap.has(product.productId)) {
378
+ const existing = productMap.get(product.productId)!;
379
+ existing.quantity += product.quantity;
380
+ existing.revenue += product.subtotal;
381
+ existing.usageCount++;
382
+ } else {
383
+ productMap.set(product.productId, {
384
+ name: product.productName,
385
+ brandName: product.brandName,
386
+ quantity: product.quantity,
387
+ revenue: product.subtotal,
388
+ usageCount: 1,
389
+ });
390
+ }
391
+ });
392
+ });
393
+
394
+ const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
395
+ productId,
396
+ productName: data.name,
397
+ brandName: data.brandName,
398
+ totalQuantity: data.quantity,
399
+ totalRevenue: data.revenue,
400
+ usageCount: data.usageCount,
401
+ }));
402
+
403
+ return {
404
+ total: appointments.length,
405
+ procedureId,
406
+ procedureName: procedureInfo?.name || 'Unknown',
407
+ procedureFamily: procedureInfo?.procedureFamily || '',
408
+ categoryName: procedureInfo?.procedureCategoryName || '',
409
+ subcategoryName: procedureInfo?.procedureSubCategoryName || '',
410
+ technologyName: procedureInfo?.procedureTechnologyName || '',
411
+ totalAppointments: appointments.length,
412
+ completedAppointments: completed.length,
413
+ canceledAppointments: canceled.length,
414
+ noShowAppointments: noShow.length,
415
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
416
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
417
+ averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
418
+ totalRevenue,
419
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
420
+ currency,
421
+ averageBookedDuration: timeMetrics.averageBookedDuration,
422
+ averageActualDuration: timeMetrics.averageActualDuration,
423
+ productUsage,
424
+ };
425
+ }
426
+
427
+ /**
428
+ * Get procedure popularity metrics
429
+ *
430
+ * @param dateRange - Optional date range filter
431
+ * @param limit - Number of top procedures to return
432
+ * @returns Array of procedure popularity metrics
433
+ */
434
+ async getProcedurePopularity(
435
+ dateRange?: AnalyticsDateRange,
436
+ limit: number = 10,
437
+ ): Promise<ProcedurePopularity[]> {
438
+ const appointments = await this.fetchAppointments(undefined, dateRange);
439
+ const completed = getCompletedAppointments(appointments);
440
+
441
+ const procedureMap = new Map<string, { name: string; category: string; subcategory: string; technology: string; count: number }>();
442
+
443
+ completed.forEach(appointment => {
444
+ const procId = appointment.procedureId;
445
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
446
+
447
+ if (procedureMap.has(procId)) {
448
+ procedureMap.get(procId)!.count++;
449
+ } else {
450
+ procedureMap.set(procId, {
451
+ name: procInfo?.name || 'Unknown',
452
+ category: procInfo?.procedureCategoryName || '',
453
+ subcategory: procInfo?.procedureSubCategoryName || '',
454
+ technology: procInfo?.procedureTechnologyName || '',
455
+ count: 1,
456
+ });
457
+ }
458
+ });
459
+
460
+ return Array.from(procedureMap.entries())
461
+ .map(([procedureId, data]) => ({
462
+ procedureId,
463
+ procedureName: data.name,
464
+ categoryName: data.category,
465
+ subcategoryName: data.subcategory,
466
+ technologyName: data.technology,
467
+ appointmentCount: data.count,
468
+ completedCount: data.count,
469
+ rank: 0, // Will be set after sorting
470
+ }))
471
+ .sort((a, b) => b.appointmentCount - a.appointmentCount)
472
+ .slice(0, limit)
473
+ .map((item, index) => ({ ...item, rank: index + 1 }));
474
+ }
475
+
476
+ /**
477
+ * Get procedure profitability metrics
478
+ *
479
+ * @param dateRange - Optional date range filter
480
+ * @param limit - Number of top procedures to return
481
+ * @returns Array of procedure profitability metrics
482
+ */
483
+ async getProcedureProfitability(
484
+ dateRange?: AnalyticsDateRange,
485
+ limit: number = 10,
486
+ ): Promise<ProcedureProfitability[]> {
487
+ const appointments = await this.fetchAppointments(undefined, dateRange);
488
+ const completed = getCompletedAppointments(appointments);
489
+
490
+ const procedureMap = new Map<
491
+ string,
492
+ {
493
+ name: string;
494
+ category: string;
495
+ subcategory: string;
496
+ technology: string;
497
+ revenue: number;
498
+ count: number;
499
+ }
500
+ >();
501
+
502
+ completed.forEach(appointment => {
503
+ const procId = appointment.procedureId;
504
+ const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
505
+ const cost = calculateAppointmentCost(appointment).cost;
506
+
507
+ if (procedureMap.has(procId)) {
508
+ const existing = procedureMap.get(procId)!;
509
+ existing.revenue += cost;
510
+ existing.count++;
511
+ } else {
512
+ procedureMap.set(procId, {
513
+ name: procInfo?.name || 'Unknown',
514
+ category: procInfo?.procedureCategoryName || '',
515
+ subcategory: procInfo?.procedureSubCategoryName || '',
516
+ technology: procInfo?.procedureTechnologyName || '',
517
+ revenue: cost,
518
+ count: 1,
519
+ });
520
+ }
521
+ });
522
+
523
+ return Array.from(procedureMap.entries())
524
+ .map(([procedureId, data]) => ({
525
+ procedureId,
526
+ procedureName: data.name,
527
+ categoryName: data.category,
528
+ subcategoryName: data.subcategory,
529
+ technologyName: data.technology,
530
+ totalRevenue: data.revenue,
531
+ averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
532
+ appointmentCount: data.count,
533
+ rank: 0, // Will be set after sorting
534
+ }))
535
+ .sort((a, b) => b.totalRevenue - a.totalRevenue)
536
+ .slice(0, limit)
537
+ .map((item, index) => ({ ...item, rank: index + 1 }));
538
+ }
539
+
540
+ // ==========================================
541
+ // Time Efficiency Analytics
542
+ // ==========================================
543
+
544
+ /**
545
+ * Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
546
+ *
547
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
548
+ * @param dateRange - Optional date range filter
549
+ * @param filters - Optional additional filters
550
+ * @returns Grouped time efficiency metrics
551
+ */
552
+ async getTimeEfficiencyMetricsByEntity(
553
+ groupBy: EntityType,
554
+ dateRange?: AnalyticsDateRange,
555
+ filters?: AnalyticsFilters,
556
+ ): Promise<GroupedTimeEfficiencyMetrics[]> {
557
+ const appointments = await this.fetchAppointments(filters, dateRange);
558
+ return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
559
+ }
560
+
561
+ /**
562
+ * Get time efficiency metrics for appointments
563
+ * First checks for stored analytics, then calculates if not available or stale
564
+ *
565
+ * @param filters - Optional filters
566
+ * @param dateRange - Optional date range filter
567
+ * @param options - Options for reading stored analytics
568
+ * @returns Time efficiency metrics
569
+ */
570
+ async getTimeEfficiencyMetrics(
571
+ filters?: AnalyticsFilters,
572
+ dateRange?: AnalyticsDateRange,
573
+ options?: ReadStoredAnalyticsOptions,
574
+ ): Promise<TimeEfficiencyMetrics> {
575
+ // Try to read from stored analytics first
576
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
577
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
578
+ const stored = await readStoredTimeEfficiencyMetrics(
579
+ this.db,
580
+ filters.clinicBranchId,
581
+ { ...options, period },
582
+ );
583
+
584
+ if (stored) {
585
+ // Return stored data (without metadata)
586
+ const { metadata, ...metrics } = stored;
587
+ return metrics;
588
+ }
589
+ }
590
+
591
+ // Fall back to calculation
592
+ const appointments = await this.fetchAppointments(filters, dateRange);
593
+ const completed = getCompletedAppointments(appointments);
594
+
595
+ const timeMetrics = calculateAverageTimeMetrics(completed);
596
+ const efficiencyDistribution = calculateEfficiencyDistribution(completed);
597
+
598
+ return {
599
+ totalAppointments: completed.length,
600
+ appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
601
+ averageBookedDuration: timeMetrics.averageBookedDuration,
602
+ averageActualDuration: timeMetrics.averageActualDuration,
603
+ averageEfficiency: timeMetrics.averageEfficiency,
604
+ totalOverrun: timeMetrics.totalOverrun,
605
+ totalUnderutilization: timeMetrics.totalUnderutilization,
606
+ averageOverrun: timeMetrics.averageOverrun,
607
+ averageUnderutilization: timeMetrics.averageUnderutilization,
608
+ efficiencyDistribution,
609
+ };
610
+ }
611
+
612
+ // ==========================================
613
+ // Cancellation & No-Show Analytics
614
+ // ==========================================
615
+
616
+ /**
617
+ * Get cancellation metrics
618
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
619
+ *
620
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
621
+ * @param dateRange - Optional date range filter
622
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
623
+ * @returns Cancellation metrics grouped by specified entity
624
+ */
625
+ async getCancellationMetrics(
626
+ groupBy: EntityType,
627
+ dateRange?: AnalyticsDateRange,
628
+ options?: ReadStoredAnalyticsOptions,
629
+ ): Promise<CancellationMetrics | CancellationMetrics[]> {
630
+ // Try to read from stored analytics first (only for clinic-level grouping)
631
+ if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
632
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
633
+ const stored = await readStoredCancellationMetrics(
634
+ this.db,
635
+ options.clinicBranchId,
636
+ 'clinic',
637
+ { ...options, period },
638
+ );
639
+
640
+ if (stored) {
641
+ // Return stored data (without metadata)
642
+ const { metadata, ...metrics } = stored;
643
+ return metrics;
644
+ }
645
+ }
646
+
647
+ // Fall back to calculation
648
+ const appointments = await this.fetchAppointments(undefined, dateRange);
649
+ const canceled = getCanceledAppointments(appointments);
650
+
651
+ if (groupBy === 'clinic') {
652
+ return this.groupCancellationsByClinic(canceled, appointments);
653
+ } else if (groupBy === 'practitioner') {
654
+ return this.groupCancellationsByPractitioner(canceled, appointments);
655
+ } else if (groupBy === 'patient') {
656
+ return this.groupCancellationsByPatient(canceled, appointments);
657
+ } else if (groupBy === 'technology') {
658
+ return this.groupCancellationsByTechnology(canceled, appointments);
659
+ } else {
660
+ return this.groupCancellationsByProcedure(canceled, appointments);
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Group cancellations by clinic
666
+ */
667
+ private groupCancellationsByClinic(
668
+ canceled: Appointment[],
669
+ allAppointments: Appointment[],
670
+ ): CancellationMetrics[] {
671
+ const clinicMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
672
+
673
+ allAppointments.forEach(appointment => {
674
+ const clinicId = appointment.clinicBranchId;
675
+ const clinicName = appointment.clinicInfo?.name || 'Unknown';
676
+
677
+ if (!clinicMap.has(clinicId)) {
678
+ clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
679
+ }
680
+ clinicMap.get(clinicId)!.all.push(appointment);
681
+ });
682
+
683
+ canceled.forEach(appointment => {
684
+ const clinicId = appointment.clinicBranchId;
685
+ if (clinicMap.has(clinicId)) {
686
+ clinicMap.get(clinicId)!.canceled.push(appointment);
687
+ }
688
+ });
689
+
690
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) =>
691
+ this.calculateCancellationMetrics(clinicId, data.name, 'clinic', data.canceled, data.all),
692
+ );
693
+ }
694
+
695
+ /**
696
+ * Group cancellations by practitioner
697
+ */
698
+ private groupCancellationsByPractitioner(
699
+ canceled: Appointment[],
700
+ allAppointments: Appointment[],
701
+ ): CancellationMetrics[] {
702
+ const practitionerMap = new Map<
703
+ string,
704
+ { name: string; canceled: Appointment[]; all: Appointment[] }
705
+ >();
706
+
707
+ allAppointments.forEach(appointment => {
708
+ const practitionerId = appointment.practitionerId;
709
+ const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
710
+
711
+ if (!practitionerMap.has(practitionerId)) {
712
+ practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
713
+ }
714
+ practitionerMap.get(practitionerId)!.all.push(appointment);
715
+ });
716
+
717
+ canceled.forEach(appointment => {
718
+ const practitionerId = appointment.practitionerId;
719
+ if (practitionerMap.has(practitionerId)) {
720
+ practitionerMap.get(practitionerId)!.canceled.push(appointment);
721
+ }
722
+ });
723
+
724
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) =>
725
+ this.calculateCancellationMetrics(
726
+ practitionerId,
727
+ data.name,
728
+ 'practitioner',
729
+ data.canceled,
730
+ data.all,
731
+ ),
732
+ );
733
+ }
734
+
735
+ /**
736
+ * Group cancellations by patient
737
+ */
738
+ private groupCancellationsByPatient(
739
+ canceled: Appointment[],
740
+ allAppointments: Appointment[],
741
+ ): CancellationMetrics[] {
742
+ const patientMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
743
+
744
+ allAppointments.forEach(appointment => {
745
+ const patientId = appointment.patientId;
746
+ const patientName = appointment.patientInfo?.fullName || 'Unknown';
747
+
748
+ if (!patientMap.has(patientId)) {
749
+ patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
750
+ }
751
+ patientMap.get(patientId)!.all.push(appointment);
752
+ });
753
+
754
+ canceled.forEach(appointment => {
755
+ const patientId = appointment.patientId;
756
+ if (patientMap.has(patientId)) {
757
+ patientMap.get(patientId)!.canceled.push(appointment);
758
+ }
759
+ });
760
+
761
+ return Array.from(patientMap.entries()).map(([patientId, data]) =>
762
+ this.calculateCancellationMetrics(patientId, data.name, 'patient', data.canceled, data.all),
763
+ );
764
+ }
765
+
766
+ /**
767
+ * Group cancellations by procedure
768
+ */
769
+ private groupCancellationsByProcedure(
770
+ canceled: Appointment[],
771
+ allAppointments: Appointment[],
772
+ ): CancellationMetrics[] {
773
+ const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
774
+
775
+ allAppointments.forEach(appointment => {
776
+ const procedureId = appointment.procedureId;
777
+ const procedureName = appointment.procedureInfo?.name || 'Unknown';
778
+
779
+ if (!procedureMap.has(procedureId)) {
780
+ procedureMap.set(procedureId, {
781
+ name: procedureName,
782
+ canceled: [],
783
+ all: [],
784
+ practitionerId: appointment.practitionerId,
785
+ practitionerName: appointment.practitionerInfo?.name,
786
+ });
787
+ }
788
+ procedureMap.get(procedureId)!.all.push(appointment);
789
+ });
790
+
791
+ canceled.forEach(appointment => {
792
+ const procedureId = appointment.procedureId;
793
+ if (procedureMap.has(procedureId)) {
794
+ procedureMap.get(procedureId)!.canceled.push(appointment);
795
+ }
796
+ });
797
+
798
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
799
+ const metrics = this.calculateCancellationMetrics(
800
+ procedureId,
801
+ data.name,
802
+ 'procedure',
803
+ data.canceled,
804
+ data.all,
805
+ );
806
+ return {
807
+ ...metrics,
808
+ ...(data.practitionerId && { practitionerId: data.practitionerId }),
809
+ ...(data.practitionerName && { practitionerName: data.practitionerName }),
810
+ };
811
+ });
812
+ }
813
+
814
+ /**
815
+ * Group cancellations by technology
816
+ * Aggregates all procedures using the same technology across all doctors
817
+ */
818
+ private groupCancellationsByTechnology(
819
+ canceled: Appointment[],
820
+ allAppointments: Appointment[],
821
+ ): CancellationMetrics[] {
822
+ const technologyMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
823
+
824
+ allAppointments.forEach(appointment => {
825
+ const technologyId =
826
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
827
+ const technologyName =
828
+ appointment.procedureExtendedInfo?.procedureTechnologyName ||
829
+ appointment.procedureInfo?.technologyName ||
830
+ 'Unknown';
831
+
832
+ if (!technologyMap.has(technologyId)) {
833
+ technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
834
+ }
835
+ technologyMap.get(technologyId)!.all.push(appointment);
836
+ });
837
+
838
+ canceled.forEach(appointment => {
839
+ const technologyId =
840
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
841
+ if (technologyMap.has(technologyId)) {
842
+ technologyMap.get(technologyId)!.canceled.push(appointment);
843
+ }
844
+ });
845
+
846
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) =>
847
+ this.calculateCancellationMetrics(
848
+ technologyId,
849
+ data.name,
850
+ 'technology',
851
+ data.canceled,
852
+ data.all,
853
+ ),
854
+ );
855
+ }
856
+
857
+ /**
858
+ * Calculate cancellation metrics for a specific entity
859
+ */
860
+ private calculateCancellationMetrics(
861
+ entityId: string,
862
+ entityName: string,
863
+ entityType: EntityType,
864
+ canceled: Appointment[],
865
+ all: Appointment[],
866
+ ): CancellationMetrics {
867
+ const canceledByPatient = canceled.filter(
868
+ a => a.status === AppointmentStatus.CANCELED_PATIENT,
869
+ ).length;
870
+ const canceledByClinic = canceled.filter(
871
+ a => a.status === AppointmentStatus.CANCELED_CLINIC,
872
+ ).length;
873
+ const canceledRescheduled = canceled.filter(
874
+ a => a.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED,
875
+ ).length;
876
+
877
+ // Calculate average cancellation lead time
878
+ const leadTimes = canceled
879
+ .map(a => calculateCancellationLeadTime(a))
880
+ .filter((lt): lt is number => lt !== null);
881
+ const averageLeadTime =
882
+ leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
883
+
884
+ // Group cancellation reasons
885
+ const reasonMap = new Map<string, number>();
886
+ canceled.forEach(appointment => {
887
+ const reason = appointment.cancellationReason || 'No reason provided';
888
+ reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
889
+ });
890
+
891
+ const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
892
+ reason,
893
+ count,
894
+ percentage: calculatePercentage(count, canceled.length),
895
+ }));
896
+
897
+ return {
898
+ entityId,
899
+ entityName,
900
+ entityType,
901
+ totalAppointments: all.length,
902
+ canceledAppointments: canceled.length,
903
+ cancellationRate: calculatePercentage(canceled.length, all.length),
904
+ canceledByPatient,
905
+ canceledByClinic,
906
+ canceledByPractitioner: 0, // Not tracked in current status enum
907
+ canceledRescheduled,
908
+ averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
909
+ cancellationReasons,
910
+ };
911
+ }
912
+
913
+ /**
914
+ * Get no-show metrics
915
+ * First checks for stored analytics when grouping by clinic, then calculates if not available or stale
916
+ *
917
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
918
+ * @param dateRange - Optional date range filter
919
+ * @param options - Options for reading stored analytics (requires clinicBranchId for cache)
920
+ * @returns No-show metrics grouped by specified entity
921
+ */
922
+ async getNoShowMetrics(
923
+ groupBy: EntityType,
924
+ dateRange?: AnalyticsDateRange,
925
+ options?: ReadStoredAnalyticsOptions,
926
+ ): Promise<NoShowMetrics | NoShowMetrics[]> {
927
+ // Try to read from stored analytics first (only for clinic-level grouping)
928
+ if (groupBy === 'clinic' && dateRange && options?.useCache !== false && options?.clinicBranchId) {
929
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
930
+ const stored = await readStoredNoShowMetrics(
931
+ this.db,
932
+ options.clinicBranchId,
933
+ 'clinic',
934
+ { ...options, period },
935
+ );
936
+
937
+ if (stored) {
938
+ // Return stored data (without metadata)
939
+ const { metadata, ...metrics } = stored;
940
+ return metrics;
941
+ }
942
+ }
943
+
944
+ // Fall back to calculation
945
+ const appointments = await this.fetchAppointments(undefined, dateRange);
946
+ const noShow = getNoShowAppointments(appointments);
947
+
948
+ if (groupBy === 'clinic') {
949
+ return this.groupNoShowsByClinic(noShow, appointments);
950
+ } else if (groupBy === 'practitioner') {
951
+ return this.groupNoShowsByPractitioner(noShow, appointments);
952
+ } else if (groupBy === 'patient') {
953
+ return this.groupNoShowsByPatient(noShow, appointments);
954
+ } else if (groupBy === 'technology') {
955
+ return this.groupNoShowsByTechnology(noShow, appointments);
956
+ } else {
957
+ return this.groupNoShowsByProcedure(noShow, appointments);
958
+ }
959
+ }
960
+
961
+ /**
962
+ * Group no-shows by clinic
963
+ */
964
+ private groupNoShowsByClinic(
965
+ noShow: Appointment[],
966
+ allAppointments: Appointment[],
967
+ ): NoShowMetrics[] {
968
+ const clinicMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
969
+
970
+ allAppointments.forEach(appointment => {
971
+ const clinicId = appointment.clinicBranchId;
972
+ const clinicName = appointment.clinicInfo?.name || 'Unknown';
973
+ if (!clinicMap.has(clinicId)) {
974
+ clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
975
+ }
976
+ clinicMap.get(clinicId)!.all.push(appointment);
977
+ });
978
+
979
+ noShow.forEach(appointment => {
980
+ const clinicId = appointment.clinicBranchId;
981
+ if (clinicMap.has(clinicId)) {
982
+ clinicMap.get(clinicId)!.noShow.push(appointment);
983
+ }
984
+ });
985
+
986
+ return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
987
+ entityId: clinicId,
988
+ entityName: data.name,
989
+ entityType: 'clinic' as EntityType,
990
+ totalAppointments: data.all.length,
991
+ noShowAppointments: data.noShow.length,
992
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
993
+ }));
994
+ }
995
+
996
+ /**
997
+ * Group no-shows by practitioner
998
+ */
999
+ private groupNoShowsByPractitioner(
1000
+ noShow: Appointment[],
1001
+ allAppointments: Appointment[],
1002
+ ): NoShowMetrics[] {
1003
+ const practitionerMap = new Map<
1004
+ string,
1005
+ { name: string; noShow: Appointment[]; all: Appointment[] }
1006
+ >();
1007
+
1008
+ allAppointments.forEach(appointment => {
1009
+ const practitionerId = appointment.practitionerId;
1010
+ const practitionerName = appointment.practitionerInfo?.name || 'Unknown';
1011
+
1012
+ if (!practitionerMap.has(practitionerId)) {
1013
+ practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
1014
+ }
1015
+ practitionerMap.get(practitionerId)!.all.push(appointment);
1016
+ });
1017
+
1018
+ noShow.forEach(appointment => {
1019
+ const practitionerId = appointment.practitionerId;
1020
+ if (practitionerMap.has(practitionerId)) {
1021
+ practitionerMap.get(practitionerId)!.noShow.push(appointment);
1022
+ }
1023
+ });
1024
+
1025
+ return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
1026
+ entityId: practitionerId,
1027
+ entityName: data.name,
1028
+ entityType: 'practitioner' as EntityType,
1029
+ totalAppointments: data.all.length,
1030
+ noShowAppointments: data.noShow.length,
1031
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1032
+ }));
1033
+ }
1034
+
1035
+ /**
1036
+ * Group no-shows by patient
1037
+ */
1038
+ private groupNoShowsByPatient(
1039
+ noShow: Appointment[],
1040
+ allAppointments: Appointment[],
1041
+ ): NoShowMetrics[] {
1042
+ const patientMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1043
+
1044
+ allAppointments.forEach(appointment => {
1045
+ const patientId = appointment.patientId;
1046
+ const patientName = appointment.patientInfo?.fullName || 'Unknown';
1047
+
1048
+ if (!patientMap.has(patientId)) {
1049
+ patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
1050
+ }
1051
+ patientMap.get(patientId)!.all.push(appointment);
1052
+ });
1053
+
1054
+ noShow.forEach(appointment => {
1055
+ const patientId = appointment.patientId;
1056
+ if (patientMap.has(patientId)) {
1057
+ patientMap.get(patientId)!.noShow.push(appointment);
1058
+ }
1059
+ });
1060
+
1061
+ return Array.from(patientMap.entries()).map(([patientId, data]) => ({
1062
+ entityId: patientId,
1063
+ entityName: data.name,
1064
+ entityType: 'patient' as EntityType,
1065
+ totalAppointments: data.all.length,
1066
+ noShowAppointments: data.noShow.length,
1067
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1068
+ }));
1069
+ }
1070
+
1071
+ /**
1072
+ * Group no-shows by procedure
1073
+ */
1074
+ private groupNoShowsByProcedure(
1075
+ noShow: Appointment[],
1076
+ allAppointments: Appointment[],
1077
+ ): NoShowMetrics[] {
1078
+ const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
1079
+
1080
+ allAppointments.forEach(appointment => {
1081
+ const procedureId = appointment.procedureId;
1082
+ const procedureName = appointment.procedureInfo?.name || 'Unknown';
1083
+
1084
+ if (!procedureMap.has(procedureId)) {
1085
+ procedureMap.set(procedureId, {
1086
+ name: procedureName,
1087
+ noShow: [],
1088
+ all: [],
1089
+ practitionerId: appointment.practitionerId,
1090
+ practitionerName: appointment.practitionerInfo?.name,
1091
+ });
1092
+ }
1093
+ procedureMap.get(procedureId)!.all.push(appointment);
1094
+ });
1095
+
1096
+ noShow.forEach(appointment => {
1097
+ const procedureId = appointment.procedureId;
1098
+ if (procedureMap.has(procedureId)) {
1099
+ procedureMap.get(procedureId)!.noShow.push(appointment);
1100
+ }
1101
+ });
1102
+
1103
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
1104
+ entityId: procedureId,
1105
+ entityName: data.name,
1106
+ entityType: 'procedure' as EntityType,
1107
+ totalAppointments: data.all.length,
1108
+ noShowAppointments: data.noShow.length,
1109
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1110
+ ...(data.practitionerId && { practitionerId: data.practitionerId }),
1111
+ ...(data.practitionerName && { practitionerName: data.practitionerName }),
1112
+ }));
1113
+ }
1114
+
1115
+ /**
1116
+ * Group no-shows by technology
1117
+ * Aggregates all procedures using the same technology across all doctors
1118
+ */
1119
+ private groupNoShowsByTechnology(
1120
+ noShow: Appointment[],
1121
+ allAppointments: Appointment[],
1122
+ ): NoShowMetrics[] {
1123
+ const technologyMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1124
+
1125
+ allAppointments.forEach(appointment => {
1126
+ const technologyId =
1127
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
1128
+ const technologyName =
1129
+ appointment.procedureExtendedInfo?.procedureTechnologyName ||
1130
+ appointment.procedureInfo?.technologyName ||
1131
+ 'Unknown';
1132
+
1133
+ if (!technologyMap.has(technologyId)) {
1134
+ technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
1135
+ }
1136
+ technologyMap.get(technologyId)!.all.push(appointment);
1137
+ });
1138
+
1139
+ noShow.forEach(appointment => {
1140
+ const technologyId =
1141
+ appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
1142
+ if (technologyMap.has(technologyId)) {
1143
+ technologyMap.get(technologyId)!.noShow.push(appointment);
1144
+ }
1145
+ });
1146
+
1147
+ return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
1148
+ entityId: technologyId,
1149
+ entityName: data.name,
1150
+ entityType: 'technology' as EntityType,
1151
+ totalAppointments: data.all.length,
1152
+ noShowAppointments: data.noShow.length,
1153
+ noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1154
+ }));
1155
+ }
1156
+
1157
+ // ==========================================
1158
+ // Financial Analytics
1159
+ // ==========================================
1160
+
1161
+ /**
1162
+ * Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
1163
+ *
1164
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
1165
+ * @param dateRange - Optional date range filter
1166
+ * @param filters - Optional additional filters
1167
+ * @returns Grouped revenue metrics
1168
+ */
1169
+ async getRevenueMetricsByEntity(
1170
+ groupBy: EntityType,
1171
+ dateRange?: AnalyticsDateRange,
1172
+ filters?: AnalyticsFilters,
1173
+ ): Promise<GroupedRevenueMetrics[]> {
1174
+ const appointments = await this.fetchAppointments(filters, dateRange);
1175
+ return calculateGroupedRevenueMetrics(appointments, groupBy);
1176
+ }
1177
+
1178
+ /**
1179
+ * Get revenue metrics
1180
+ * First checks for stored analytics, then calculates if not available or stale
1181
+ *
1182
+ * IMPORTANT: Financial calculations only consider COMPLETED appointments.
1183
+ * Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
1184
+ * Only procedures that have been completed generate revenue.
1185
+ *
1186
+ * @param filters - Optional filters
1187
+ * @param dateRange - Optional date range filter
1188
+ * @param options - Options for reading stored analytics
1189
+ * @returns Revenue metrics
1190
+ */
1191
+ async getRevenueMetrics(
1192
+ filters?: AnalyticsFilters,
1193
+ dateRange?: AnalyticsDateRange,
1194
+ options?: ReadStoredAnalyticsOptions,
1195
+ ): Promise<RevenueMetrics> {
1196
+ // Try to read from stored analytics first
1197
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
1198
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
1199
+ const stored = await readStoredRevenueMetrics(
1200
+ this.db,
1201
+ filters.clinicBranchId,
1202
+ { ...options, period },
1203
+ );
1204
+
1205
+ if (stored) {
1206
+ // Return stored data (without metadata)
1207
+ const { metadata, ...metrics } = stored;
1208
+ return metrics;
1209
+ }
1210
+ }
1211
+
1212
+ // Fall back to calculation
1213
+ const appointments = await this.fetchAppointments(filters, dateRange);
1214
+ const completed = getCompletedAppointments(appointments);
1215
+
1216
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1217
+
1218
+ // Calculate revenue by status - ONLY for COMPLETED appointments
1219
+ // Financial calculations should only consider completed procedures
1220
+ const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
1221
+ // Only calculate revenue for COMPLETED status (other statuses have no revenue)
1222
+ const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
1223
+ revenueByStatus[AppointmentStatus.COMPLETED] = completedRevenue;
1224
+ // All other statuses have 0 revenue (confirmed, pending, canceled, etc. don't generate revenue)
1225
+
1226
+ // Calculate revenue by payment status
1227
+ const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
1228
+ Object.values(PaymentStatus).forEach(paymentStatus => {
1229
+ const paymentAppointments = completed.filter(a => a.paymentStatus === paymentStatus);
1230
+ const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
1231
+ revenueByPaymentStatus[paymentStatus] = paymentRevenue;
1232
+ });
1233
+
1234
+ const unpaid = completed.filter(a => a.paymentStatus === PaymentStatus.UNPAID);
1235
+ const refunded = completed.filter(a => a.paymentStatus === PaymentStatus.REFUNDED);
1236
+
1237
+ const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
1238
+ const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
1239
+
1240
+ // Calculate tax and subtotal from finalbilling if available
1241
+ let totalTax = 0;
1242
+ let totalSubtotal = 0;
1243
+ completed.forEach(appointment => {
1244
+ const costData = calculateAppointmentCost(appointment);
1245
+ if (costData.source === 'finalbilling') {
1246
+ totalTax += costData.tax || 0;
1247
+ totalSubtotal += costData.subtotal || 0;
1248
+ } else {
1249
+ totalSubtotal += costData.cost;
1250
+ }
1251
+ });
1252
+
1253
+ return {
1254
+ totalRevenue,
1255
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1256
+ totalAppointments: appointments.length,
1257
+ completedAppointments: completed.length,
1258
+ currency,
1259
+ revenueByStatus,
1260
+ revenueByPaymentStatus,
1261
+ unpaidRevenue,
1262
+ refundedRevenue,
1263
+ totalTax,
1264
+ totalSubtotal,
1265
+ };
1266
+ }
1267
+
1268
+ // ==========================================
1269
+ // Product Usage Analytics
1270
+ // ==========================================
1271
+
1272
+ /**
1273
+ * Get product usage metrics grouped by clinic, practitioner, procedure, or patient
1274
+ *
1275
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
1276
+ * @param dateRange - Optional date range filter
1277
+ * @param filters - Optional additional filters
1278
+ * @returns Grouped product usage metrics
1279
+ */
1280
+ async getProductUsageMetricsByEntity(
1281
+ groupBy: EntityType,
1282
+ dateRange?: AnalyticsDateRange,
1283
+ filters?: AnalyticsFilters,
1284
+ ): Promise<GroupedProductUsageMetrics[]> {
1285
+ const appointments = await this.fetchAppointments(filters, dateRange);
1286
+ return calculateGroupedProductUsageMetrics(appointments, groupBy);
1287
+ }
1288
+
1289
+ /**
1290
+ * Get product usage metrics
1291
+ *
1292
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
1293
+ * Products are only considered "used" when the procedure has been completed.
1294
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
1295
+ *
1296
+ * @param productId - Optional product ID (if not provided, returns all products)
1297
+ * @param dateRange - Optional date range filter
1298
+ * @param filters - Optional filters (e.g., clinicBranchId)
1299
+ * @returns Product usage metrics
1300
+ */
1301
+ async getProductUsageMetrics(
1302
+ productId?: string,
1303
+ dateRange?: AnalyticsDateRange,
1304
+ filters?: AnalyticsFilters,
1305
+ ): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
1306
+ const appointments = await this.fetchAppointments(filters, dateRange);
1307
+ const completed = getCompletedAppointments(appointments);
1308
+
1309
+ const productMap = new Map<
1310
+ string,
1311
+ {
1312
+ name: string;
1313
+ brandId: string;
1314
+ brandName: string;
1315
+ quantity: number;
1316
+ revenue: number;
1317
+ usageCount: number;
1318
+ appointmentIds: Set<string>; // Track which appointments used this product
1319
+ procedureMap: Map<string, { name: string; count: number; quantity: number }>;
1320
+ }
1321
+ >();
1322
+
1323
+ completed.forEach(appointment => {
1324
+ const products = extractProductUsage(appointment);
1325
+ // Track which products were used in this appointment to count appointments, not product entries
1326
+ const productsInThisAppointment = new Set<string>();
1327
+
1328
+ products.forEach(product => {
1329
+ if (productId && product.productId !== productId) {
1330
+ return;
1331
+ }
1332
+
1333
+ if (!productMap.has(product.productId)) {
1334
+ productMap.set(product.productId, {
1335
+ name: product.productName,
1336
+ brandId: product.brandId,
1337
+ brandName: product.brandName,
1338
+ quantity: 0,
1339
+ revenue: 0,
1340
+ usageCount: 0,
1341
+ appointmentIds: new Set(),
1342
+ procedureMap: new Map(),
1343
+ });
1344
+ }
1345
+
1346
+ const productData = productMap.get(product.productId)!;
1347
+ productData.quantity += product.quantity;
1348
+ productData.revenue += product.subtotal;
1349
+
1350
+ // Track that this product was used in this appointment
1351
+ productsInThisAppointment.add(product.productId);
1352
+ });
1353
+
1354
+ // After processing all products from this appointment, increment usageCount once per product
1355
+ productsInThisAppointment.forEach(productId => {
1356
+ const productData = productMap.get(productId)!;
1357
+ if (!productData.appointmentIds.has(appointment.id)) {
1358
+ productData.appointmentIds.add(appointment.id);
1359
+ productData.usageCount++;
1360
+
1361
+ // Track usage by procedure (only once per appointment)
1362
+ const procId = appointment.procedureId;
1363
+ const procName = appointment.procedureInfo?.name || 'Unknown';
1364
+ if (productData.procedureMap.has(procId)) {
1365
+ const procData = productData.procedureMap.get(procId)!;
1366
+ procData.count++;
1367
+ // Sum all quantities for this product in this appointment
1368
+ const appointmentProducts = products.filter(p => p.productId === productId);
1369
+ procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
1370
+ } else {
1371
+ const appointmentProducts = products.filter(p => p.productId === productId);
1372
+ const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
1373
+ productData.procedureMap.set(procId, {
1374
+ name: procName,
1375
+ count: 1,
1376
+ quantity: totalQuantity,
1377
+ });
1378
+ }
1379
+ }
1380
+ });
1381
+ });
1382
+
1383
+ const results = Array.from(productMap.entries()).map(([productId, data]) => ({
1384
+ productId,
1385
+ productName: data.name,
1386
+ brandId: data.brandId,
1387
+ brandName: data.brandName,
1388
+ totalQuantity: data.quantity,
1389
+ totalRevenue: data.revenue,
1390
+ averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
1391
+ currency: 'CHF', // Could be extracted from products
1392
+ usageCount: data.usageCount,
1393
+ averageQuantityPerAppointment:
1394
+ data.usageCount > 0 ? data.quantity / data.usageCount : 0,
1395
+ usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
1396
+ procedureId: procId,
1397
+ procedureName: procData.name,
1398
+ count: procData.count,
1399
+ totalQuantity: procData.quantity,
1400
+ })),
1401
+ }));
1402
+
1403
+ return productId ? results[0] : results;
1404
+ }
1405
+
1406
+ // ==========================================
1407
+ // Patient Analytics
1408
+ // ==========================================
1409
+
1410
+ /**
1411
+ * Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
1412
+ * Shows patient no-show and cancellation patterns per entity
1413
+ *
1414
+ * @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
1415
+ * @param dateRange - Optional date range filter
1416
+ * @param filters - Optional additional filters
1417
+ * @returns Grouped patient behavior metrics
1418
+ */
1419
+ async getPatientBehaviorMetricsByEntity(
1420
+ groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
1421
+ dateRange?: AnalyticsDateRange,
1422
+ filters?: AnalyticsFilters,
1423
+ ): Promise<GroupedPatientBehaviorMetrics[]> {
1424
+ const appointments = await this.fetchAppointments(filters, dateRange);
1425
+ return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
1426
+ }
1427
+
1428
+ /**
1429
+ * Get patient analytics
1430
+ *
1431
+ * @param patientId - Optional patient ID (if not provided, returns aggregate)
1432
+ * @param dateRange - Optional date range filter
1433
+ * @returns Patient analytics
1434
+ */
1435
+ async getPatientAnalytics(
1436
+ patientId?: string,
1437
+ dateRange?: AnalyticsDateRange,
1438
+ ): Promise<PatientAnalytics | PatientAnalytics[]> {
1439
+ const appointments = await this.fetchAppointments(patientId ? { patientId } : undefined, dateRange);
1440
+
1441
+ if (patientId) {
1442
+ return this.calculatePatientAnalytics(appointments, patientId);
1443
+ }
1444
+
1445
+ // Group by patient
1446
+ const patientMap = new Map<string, Appointment[]>();
1447
+ appointments.forEach(appointment => {
1448
+ const patId = appointment.patientId;
1449
+ if (!patientMap.has(patId)) {
1450
+ patientMap.set(patId, []);
1451
+ }
1452
+ patientMap.get(patId)!.push(appointment);
1453
+ });
1454
+
1455
+ return Array.from(patientMap.entries()).map(([patId, patAppointments]) =>
1456
+ this.calculatePatientAnalytics(patAppointments, patId),
1457
+ );
1458
+ }
1459
+
1460
+ /**
1461
+ * Calculate analytics for a specific patient
1462
+ */
1463
+ private calculatePatientAnalytics(appointments: Appointment[], patientId: string): PatientAnalytics {
1464
+ const completed = getCompletedAppointments(appointments);
1465
+ const canceled = getCanceledAppointments(appointments);
1466
+ const noShow = getNoShowAppointments(appointments);
1467
+
1468
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1469
+
1470
+ // Get appointment dates
1471
+ const appointmentDates = appointments
1472
+ .map(a => a.appointmentStartTime.toDate())
1473
+ .sort((a, b) => a.getTime() - b.getTime());
1474
+
1475
+ const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
1476
+ const lastAppointmentDate =
1477
+ appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
1478
+
1479
+ // Calculate average days between appointments
1480
+ let averageDaysBetween = null;
1481
+ if (appointmentDates.length > 1) {
1482
+ const intervals: number[] = [];
1483
+ for (let i = 1; i < appointmentDates.length; i++) {
1484
+ const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
1485
+ intervals.push(diffMs / (1000 * 60 * 60 * 24));
1486
+ }
1487
+ averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1488
+ }
1489
+
1490
+ // Get unique practitioners and clinics
1491
+ const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
1492
+ const uniqueClinics = new Set(appointments.map(a => a.clinicBranchId));
1493
+
1494
+ // Get favorite procedures
1495
+ const procedureMap = new Map<string, { name: string; count: number }>();
1496
+ completed.forEach(appointment => {
1497
+ const procId = appointment.procedureId;
1498
+ const procName = appointment.procedureInfo?.name || 'Unknown';
1499
+ procedureMap.set(procId, {
1500
+ name: procName,
1501
+ count: (procedureMap.get(procId)?.count || 0) + 1,
1502
+ });
1503
+ });
1504
+
1505
+ const favoriteProcedures = Array.from(procedureMap.entries())
1506
+ .map(([procedureId, data]) => ({
1507
+ procedureId,
1508
+ procedureName: data.name,
1509
+ count: data.count,
1510
+ }))
1511
+ .sort((a, b) => b.count - a.count)
1512
+ .slice(0, 5);
1513
+
1514
+ const patientName = appointments.length > 0 ? appointments[0].patientInfo?.fullName || 'Unknown' : 'Unknown';
1515
+
1516
+ return {
1517
+ patientId,
1518
+ patientName,
1519
+ totalAppointments: appointments.length,
1520
+ completedAppointments: completed.length,
1521
+ canceledAppointments: canceled.length,
1522
+ noShowAppointments: noShow.length,
1523
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
1524
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
1525
+ totalRevenue,
1526
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1527
+ currency,
1528
+ lifetimeValue: totalRevenue,
1529
+ firstAppointmentDate,
1530
+ lastAppointmentDate,
1531
+ averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
1532
+ uniquePractitioners: uniquePractitioners.size,
1533
+ uniqueClinics: uniqueClinics.size,
1534
+ favoriteProcedures,
1535
+ };
1536
+ }
1537
+
1538
+ // ==========================================
1539
+ // Dashboard Analytics
1540
+ // ==========================================
1541
+
1542
+ /**
1543
+ * Determines analytics period from date range
1544
+ */
1545
+ private determinePeriodFromDateRange(dateRange: AnalyticsDateRange): AnalyticsPeriod {
1546
+ const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
1547
+ const diffDays = diffMs / (1000 * 60 * 60 * 24);
1548
+
1549
+ if (diffDays <= 1) return 'daily';
1550
+ if (diffDays <= 7) return 'weekly';
1551
+ if (diffDays <= 31) return 'monthly';
1552
+ if (diffDays <= 365) return 'yearly';
1553
+ return 'all_time';
1554
+ }
1555
+
1556
+ /**
1557
+ * Get comprehensive dashboard data
1558
+ * First checks for stored analytics, then calculates if not available or stale
1559
+ *
1560
+ * @param filters - Optional filters
1561
+ * @param dateRange - Optional date range filter
1562
+ * @param options - Options for reading stored analytics
1563
+ * @returns Complete dashboard analytics
1564
+ */
1565
+ async getDashboardData(
1566
+ filters?: AnalyticsFilters,
1567
+ dateRange?: AnalyticsDateRange,
1568
+ options?: ReadStoredAnalyticsOptions,
1569
+ ): Promise<DashboardAnalytics> {
1570
+ // Try to read from stored analytics first
1571
+ if (filters?.clinicBranchId && dateRange && options?.useCache !== false) {
1572
+ const period: AnalyticsPeriod = this.determinePeriodFromDateRange(dateRange);
1573
+ const stored = await readStoredDashboardAnalytics(
1574
+ this.db,
1575
+ filters.clinicBranchId,
1576
+ { ...options, period },
1577
+ );
1578
+
1579
+ if (stored) {
1580
+ const { metadata, ...analytics } = stored;
1581
+ return analytics;
1582
+ }
1583
+ }
1584
+
1585
+ // Fall back to calculation
1586
+ const appointments = await this.fetchAppointments(filters, dateRange);
1587
+
1588
+ const completed = getCompletedAppointments(appointments);
1589
+ const canceled = getCanceledAppointments(appointments);
1590
+ const noShow = getNoShowAppointments(appointments);
1591
+ const pending = appointments.filter(a => a.status === AppointmentStatus.PENDING);
1592
+ const confirmed = appointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
1593
+
1594
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1595
+
1596
+ // Get unique counts
1597
+ const uniquePatients = new Set(appointments.map(a => a.patientId));
1598
+ const uniquePractitioners = new Set(appointments.map(a => a.practitionerId));
1599
+ const uniqueProcedures = new Set(appointments.map(a => a.procedureId));
1600
+
1601
+ // Get top practitioners (limit to 5)
1602
+ const practitionerMetrics = await Promise.all(
1603
+ Array.from(uniquePractitioners)
1604
+ .slice(0, 5)
1605
+ .map(practitionerId => this.getPractitionerAnalytics(practitionerId, dateRange)),
1606
+ );
1607
+
1608
+ // Get top procedures (limit to 5)
1609
+ const procedureMetricsResults = await Promise.all(
1610
+ Array.from(uniqueProcedures)
1611
+ .slice(0, 5)
1612
+ .map(procedureId => this.getProcedureAnalytics(procedureId, dateRange)),
1613
+ );
1614
+ // Filter out arrays and ensure we have ProcedureAnalytics objects
1615
+ const procedureMetrics = procedureMetricsResults.filter(
1616
+ (result): result is ProcedureAnalytics => !Array.isArray(result),
1617
+ );
1618
+
1619
+ // Get cancellation and no-show metrics (aggregated)
1620
+ const cancellationMetrics = await this.getCancellationMetrics('clinic', dateRange);
1621
+ const noShowMetrics = await this.getNoShowMetrics('clinic', dateRange);
1622
+
1623
+ // Get time efficiency
1624
+ const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
1625
+
1626
+ // Get top products
1627
+ const productMetrics = await this.getProductUsageMetrics(undefined, dateRange);
1628
+ const topProducts = Array.isArray(productMetrics)
1629
+ ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5)
1630
+ : [];
1631
+
1632
+ // Get recent activity (last 10 appointments)
1633
+ const recentActivity = appointments
1634
+ .sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis())
1635
+ .slice(0, 10)
1636
+ .map(appointment => {
1637
+ let type: 'appointment' | 'cancellation' | 'completion' | 'no_show' = 'appointment';
1638
+ let description = '';
1639
+
1640
+ if (appointment.status === AppointmentStatus.COMPLETED) {
1641
+ type = 'completion';
1642
+ description = `Appointment completed: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1643
+ } else if (
1644
+ appointment.status === AppointmentStatus.CANCELED_PATIENT ||
1645
+ appointment.status === AppointmentStatus.CANCELED_CLINIC ||
1646
+ appointment.status === AppointmentStatus.CANCELED_PATIENT_RESCHEDULED
1647
+ ) {
1648
+ type = 'cancellation';
1649
+ description = `Appointment canceled: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1650
+ } else if (appointment.status === AppointmentStatus.NO_SHOW) {
1651
+ type = 'no_show';
1652
+ description = `No-show: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1653
+ } else {
1654
+ description = `Appointment ${appointment.status}: ${appointment.procedureInfo?.name || 'Unknown procedure'}`;
1655
+ }
1656
+
1657
+ return {
1658
+ type,
1659
+ date: appointment.appointmentStartTime.toDate(),
1660
+ description,
1661
+ entityId: appointment.practitionerId,
1662
+ entityName: appointment.practitionerInfo?.name || 'Unknown',
1663
+ };
1664
+ });
1665
+
1666
+ return {
1667
+ overview: {
1668
+ totalAppointments: appointments.length,
1669
+ completedAppointments: completed.length,
1670
+ canceledAppointments: canceled.length,
1671
+ noShowAppointments: noShow.length,
1672
+ pendingAppointments: pending.length,
1673
+ confirmedAppointments: confirmed.length,
1674
+ totalRevenue,
1675
+ averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
1676
+ currency,
1677
+ uniquePatients: uniquePatients.size,
1678
+ uniquePractitioners: uniquePractitioners.size,
1679
+ uniqueProcedures: uniqueProcedures.size,
1680
+ cancellationRate: calculatePercentage(canceled.length, appointments.length),
1681
+ noShowRate: calculatePercentage(noShow.length, appointments.length),
1682
+ },
1683
+ practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
1684
+ procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
1685
+ cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
1686
+ noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
1687
+ revenueTrends: [], // TODO: Implement revenue trends
1688
+ timeEfficiency,
1689
+ topProducts,
1690
+ recentActivity,
1691
+ };
1692
+ }
1693
+
1694
+ /**
1695
+ * Calculate revenue trends over time
1696
+ * Groups appointments by week/month/quarter/year and calculates revenue metrics
1697
+ *
1698
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
1699
+ * @param period - Period type (week, month, quarter, year)
1700
+ * @param filters - Optional filters for clinic, practitioner, procedure, patient
1701
+ * @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
1702
+ * @returns Array of revenue trends with percentage changes
1703
+ */
1704
+ async getRevenueTrends(
1705
+ dateRange: AnalyticsDateRange,
1706
+ period: TrendPeriod,
1707
+ filters?: AnalyticsFilters,
1708
+ groupBy?: EntityType,
1709
+ ): Promise<RevenueTrend[]> {
1710
+ const appointments = await this.fetchAppointments(filters);
1711
+ const filtered = filterByDateRange(appointments, dateRange);
1712
+
1713
+ if (filtered.length === 0) {
1714
+ return [];
1715
+ }
1716
+
1717
+ // If grouping by entity, calculate trends per entity
1718
+ if (groupBy) {
1719
+ return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
1720
+ }
1721
+
1722
+ // Calculate overall trends
1723
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1724
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1725
+ const trends: RevenueTrend[] = [];
1726
+
1727
+ let previousRevenue = 0;
1728
+ let previousAppointmentCount = 0;
1729
+
1730
+ periods.forEach(periodInfo => {
1731
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1732
+ const completed = getCompletedAppointments(periodAppointments);
1733
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1734
+
1735
+ const appointmentCount = completed.length;
1736
+ const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
1737
+
1738
+ const trend: RevenueTrend = {
1739
+ period: periodInfo.period,
1740
+ startDate: periodInfo.startDate,
1741
+ endDate: periodInfo.endDate,
1742
+ revenue: totalRevenue,
1743
+ appointmentCount,
1744
+ averageRevenue,
1745
+ currency,
1746
+ };
1747
+
1748
+ // Calculate percentage change from previous period
1749
+ if (previousRevenue > 0 || previousAppointmentCount > 0) {
1750
+ const revenueChange = getTrendChange(totalRevenue, previousRevenue);
1751
+ trend.previousPeriod = {
1752
+ revenue: previousRevenue,
1753
+ appointmentCount: previousAppointmentCount,
1754
+ percentageChange: revenueChange.percentageChange,
1755
+ direction: revenueChange.direction,
1756
+ };
1757
+ }
1758
+
1759
+ trends.push(trend);
1760
+ previousRevenue = totalRevenue;
1761
+ previousAppointmentCount = appointmentCount;
1762
+ });
1763
+
1764
+ return trends;
1765
+ }
1766
+
1767
+ /**
1768
+ * Calculate revenue trends grouped by entity
1769
+ */
1770
+ private async getGroupedRevenueTrends(
1771
+ appointments: Appointment[],
1772
+ dateRange: AnalyticsDateRange,
1773
+ period: TrendPeriod,
1774
+ groupBy: EntityType,
1775
+ ): Promise<RevenueTrend[]> {
1776
+ const periodMap = groupAppointmentsByPeriod(appointments, period as TrendPeriodType);
1777
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1778
+ const trends: RevenueTrend[] = [];
1779
+
1780
+ // Group appointments by entity for each period
1781
+ periods.forEach(periodInfo => {
1782
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1783
+ if (periodAppointments.length === 0) return;
1784
+
1785
+ const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
1786
+
1787
+ // Sum up all entities for this period
1788
+ const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
1789
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
1790
+ const currency = groupedMetrics[0]?.currency || 'CHF';
1791
+ const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
1792
+
1793
+ trends.push({
1794
+ period: periodInfo.period,
1795
+ startDate: periodInfo.startDate,
1796
+ endDate: periodInfo.endDate,
1797
+ revenue: totalRevenue,
1798
+ appointmentCount: totalAppointments,
1799
+ averageRevenue,
1800
+ currency,
1801
+ });
1802
+ });
1803
+
1804
+ // Calculate percentage changes
1805
+ for (let i = 1; i < trends.length; i++) {
1806
+ const current = trends[i];
1807
+ const previous = trends[i - 1];
1808
+ const revenueChange = getTrendChange(current.revenue, previous.revenue);
1809
+
1810
+ current.previousPeriod = {
1811
+ revenue: previous.revenue,
1812
+ appointmentCount: previous.appointmentCount,
1813
+ percentageChange: revenueChange.percentageChange,
1814
+ direction: revenueChange.direction,
1815
+ };
1816
+ }
1817
+
1818
+ return trends;
1819
+ }
1820
+
1821
+ /**
1822
+ * Calculate duration/efficiency trends over time
1823
+ *
1824
+ * @param dateRange - Date range for trend analysis
1825
+ * @param period - Period type (week, month, quarter, year)
1826
+ * @param filters - Optional filters
1827
+ * @param groupBy - Optional entity type to group trends by
1828
+ * @returns Array of duration trends with percentage changes
1829
+ */
1830
+ async getDurationTrends(
1831
+ dateRange: AnalyticsDateRange,
1832
+ period: TrendPeriod,
1833
+ filters?: AnalyticsFilters,
1834
+ groupBy?: EntityType,
1835
+ ): Promise<DurationTrend[]> {
1836
+ const appointments = await this.fetchAppointments(filters);
1837
+ const filtered = filterByDateRange(appointments, dateRange);
1838
+
1839
+ if (filtered.length === 0) {
1840
+ return [];
1841
+ }
1842
+
1843
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1844
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1845
+ const trends: DurationTrend[] = [];
1846
+
1847
+ let previousEfficiency = 0;
1848
+ let previousBookedDuration = 0;
1849
+ let previousActualDuration = 0;
1850
+
1851
+ periods.forEach(periodInfo => {
1852
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1853
+ const completed = getCompletedAppointments(periodAppointments);
1854
+
1855
+ if (groupBy) {
1856
+ // Group by entity and calculate average
1857
+ const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
1858
+ if (groupedMetrics.length === 0) return;
1859
+
1860
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
1861
+ const weightedBooked = groupedMetrics.reduce(
1862
+ (sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
1863
+ 0,
1864
+ );
1865
+ const weightedActual = groupedMetrics.reduce(
1866
+ (sum, m) => sum + m.averageActualDuration * m.totalAppointments,
1867
+ 0,
1868
+ );
1869
+ const weightedEfficiency = groupedMetrics.reduce(
1870
+ (sum, m) => sum + m.averageEfficiency * m.totalAppointments,
1871
+ 0,
1872
+ );
1873
+
1874
+ const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
1875
+ const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
1876
+ const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
1877
+
1878
+ const trend: DurationTrend = {
1879
+ period: periodInfo.period,
1880
+ startDate: periodInfo.startDate,
1881
+ endDate: periodInfo.endDate,
1882
+ averageBookedDuration,
1883
+ averageActualDuration,
1884
+ averageEfficiency,
1885
+ appointmentCount: totalAppointments,
1886
+ };
1887
+
1888
+ if (previousEfficiency > 0) {
1889
+ const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
1890
+ trend.previousPeriod = {
1891
+ averageBookedDuration: previousBookedDuration,
1892
+ averageActualDuration: previousActualDuration,
1893
+ averageEfficiency: previousEfficiency,
1894
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
1895
+ direction: efficiencyChange.direction,
1896
+ };
1897
+ }
1898
+
1899
+ trends.push(trend);
1900
+ previousEfficiency = averageEfficiency;
1901
+ previousBookedDuration = averageBookedDuration;
1902
+ previousActualDuration = averageActualDuration;
1903
+ } else {
1904
+ // Overall trends
1905
+ const timeMetrics = calculateAverageTimeMetrics(completed);
1906
+
1907
+ const trend: DurationTrend = {
1908
+ period: periodInfo.period,
1909
+ startDate: periodInfo.startDate,
1910
+ endDate: periodInfo.endDate,
1911
+ averageBookedDuration: timeMetrics.averageBookedDuration,
1912
+ averageActualDuration: timeMetrics.averageActualDuration,
1913
+ averageEfficiency: timeMetrics.averageEfficiency,
1914
+ appointmentCount: timeMetrics.appointmentsWithActualTime,
1915
+ };
1916
+
1917
+ if (previousEfficiency > 0) {
1918
+ const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
1919
+ trend.previousPeriod = {
1920
+ averageBookedDuration: previousBookedDuration,
1921
+ averageActualDuration: previousActualDuration,
1922
+ averageEfficiency: previousEfficiency,
1923
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
1924
+ direction: efficiencyChange.direction,
1925
+ };
1926
+ }
1927
+
1928
+ trends.push(trend);
1929
+ previousEfficiency = timeMetrics.averageEfficiency;
1930
+ previousBookedDuration = timeMetrics.averageBookedDuration;
1931
+ previousActualDuration = timeMetrics.averageActualDuration;
1932
+ }
1933
+ });
1934
+
1935
+ return trends;
1936
+ }
1937
+
1938
+ /**
1939
+ * Calculate appointment count trends over time
1940
+ *
1941
+ * @param dateRange - Date range for trend analysis
1942
+ * @param period - Period type (week, month, quarter, year)
1943
+ * @param filters - Optional filters
1944
+ * @param groupBy - Optional entity type to group trends by
1945
+ * @returns Array of appointment trends with percentage changes
1946
+ */
1947
+ async getAppointmentTrends(
1948
+ dateRange: AnalyticsDateRange,
1949
+ period: TrendPeriod,
1950
+ filters?: AnalyticsFilters,
1951
+ groupBy?: EntityType,
1952
+ ): Promise<AppointmentTrend[]> {
1953
+ const appointments = await this.fetchAppointments(filters);
1954
+ const filtered = filterByDateRange(appointments, dateRange);
1955
+
1956
+ if (filtered.length === 0) {
1957
+ return [];
1958
+ }
1959
+
1960
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1961
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1962
+ const trends: AppointmentTrend[] = [];
1963
+
1964
+ let previousTotal = 0;
1965
+ let previousCompleted = 0;
1966
+
1967
+ periods.forEach(periodInfo => {
1968
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1969
+ const completed = getCompletedAppointments(periodAppointments);
1970
+ const canceled = getCanceledAppointments(periodAppointments);
1971
+ const noShow = getNoShowAppointments(periodAppointments);
1972
+ const pending = periodAppointments.filter(a => a.status === AppointmentStatus.PENDING);
1973
+ const confirmed = periodAppointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
1974
+
1975
+ const trend: AppointmentTrend = {
1976
+ period: periodInfo.period,
1977
+ startDate: periodInfo.startDate,
1978
+ endDate: periodInfo.endDate,
1979
+ totalAppointments: periodAppointments.length,
1980
+ completedAppointments: completed.length,
1981
+ canceledAppointments: canceled.length,
1982
+ noShowAppointments: noShow.length,
1983
+ pendingAppointments: pending.length,
1984
+ confirmedAppointments: confirmed.length,
1985
+ };
1986
+
1987
+ if (previousTotal > 0) {
1988
+ const totalChange = getTrendChange(periodAppointments.length, previousTotal);
1989
+ trend.previousPeriod = {
1990
+ totalAppointments: previousTotal,
1991
+ completedAppointments: previousCompleted,
1992
+ percentageChange: totalChange.percentageChange,
1993
+ direction: totalChange.direction,
1994
+ };
1995
+ }
1996
+
1997
+ trends.push(trend);
1998
+ previousTotal = periodAppointments.length;
1999
+ previousCompleted = completed.length;
2000
+ });
2001
+
2002
+ return trends;
2003
+ }
2004
+
2005
+ /**
2006
+ * Calculate cancellation and no-show rate trends over time
2007
+ *
2008
+ * @param dateRange - Date range for trend analysis
2009
+ * @param period - Period type (week, month, quarter, year)
2010
+ * @param filters - Optional filters
2011
+ * @param groupBy - Optional entity type to group trends by
2012
+ * @returns Array of cancellation rate trends with percentage changes
2013
+ */
2014
+ async getCancellationRateTrends(
2015
+ dateRange: AnalyticsDateRange,
2016
+ period: TrendPeriod,
2017
+ filters?: AnalyticsFilters,
2018
+ groupBy?: EntityType,
2019
+ ): Promise<CancellationRateTrend[]> {
2020
+ const appointments = await this.fetchAppointments(filters);
2021
+ const filtered = filterByDateRange(appointments, dateRange);
2022
+
2023
+ if (filtered.length === 0) {
2024
+ return [];
2025
+ }
2026
+
2027
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
2028
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
2029
+ const trends: CancellationRateTrend[] = [];
2030
+
2031
+ let previousCancellationRate = 0;
2032
+ let previousNoShowRate = 0;
2033
+
2034
+ periods.forEach(periodInfo => {
2035
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
2036
+ const canceled = getCanceledAppointments(periodAppointments);
2037
+ const noShow = getNoShowAppointments(periodAppointments);
2038
+
2039
+ const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
2040
+ const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
2041
+
2042
+ const trend: CancellationRateTrend = {
2043
+ period: periodInfo.period,
2044
+ startDate: periodInfo.startDate,
2045
+ endDate: periodInfo.endDate,
2046
+ cancellationRate,
2047
+ noShowRate,
2048
+ totalAppointments: periodAppointments.length,
2049
+ canceledAppointments: canceled.length,
2050
+ noShowAppointments: noShow.length,
2051
+ };
2052
+
2053
+ if (previousCancellationRate > 0 || previousNoShowRate > 0) {
2054
+ const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
2055
+ const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
2056
+
2057
+ trend.previousPeriod = {
2058
+ cancellationRate: previousCancellationRate,
2059
+ noShowRate: previousNoShowRate,
2060
+ cancellationRateChange: cancellationChange.percentageChange,
2061
+ noShowRateChange: noShowChange.percentageChange,
2062
+ direction: cancellationChange.direction, // Use cancellation direction as primary
2063
+ };
2064
+ }
2065
+
2066
+ trends.push(trend);
2067
+ previousCancellationRate = cancellationRate;
2068
+ previousNoShowRate = noShowRate;
2069
+ });
2070
+
2071
+ return trends;
2072
+ }
2073
+
2074
+ // ==========================================
2075
+ // Review Analytics Methods
2076
+ // ==========================================
2077
+
2078
+ /**
2079
+ * Get review metrics for a specific entity (practitioner, procedure, etc.)
2080
+ */
2081
+ async getReviewMetricsByEntity(
2082
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
2083
+ entityId: string,
2084
+ dateRange?: AnalyticsDateRange,
2085
+ filters?: AnalyticsFilters
2086
+ ): Promise<ReviewAnalyticsMetrics | null> {
2087
+ return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
2088
+ }
2089
+
2090
+ /**
2091
+ * Get review metrics for multiple entities (grouped)
2092
+ */
2093
+ async getReviewMetricsByEntities(
2094
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
2095
+ dateRange?: AnalyticsDateRange,
2096
+ filters?: AnalyticsFilters
2097
+ ): Promise<ReviewAnalyticsMetrics[]> {
2098
+ return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
2099
+ }
2100
+
2101
+ /**
2102
+ * Get overall review averages for comparison
2103
+ */
2104
+ async getOverallReviewAverages(
2105
+ dateRange?: AnalyticsDateRange,
2106
+ filters?: AnalyticsFilters
2107
+ ): Promise<OverallReviewAverages> {
2108
+ return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
2109
+ }
2110
+
2111
+ /**
2112
+ * Get review details for a specific entity
2113
+ */
2114
+ async getReviewDetails(
2115
+ entityType: 'practitioner' | 'procedure',
2116
+ entityId: string,
2117
+ dateRange?: AnalyticsDateRange,
2118
+ filters?: AnalyticsFilters
2119
+ ): Promise<ReviewDetail[]> {
2120
+ return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
2121
+ }
2122
+
2123
+ /**
2124
+ * Calculate review trends over time
2125
+ * Groups reviews by period and calculates rating and recommendation metrics
2126
+ *
2127
+ * @param dateRange - Date range for trend analysis
2128
+ * @param period - Period type (week, month, quarter, year)
2129
+ * @param filters - Optional filters for clinic, practitioner, procedure
2130
+ * @param entityType - Optional entity type to group trends by
2131
+ * @returns Array of review trends with percentage changes
2132
+ */
2133
+ async getReviewTrends(
2134
+ dateRange: AnalyticsDateRange,
2135
+ period: TrendPeriod,
2136
+ filters?: AnalyticsFilters,
2137
+ entityType?: 'practitioner' | 'procedure' | 'technology'
2138
+ ): Promise<ReviewTrend[]> {
2139
+ return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
2140
+ }
2141
+ }
2142
+