@blackcode_sa/metaestetics-api 1.13.5 → 1.13.6

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