@blackcode_sa/metaestetics-api 1.13.4 → 1.13.5

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 (293) hide show
  1. package/dist/admin/index.d.mts +15 -28
  2. package/dist/admin/index.d.ts +15 -28
  3. package/dist/index.d.mts +16 -29
  4. package/dist/index.d.ts +16 -29
  5. package/dist/index.js +1 -0
  6. package/dist/index.mjs +1 -0
  7. package/package.json +121 -119
  8. package/src/__mocks__/firstore.ts +10 -10
  9. package/src/admin/aggregation/README.md +79 -79
  10. package/src/admin/aggregation/appointment/README.md +128 -128
  11. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  12. package/src/admin/aggregation/appointment/index.ts +1 -1
  13. package/src/admin/aggregation/clinic/README.md +52 -52
  14. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
  15. package/src/admin/aggregation/clinic/index.ts +1 -1
  16. package/src/admin/aggregation/forms/README.md +13 -13
  17. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  18. package/src/admin/aggregation/forms/index.ts +1 -1
  19. package/src/admin/aggregation/index.ts +8 -8
  20. package/src/admin/aggregation/patient/README.md +27 -27
  21. package/src/admin/aggregation/patient/index.ts +1 -1
  22. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  23. package/src/admin/aggregation/practitioner/README.md +42 -42
  24. package/src/admin/aggregation/practitioner/index.ts +1 -1
  25. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  26. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  27. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  28. package/src/admin/aggregation/procedure/README.md +43 -43
  29. package/src/admin/aggregation/procedure/index.ts +1 -1
  30. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  31. package/src/admin/aggregation/reviews/index.ts +1 -1
  32. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  33. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  34. package/src/admin/analytics/index.ts +2 -2
  35. package/src/admin/booking/README.md +125 -125
  36. package/src/admin/booking/booking.admin.ts +1037 -1037
  37. package/src/admin/booking/booking.calculator.ts +712 -712
  38. package/src/admin/booking/booking.types.ts +59 -59
  39. package/src/admin/booking/index.ts +3 -3
  40. package/src/admin/booking/timezones-problem.md +185 -185
  41. package/src/admin/calendar/README.md +7 -7
  42. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  43. package/src/admin/calendar/index.ts +1 -1
  44. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  45. package/src/admin/documentation-templates/index.ts +1 -1
  46. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  47. package/src/admin/free-consultation/index.ts +1 -1
  48. package/src/admin/index.ts +81 -81
  49. package/src/admin/logger/index.ts +78 -78
  50. package/src/admin/mailing/README.md +95 -95
  51. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  52. package/src/admin/mailing/appointment/index.ts +1 -1
  53. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  54. package/src/admin/mailing/base.mailing.service.ts +208 -208
  55. package/src/admin/mailing/index.ts +3 -3
  56. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  57. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  58. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  59. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  60. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  61. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  62. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  63. package/src/admin/notifications/index.ts +1 -1
  64. package/src/admin/notifications/notifications.admin.ts +710 -710
  65. package/src/admin/requirements/README.md +128 -128
  66. package/src/admin/requirements/index.ts +1 -1
  67. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  68. package/src/admin/users/index.ts +1 -1
  69. package/src/admin/users/user-profile.admin.ts +405 -405
  70. package/src/backoffice/constants/certification.constants.ts +13 -13
  71. package/src/backoffice/constants/index.ts +1 -1
  72. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  73. package/src/backoffice/errors/index.ts +1 -1
  74. package/src/backoffice/expo-safe/README.md +26 -26
  75. package/src/backoffice/expo-safe/index.ts +41 -41
  76. package/src/backoffice/index.ts +5 -5
  77. package/src/backoffice/services/FIXES_README.md +102 -102
  78. package/src/backoffice/services/README.md +57 -57
  79. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  80. package/src/backoffice/services/analytics.service.summary.md +143 -143
  81. package/src/backoffice/services/brand.service.ts +256 -256
  82. package/src/backoffice/services/category.service.ts +384 -384
  83. package/src/backoffice/services/constants.service.ts +385 -385
  84. package/src/backoffice/services/documentation-template.service.ts +202 -202
  85. package/src/backoffice/services/index.ts +10 -10
  86. package/src/backoffice/services/migrate-products.ts +116 -116
  87. package/src/backoffice/services/product.service.ts +553 -553
  88. package/src/backoffice/services/requirement.service.ts +235 -235
  89. package/src/backoffice/services/subcategory.service.ts +461 -461
  90. package/src/backoffice/services/technology.service.ts +1151 -1151
  91. package/src/backoffice/types/README.md +12 -12
  92. package/src/backoffice/types/admin-constants.types.ts +69 -69
  93. package/src/backoffice/types/brand.types.ts +29 -29
  94. package/src/backoffice/types/category.types.ts +67 -67
  95. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  96. package/src/backoffice/types/index.ts +10 -10
  97. package/src/backoffice/types/procedure-product.types.ts +38 -38
  98. package/src/backoffice/types/product.types.ts +240 -240
  99. package/src/backoffice/types/requirement.types.ts +63 -63
  100. package/src/backoffice/types/static/README.md +18 -18
  101. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  102. package/src/backoffice/types/static/certification.types.ts +37 -37
  103. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  104. package/src/backoffice/types/static/index.ts +6 -6
  105. package/src/backoffice/types/static/pricing.types.ts +16 -16
  106. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  107. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  108. package/src/backoffice/types/subcategory.types.ts +34 -34
  109. package/src/backoffice/types/technology.types.ts +168 -168
  110. package/src/backoffice/validations/index.ts +1 -1
  111. package/src/backoffice/validations/schemas.ts +164 -164
  112. package/src/config/__mocks__/firebase.ts +99 -99
  113. package/src/config/firebase.ts +78 -78
  114. package/src/config/index.ts +9 -9
  115. package/src/errors/auth.error.ts +6 -6
  116. package/src/errors/auth.errors.ts +200 -200
  117. package/src/errors/clinic.errors.ts +32 -32
  118. package/src/errors/firebase.errors.ts +47 -47
  119. package/src/errors/user.errors.ts +99 -99
  120. package/src/index.backup.ts +407 -407
  121. package/src/index.ts +6 -6
  122. package/src/locales/en.ts +31 -31
  123. package/src/recommender/admin/index.ts +1 -1
  124. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  125. package/src/recommender/front/index.ts +1 -1
  126. package/src/recommender/front/services/onboarding.service.ts +5 -5
  127. package/src/recommender/front/services/recommender.service.ts +3 -3
  128. package/src/recommender/index.ts +1 -1
  129. package/src/services/PATIENTAUTH.MD +197 -197
  130. package/src/services/README.md +106 -106
  131. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  132. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  133. package/src/services/__tests__/auth.service.test.ts +346 -346
  134. package/src/services/__tests__/base.service.test.ts +77 -77
  135. package/src/services/__tests__/user.service.test.ts +528 -528
  136. package/src/services/analytics/ARCHITECTURE.md +199 -199
  137. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  138. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  139. package/src/services/analytics/QUICK_START.md +393 -393
  140. package/src/services/analytics/README.md +304 -304
  141. package/src/services/analytics/SUMMARY.md +141 -141
  142. package/src/services/analytics/TRENDS.md +380 -380
  143. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  144. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  145. package/src/services/analytics/analytics.service.ts +2142 -2142
  146. package/src/services/analytics/index.ts +4 -4
  147. package/src/services/analytics/review-analytics.service.ts +941 -941
  148. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  149. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  150. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  151. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  152. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  153. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  154. package/src/services/appointment/README.md +17 -17
  155. package/src/services/appointment/appointment.service.ts +2558 -2558
  156. package/src/services/appointment/index.ts +1 -1
  157. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  158. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  159. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  160. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  161. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  162. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  163. package/src/services/auth/auth.service.ts +989 -989
  164. package/src/services/auth/auth.v2.service.ts +961 -961
  165. package/src/services/auth/index.ts +7 -7
  166. package/src/services/auth/utils/error.utils.ts +90 -90
  167. package/src/services/auth/utils/firebase.utils.ts +49 -49
  168. package/src/services/auth/utils/index.ts +21 -21
  169. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  170. package/src/services/base.service.ts +41 -41
  171. package/src/services/calendar/calendar.service.ts +1077 -1077
  172. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  173. package/src/services/calendar/calendar.v3.service.ts +313 -313
  174. package/src/services/calendar/externalCalendar.service.ts +178 -178
  175. package/src/services/calendar/index.ts +5 -5
  176. package/src/services/calendar/synced-calendars.service.ts +743 -743
  177. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  178. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  179. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  180. package/src/services/calendar/utils/docs.utils.ts +157 -157
  181. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  182. package/src/services/calendar/utils/index.ts +8 -8
  183. package/src/services/calendar/utils/patient.utils.ts +198 -198
  184. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  185. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  186. package/src/services/clinic/README.md +204 -204
  187. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  188. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  189. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  190. package/src/services/clinic/billing-transactions.service.ts +217 -217
  191. package/src/services/clinic/clinic-admin.service.ts +202 -202
  192. package/src/services/clinic/clinic-group.service.ts +310 -310
  193. package/src/services/clinic/clinic.service.ts +708 -708
  194. package/src/services/clinic/index.ts +5 -5
  195. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  196. package/src/services/clinic/utils/admin.utils.ts +551 -551
  197. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  198. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  199. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  200. package/src/services/clinic/utils/filter.utils.ts +446 -446
  201. package/src/services/clinic/utils/index.ts +11 -11
  202. package/src/services/clinic/utils/photos.utils.ts +188 -188
  203. package/src/services/clinic/utils/search.utils.ts +84 -84
  204. package/src/services/clinic/utils/tag.utils.ts +124 -124
  205. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  206. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  207. package/src/services/documentation-templates/index.ts +2 -2
  208. package/src/services/index.ts +14 -14
  209. package/src/services/media/index.ts +1 -1
  210. package/src/services/media/media.service.ts +418 -418
  211. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  212. package/src/services/notifications/index.ts +1 -1
  213. package/src/services/notifications/notification.service.ts +215 -215
  214. package/src/services/patient/README.md +48 -48
  215. package/src/services/patient/To-Do.md +43 -43
  216. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  217. package/src/services/patient/index.ts +2 -2
  218. package/src/services/patient/patient.service.ts +883 -883
  219. package/src/services/patient/patientRequirements.service.ts +285 -285
  220. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  221. package/src/services/patient/utils/clinic.utils.ts +80 -80
  222. package/src/services/patient/utils/docs.utils.ts +142 -142
  223. package/src/services/patient/utils/index.ts +9 -9
  224. package/src/services/patient/utils/location.utils.ts +126 -126
  225. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  226. package/src/services/patient/utils/medical.utils.ts +458 -458
  227. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  228. package/src/services/patient/utils/profile.utils.ts +510 -510
  229. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  230. package/src/services/patient/utils/token.utils.ts +211 -211
  231. package/src/services/practitioner/README.md +145 -145
  232. package/src/services/practitioner/index.ts +1 -1
  233. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  234. package/src/services/procedure/README.md +163 -163
  235. package/src/services/procedure/index.ts +1 -1
  236. package/src/services/procedure/procedure.service.ts +2200 -2200
  237. package/src/services/reviews/index.ts +1 -1
  238. package/src/services/reviews/reviews.service.ts +734 -734
  239. package/src/services/user/index.ts +1 -1
  240. package/src/services/user/user.service.ts +489 -489
  241. package/src/services/user/user.v2.service.ts +466 -466
  242. package/src/types/analytics/analytics.types.ts +597 -597
  243. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  244. package/src/types/analytics/index.ts +4 -4
  245. package/src/types/analytics/stored-analytics.types.ts +137 -137
  246. package/src/types/appointment/index.ts +480 -480
  247. package/src/types/calendar/index.ts +258 -258
  248. package/src/types/calendar/synced-calendar.types.ts +66 -66
  249. package/src/types/clinic/index.ts +498 -489
  250. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  251. package/src/types/clinic/preferences.types.ts +159 -159
  252. package/src/types/clinic/to-do +3 -3
  253. package/src/types/documentation-templates/index.ts +308 -308
  254. package/src/types/index.ts +47 -47
  255. package/src/types/notifications/README.md +77 -77
  256. package/src/types/notifications/index.ts +286 -286
  257. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  258. package/src/types/patient/allergies.ts +58 -58
  259. package/src/types/patient/index.ts +275 -275
  260. package/src/types/patient/medical-info.types.ts +152 -152
  261. package/src/types/patient/patient-requirements.ts +92 -92
  262. package/src/types/patient/token.types.ts +61 -61
  263. package/src/types/practitioner/index.ts +206 -206
  264. package/src/types/procedure/index.ts +181 -181
  265. package/src/types/profile/index.ts +39 -39
  266. package/src/types/reviews/index.ts +132 -132
  267. package/src/types/tz-lookup.d.ts +4 -4
  268. package/src/types/user/index.ts +38 -38
  269. package/src/utils/TIMESTAMPS.md +176 -176
  270. package/src/utils/TimestampUtils.ts +241 -241
  271. package/src/utils/index.ts +1 -1
  272. package/src/validations/appointment.schema.ts +574 -574
  273. package/src/validations/calendar.schema.ts +225 -225
  274. package/src/validations/clinic.schema.ts +494 -493
  275. package/src/validations/common.schema.ts +25 -25
  276. package/src/validations/documentation-templates/index.ts +1 -1
  277. package/src/validations/documentation-templates/template.schema.ts +220 -220
  278. package/src/validations/documentation-templates.schema.ts +10 -10
  279. package/src/validations/index.ts +20 -20
  280. package/src/validations/media.schema.ts +10 -10
  281. package/src/validations/notification.schema.ts +90 -90
  282. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  283. package/src/validations/patient/medical-info.schema.ts +125 -125
  284. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  285. package/src/validations/patient/token.schema.ts +29 -29
  286. package/src/validations/patient.schema.ts +217 -217
  287. package/src/validations/practitioner.schema.ts +222 -222
  288. package/src/validations/procedure-product.schema.ts +41 -41
  289. package/src/validations/procedure.schema.ts +124 -124
  290. package/src/validations/profile-info.schema.ts +41 -41
  291. package/src/validations/reviews.schema.ts +195 -195
  292. package/src/validations/schemas.ts +104 -104
  293. package/src/validations/shared.schema.ts +78 -78
@@ -1,2142 +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
-
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
+