@blackcode_sa/metaestetics-api 1.15.16 → 1.15.17-staging.1

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