@blackcode_sa/metaestetics-api 1.13.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,8 @@ import {
13
13
  RevenueMetrics,
14
14
  RevenueTrend,
15
15
  DurationTrend,
16
+ AppointmentTrend,
17
+ CancellationRateTrend,
16
18
  ProductUsageMetrics,
17
19
  ProductRevenueMetrics,
18
20
  ProductUsageByProcedure,
@@ -30,6 +32,7 @@ import {
30
32
  AnalyticsDateRange,
31
33
  AnalyticsFilters,
32
34
  GroupingPeriod,
35
+ TrendPeriod,
33
36
  EntityType,
34
37
  } from '../../types/analytics';
35
38
 
@@ -66,6 +69,12 @@ import {
66
69
  calculateGroupedTimeEfficiencyMetrics,
67
70
  calculateGroupedPatientBehaviorMetrics,
68
71
  } from './utils/grouping.utils';
72
+ import {
73
+ groupAppointmentsByPeriod,
74
+ generatePeriods,
75
+ getTrendChange,
76
+ type TrendPeriod as TrendPeriodType,
77
+ } from './utils/trend-calculation.utils';
69
78
  import { ReadStoredAnalyticsOptions, AnalyticsPeriod } from '../../types/analytics';
70
79
  import {
71
80
  GroupedRevenueMetrics,
@@ -73,6 +82,8 @@ import {
73
82
  GroupedTimeEfficiencyMetrics,
74
83
  GroupedPatientBehaviorMetrics,
75
84
  } from '../../types/analytics/grouped-analytics.types';
85
+ import { ReviewAnalyticsService, ReviewAnalyticsMetrics, OverallReviewAverages, ReviewDetail } from './review-analytics.service';
86
+ import { ReviewTrend } from '../../types/analytics';
76
87
 
77
88
  /**
78
89
  * AnalyticsService provides comprehensive financial and analytical intelligence
@@ -81,6 +92,7 @@ import {
81
92
  */
82
93
  export class AnalyticsService extends BaseService {
83
94
  private appointmentService: AppointmentService;
95
+ private reviewAnalyticsService: ReviewAnalyticsService;
84
96
 
85
97
  /**
86
98
  * Creates a new AnalyticsService instance.
@@ -93,6 +105,7 @@ export class AnalyticsService extends BaseService {
93
105
  constructor(db: Firestore, auth: Auth, app: FirebaseApp, appointmentService: AppointmentService) {
94
106
  super(db, auth, app);
95
107
  this.appointmentService = appointmentService;
108
+ this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
96
109
  }
97
110
 
98
111
  /**
@@ -757,14 +770,20 @@ export class AnalyticsService extends BaseService {
757
770
  canceled: Appointment[],
758
771
  allAppointments: Appointment[],
759
772
  ): CancellationMetrics[] {
760
- const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[] }>();
773
+ const procedureMap = new Map<string, { name: string; canceled: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
761
774
 
762
775
  allAppointments.forEach(appointment => {
763
776
  const procedureId = appointment.procedureId;
764
777
  const procedureName = appointment.procedureInfo?.name || 'Unknown';
765
778
 
766
779
  if (!procedureMap.has(procedureId)) {
767
- procedureMap.set(procedureId, { name: procedureName, canceled: [], all: [] });
780
+ procedureMap.set(procedureId, {
781
+ name: procedureName,
782
+ canceled: [],
783
+ all: [],
784
+ practitionerId: appointment.practitionerId,
785
+ practitionerName: appointment.practitionerInfo?.name,
786
+ });
768
787
  }
769
788
  procedureMap.get(procedureId)!.all.push(appointment);
770
789
  });
@@ -776,15 +795,20 @@ export class AnalyticsService extends BaseService {
776
795
  }
777
796
  });
778
797
 
779
- return Array.from(procedureMap.entries()).map(([procedureId, data]) =>
780
- this.calculateCancellationMetrics(
798
+ return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
799
+ const metrics = this.calculateCancellationMetrics(
781
800
  procedureId,
782
801
  data.name,
783
802
  'procedure',
784
803
  data.canceled,
785
804
  data.all,
786
- ),
787
- );
805
+ );
806
+ return {
807
+ ...metrics,
808
+ ...(data.practitionerId && { practitionerId: data.practitionerId }),
809
+ ...(data.practitionerName && { practitionerName: data.practitionerName }),
810
+ };
811
+ });
788
812
  }
789
813
 
790
814
  /**
@@ -1051,14 +1075,20 @@ export class AnalyticsService extends BaseService {
1051
1075
  noShow: Appointment[],
1052
1076
  allAppointments: Appointment[],
1053
1077
  ): NoShowMetrics[] {
1054
- const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[] }>();
1078
+ const procedureMap = new Map<string, { name: string; noShow: Appointment[]; all: Appointment[]; practitionerId?: string; practitionerName?: string }>();
1055
1079
 
1056
1080
  allAppointments.forEach(appointment => {
1057
1081
  const procedureId = appointment.procedureId;
1058
1082
  const procedureName = appointment.procedureInfo?.name || 'Unknown';
1059
1083
 
1060
1084
  if (!procedureMap.has(procedureId)) {
1061
- procedureMap.set(procedureId, { name: procedureName, noShow: [], all: [] });
1085
+ procedureMap.set(procedureId, {
1086
+ name: procedureName,
1087
+ noShow: [],
1088
+ all: [],
1089
+ practitionerId: appointment.practitionerId,
1090
+ practitionerName: appointment.practitionerInfo?.name,
1091
+ });
1062
1092
  }
1063
1093
  procedureMap.get(procedureId)!.all.push(appointment);
1064
1094
  });
@@ -1077,6 +1107,8 @@ export class AnalyticsService extends BaseService {
1077
1107
  totalAppointments: data.all.length,
1078
1108
  noShowAppointments: data.noShow.length,
1079
1109
  noShowRate: calculatePercentage(data.noShow.length, data.all.length),
1110
+ ...(data.practitionerId && { practitionerId: data.practitionerId }),
1111
+ ...(data.practitionerName && { practitionerName: data.practitionerName }),
1080
1112
  }));
1081
1113
  }
1082
1114
 
@@ -1147,6 +1179,10 @@ export class AnalyticsService extends BaseService {
1147
1179
  * Get revenue metrics
1148
1180
  * First checks for stored analytics, then calculates if not available or stale
1149
1181
  *
1182
+ * IMPORTANT: Financial calculations only consider COMPLETED appointments.
1183
+ * Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
1184
+ * Only procedures that have been completed generate revenue.
1185
+ *
1150
1186
  * @param filters - Optional filters
1151
1187
  * @param dateRange - Optional date range filter
1152
1188
  * @param options - Options for reading stored analytics
@@ -1179,13 +1215,13 @@ export class AnalyticsService extends BaseService {
1179
1215
 
1180
1216
  const { totalRevenue, currency } = calculateTotalRevenue(completed);
1181
1217
 
1182
- // Calculate revenue by status
1218
+ // Calculate revenue by status - ONLY for COMPLETED appointments
1219
+ // Financial calculations should only consider completed procedures
1183
1220
  const revenueByStatus: Partial<Record<AppointmentStatus, number>> = {};
1184
- Object.values(AppointmentStatus).forEach(status => {
1185
- const statusAppointments = appointments.filter(a => a.status === status);
1186
- const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
1187
- revenueByStatus[status] = statusRevenue;
1188
- });
1221
+ // Only calculate revenue for COMPLETED status (other statuses have no revenue)
1222
+ const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
1223
+ revenueByStatus[AppointmentStatus.COMPLETED] = completedRevenue;
1224
+ // All other statuses have 0 revenue (confirmed, pending, canceled, etc. don't generate revenue)
1189
1225
 
1190
1226
  // Calculate revenue by payment status
1191
1227
  const revenueByPaymentStatus: Partial<Record<PaymentStatus, number>> = {};
@@ -1253,15 +1289,21 @@ export class AnalyticsService extends BaseService {
1253
1289
  /**
1254
1290
  * Get product usage metrics
1255
1291
  *
1292
+ * IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
1293
+ * Products are only considered "used" when the procedure has been completed.
1294
+ * Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
1295
+ *
1256
1296
  * @param productId - Optional product ID (if not provided, returns all products)
1257
1297
  * @param dateRange - Optional date range filter
1298
+ * @param filters - Optional filters (e.g., clinicBranchId)
1258
1299
  * @returns Product usage metrics
1259
1300
  */
1260
1301
  async getProductUsageMetrics(
1261
1302
  productId?: string,
1262
1303
  dateRange?: AnalyticsDateRange,
1304
+ filters?: AnalyticsFilters,
1263
1305
  ): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
1264
- const appointments = await this.fetchAppointments(undefined, dateRange);
1306
+ const appointments = await this.fetchAppointments(filters, dateRange);
1265
1307
  const completed = getCompletedAppointments(appointments);
1266
1308
 
1267
1309
  const productMap = new Map<
@@ -1273,12 +1315,16 @@ export class AnalyticsService extends BaseService {
1273
1315
  quantity: number;
1274
1316
  revenue: number;
1275
1317
  usageCount: number;
1318
+ appointmentIds: Set<string>; // Track which appointments used this product
1276
1319
  procedureMap: Map<string, { name: string; count: number; quantity: number }>;
1277
1320
  }
1278
1321
  >();
1279
1322
 
1280
1323
  completed.forEach(appointment => {
1281
1324
  const products = extractProductUsage(appointment);
1325
+ // Track which products were used in this appointment to count appointments, not product entries
1326
+ const productsInThisAppointment = new Set<string>();
1327
+
1282
1328
  products.forEach(product => {
1283
1329
  if (productId && product.productId !== productId) {
1284
1330
  return;
@@ -1292,6 +1338,7 @@ export class AnalyticsService extends BaseService {
1292
1338
  quantity: 0,
1293
1339
  revenue: 0,
1294
1340
  usageCount: 0,
1341
+ appointmentIds: new Set(),
1295
1342
  procedureMap: new Map(),
1296
1343
  });
1297
1344
  }
@@ -1299,21 +1346,36 @@ export class AnalyticsService extends BaseService {
1299
1346
  const productData = productMap.get(product.productId)!;
1300
1347
  productData.quantity += product.quantity;
1301
1348
  productData.revenue += product.subtotal;
1302
- productData.usageCount++;
1303
-
1304
- // Track usage by procedure
1305
- const procId = appointment.procedureId;
1306
- const procName = appointment.procedureInfo?.name || 'Unknown';
1307
- if (productData.procedureMap.has(procId)) {
1308
- const procData = productData.procedureMap.get(procId)!;
1309
- procData.count++;
1310
- procData.quantity += product.quantity;
1311
- } else {
1312
- productData.procedureMap.set(procId, {
1313
- name: procName,
1314
- count: 1,
1315
- quantity: product.quantity,
1316
- });
1349
+
1350
+ // Track that this product was used in this appointment
1351
+ productsInThisAppointment.add(product.productId);
1352
+ });
1353
+
1354
+ // After processing all products from this appointment, increment usageCount once per product
1355
+ productsInThisAppointment.forEach(productId => {
1356
+ const productData = productMap.get(productId)!;
1357
+ if (!productData.appointmentIds.has(appointment.id)) {
1358
+ productData.appointmentIds.add(appointment.id);
1359
+ productData.usageCount++;
1360
+
1361
+ // Track usage by procedure (only once per appointment)
1362
+ const procId = appointment.procedureId;
1363
+ const procName = appointment.procedureInfo?.name || 'Unknown';
1364
+ if (productData.procedureMap.has(procId)) {
1365
+ const procData = productData.procedureMap.get(procId)!;
1366
+ procData.count++;
1367
+ // Sum all quantities for this product in this appointment
1368
+ const appointmentProducts = products.filter(p => p.productId === productId);
1369
+ procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
1370
+ } else {
1371
+ const appointmentProducts = products.filter(p => p.productId === productId);
1372
+ const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
1373
+ productData.procedureMap.set(procId, {
1374
+ name: procName,
1375
+ count: 1,
1376
+ quantity: totalQuantity,
1377
+ });
1378
+ }
1317
1379
  }
1318
1380
  });
1319
1381
  });
@@ -1628,5 +1690,453 @@ export class AnalyticsService extends BaseService {
1628
1690
  recentActivity,
1629
1691
  };
1630
1692
  }
1693
+
1694
+ /**
1695
+ * Calculate revenue trends over time
1696
+ * Groups appointments by week/month/quarter/year and calculates revenue metrics
1697
+ *
1698
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
1699
+ * @param period - Period type (week, month, quarter, year)
1700
+ * @param filters - Optional filters for clinic, practitioner, procedure, patient
1701
+ * @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
1702
+ * @returns Array of revenue trends with percentage changes
1703
+ */
1704
+ async getRevenueTrends(
1705
+ dateRange: AnalyticsDateRange,
1706
+ period: TrendPeriod,
1707
+ filters?: AnalyticsFilters,
1708
+ groupBy?: EntityType,
1709
+ ): Promise<RevenueTrend[]> {
1710
+ const appointments = await this.fetchAppointments(filters);
1711
+ const filtered = filterByDateRange(appointments, dateRange);
1712
+
1713
+ if (filtered.length === 0) {
1714
+ return [];
1715
+ }
1716
+
1717
+ // If grouping by entity, calculate trends per entity
1718
+ if (groupBy) {
1719
+ return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
1720
+ }
1721
+
1722
+ // Calculate overall trends
1723
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1724
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1725
+ const trends: RevenueTrend[] = [];
1726
+
1727
+ let previousRevenue = 0;
1728
+ let previousAppointmentCount = 0;
1729
+
1730
+ periods.forEach(periodInfo => {
1731
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1732
+ const completed = getCompletedAppointments(periodAppointments);
1733
+ const { totalRevenue, currency } = calculateTotalRevenue(completed);
1734
+
1735
+ const appointmentCount = completed.length;
1736
+ const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
1737
+
1738
+ const trend: RevenueTrend = {
1739
+ period: periodInfo.period,
1740
+ startDate: periodInfo.startDate,
1741
+ endDate: periodInfo.endDate,
1742
+ revenue: totalRevenue,
1743
+ appointmentCount,
1744
+ averageRevenue,
1745
+ currency,
1746
+ };
1747
+
1748
+ // Calculate percentage change from previous period
1749
+ if (previousRevenue > 0 || previousAppointmentCount > 0) {
1750
+ const revenueChange = getTrendChange(totalRevenue, previousRevenue);
1751
+ trend.previousPeriod = {
1752
+ revenue: previousRevenue,
1753
+ appointmentCount: previousAppointmentCount,
1754
+ percentageChange: revenueChange.percentageChange,
1755
+ direction: revenueChange.direction,
1756
+ };
1757
+ }
1758
+
1759
+ trends.push(trend);
1760
+ previousRevenue = totalRevenue;
1761
+ previousAppointmentCount = appointmentCount;
1762
+ });
1763
+
1764
+ return trends;
1765
+ }
1766
+
1767
+ /**
1768
+ * Calculate revenue trends grouped by entity
1769
+ */
1770
+ private async getGroupedRevenueTrends(
1771
+ appointments: Appointment[],
1772
+ dateRange: AnalyticsDateRange,
1773
+ period: TrendPeriod,
1774
+ groupBy: EntityType,
1775
+ ): Promise<RevenueTrend[]> {
1776
+ const periodMap = groupAppointmentsByPeriod(appointments, period as TrendPeriodType);
1777
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1778
+ const trends: RevenueTrend[] = [];
1779
+
1780
+ // Group appointments by entity for each period
1781
+ periods.forEach(periodInfo => {
1782
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1783
+ if (periodAppointments.length === 0) return;
1784
+
1785
+ const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
1786
+
1787
+ // Sum up all entities for this period
1788
+ const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
1789
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
1790
+ const currency = groupedMetrics[0]?.currency || 'CHF';
1791
+ const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
1792
+
1793
+ trends.push({
1794
+ period: periodInfo.period,
1795
+ startDate: periodInfo.startDate,
1796
+ endDate: periodInfo.endDate,
1797
+ revenue: totalRevenue,
1798
+ appointmentCount: totalAppointments,
1799
+ averageRevenue,
1800
+ currency,
1801
+ });
1802
+ });
1803
+
1804
+ // Calculate percentage changes
1805
+ for (let i = 1; i < trends.length; i++) {
1806
+ const current = trends[i];
1807
+ const previous = trends[i - 1];
1808
+ const revenueChange = getTrendChange(current.revenue, previous.revenue);
1809
+
1810
+ current.previousPeriod = {
1811
+ revenue: previous.revenue,
1812
+ appointmentCount: previous.appointmentCount,
1813
+ percentageChange: revenueChange.percentageChange,
1814
+ direction: revenueChange.direction,
1815
+ };
1816
+ }
1817
+
1818
+ return trends;
1819
+ }
1820
+
1821
+ /**
1822
+ * Calculate duration/efficiency trends over time
1823
+ *
1824
+ * @param dateRange - Date range for trend analysis
1825
+ * @param period - Period type (week, month, quarter, year)
1826
+ * @param filters - Optional filters
1827
+ * @param groupBy - Optional entity type to group trends by
1828
+ * @returns Array of duration trends with percentage changes
1829
+ */
1830
+ async getDurationTrends(
1831
+ dateRange: AnalyticsDateRange,
1832
+ period: TrendPeriod,
1833
+ filters?: AnalyticsFilters,
1834
+ groupBy?: EntityType,
1835
+ ): Promise<DurationTrend[]> {
1836
+ const appointments = await this.fetchAppointments(filters);
1837
+ const filtered = filterByDateRange(appointments, dateRange);
1838
+
1839
+ if (filtered.length === 0) {
1840
+ return [];
1841
+ }
1842
+
1843
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1844
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1845
+ const trends: DurationTrend[] = [];
1846
+
1847
+ let previousEfficiency = 0;
1848
+ let previousBookedDuration = 0;
1849
+ let previousActualDuration = 0;
1850
+
1851
+ periods.forEach(periodInfo => {
1852
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1853
+ const completed = getCompletedAppointments(periodAppointments);
1854
+
1855
+ if (groupBy) {
1856
+ // Group by entity and calculate average
1857
+ const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
1858
+ if (groupedMetrics.length === 0) return;
1859
+
1860
+ const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
1861
+ const weightedBooked = groupedMetrics.reduce(
1862
+ (sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
1863
+ 0,
1864
+ );
1865
+ const weightedActual = groupedMetrics.reduce(
1866
+ (sum, m) => sum + m.averageActualDuration * m.totalAppointments,
1867
+ 0,
1868
+ );
1869
+ const weightedEfficiency = groupedMetrics.reduce(
1870
+ (sum, m) => sum + m.averageEfficiency * m.totalAppointments,
1871
+ 0,
1872
+ );
1873
+
1874
+ const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
1875
+ const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
1876
+ const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
1877
+
1878
+ const trend: DurationTrend = {
1879
+ period: periodInfo.period,
1880
+ startDate: periodInfo.startDate,
1881
+ endDate: periodInfo.endDate,
1882
+ averageBookedDuration,
1883
+ averageActualDuration,
1884
+ averageEfficiency,
1885
+ appointmentCount: totalAppointments,
1886
+ };
1887
+
1888
+ if (previousEfficiency > 0) {
1889
+ const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
1890
+ trend.previousPeriod = {
1891
+ averageBookedDuration: previousBookedDuration,
1892
+ averageActualDuration: previousActualDuration,
1893
+ averageEfficiency: previousEfficiency,
1894
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
1895
+ direction: efficiencyChange.direction,
1896
+ };
1897
+ }
1898
+
1899
+ trends.push(trend);
1900
+ previousEfficiency = averageEfficiency;
1901
+ previousBookedDuration = averageBookedDuration;
1902
+ previousActualDuration = averageActualDuration;
1903
+ } else {
1904
+ // Overall trends
1905
+ const timeMetrics = calculateAverageTimeMetrics(completed);
1906
+
1907
+ const trend: DurationTrend = {
1908
+ period: periodInfo.period,
1909
+ startDate: periodInfo.startDate,
1910
+ endDate: periodInfo.endDate,
1911
+ averageBookedDuration: timeMetrics.averageBookedDuration,
1912
+ averageActualDuration: timeMetrics.averageActualDuration,
1913
+ averageEfficiency: timeMetrics.averageEfficiency,
1914
+ appointmentCount: timeMetrics.appointmentsWithActualTime,
1915
+ };
1916
+
1917
+ if (previousEfficiency > 0) {
1918
+ const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
1919
+ trend.previousPeriod = {
1920
+ averageBookedDuration: previousBookedDuration,
1921
+ averageActualDuration: previousActualDuration,
1922
+ averageEfficiency: previousEfficiency,
1923
+ efficiencyPercentageChange: efficiencyChange.percentageChange,
1924
+ direction: efficiencyChange.direction,
1925
+ };
1926
+ }
1927
+
1928
+ trends.push(trend);
1929
+ previousEfficiency = timeMetrics.averageEfficiency;
1930
+ previousBookedDuration = timeMetrics.averageBookedDuration;
1931
+ previousActualDuration = timeMetrics.averageActualDuration;
1932
+ }
1933
+ });
1934
+
1935
+ return trends;
1936
+ }
1937
+
1938
+ /**
1939
+ * Calculate appointment count trends over time
1940
+ *
1941
+ * @param dateRange - Date range for trend analysis
1942
+ * @param period - Period type (week, month, quarter, year)
1943
+ * @param filters - Optional filters
1944
+ * @param groupBy - Optional entity type to group trends by
1945
+ * @returns Array of appointment trends with percentage changes
1946
+ */
1947
+ async getAppointmentTrends(
1948
+ dateRange: AnalyticsDateRange,
1949
+ period: TrendPeriod,
1950
+ filters?: AnalyticsFilters,
1951
+ groupBy?: EntityType,
1952
+ ): Promise<AppointmentTrend[]> {
1953
+ const appointments = await this.fetchAppointments(filters);
1954
+ const filtered = filterByDateRange(appointments, dateRange);
1955
+
1956
+ if (filtered.length === 0) {
1957
+ return [];
1958
+ }
1959
+
1960
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
1961
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
1962
+ const trends: AppointmentTrend[] = [];
1963
+
1964
+ let previousTotal = 0;
1965
+ let previousCompleted = 0;
1966
+
1967
+ periods.forEach(periodInfo => {
1968
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
1969
+ const completed = getCompletedAppointments(periodAppointments);
1970
+ const canceled = getCanceledAppointments(periodAppointments);
1971
+ const noShow = getNoShowAppointments(periodAppointments);
1972
+ const pending = periodAppointments.filter(a => a.status === AppointmentStatus.PENDING);
1973
+ const confirmed = periodAppointments.filter(a => a.status === AppointmentStatus.CONFIRMED);
1974
+
1975
+ const trend: AppointmentTrend = {
1976
+ period: periodInfo.period,
1977
+ startDate: periodInfo.startDate,
1978
+ endDate: periodInfo.endDate,
1979
+ totalAppointments: periodAppointments.length,
1980
+ completedAppointments: completed.length,
1981
+ canceledAppointments: canceled.length,
1982
+ noShowAppointments: noShow.length,
1983
+ pendingAppointments: pending.length,
1984
+ confirmedAppointments: confirmed.length,
1985
+ };
1986
+
1987
+ if (previousTotal > 0) {
1988
+ const totalChange = getTrendChange(periodAppointments.length, previousTotal);
1989
+ trend.previousPeriod = {
1990
+ totalAppointments: previousTotal,
1991
+ completedAppointments: previousCompleted,
1992
+ percentageChange: totalChange.percentageChange,
1993
+ direction: totalChange.direction,
1994
+ };
1995
+ }
1996
+
1997
+ trends.push(trend);
1998
+ previousTotal = periodAppointments.length;
1999
+ previousCompleted = completed.length;
2000
+ });
2001
+
2002
+ return trends;
2003
+ }
2004
+
2005
+ /**
2006
+ * Calculate cancellation and no-show rate trends over time
2007
+ *
2008
+ * @param dateRange - Date range for trend analysis
2009
+ * @param period - Period type (week, month, quarter, year)
2010
+ * @param filters - Optional filters
2011
+ * @param groupBy - Optional entity type to group trends by
2012
+ * @returns Array of cancellation rate trends with percentage changes
2013
+ */
2014
+ async getCancellationRateTrends(
2015
+ dateRange: AnalyticsDateRange,
2016
+ period: TrendPeriod,
2017
+ filters?: AnalyticsFilters,
2018
+ groupBy?: EntityType,
2019
+ ): Promise<CancellationRateTrend[]> {
2020
+ const appointments = await this.fetchAppointments(filters);
2021
+ const filtered = filterByDateRange(appointments, dateRange);
2022
+
2023
+ if (filtered.length === 0) {
2024
+ return [];
2025
+ }
2026
+
2027
+ const periodMap = groupAppointmentsByPeriod(filtered, period as TrendPeriodType);
2028
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
2029
+ const trends: CancellationRateTrend[] = [];
2030
+
2031
+ let previousCancellationRate = 0;
2032
+ let previousNoShowRate = 0;
2033
+
2034
+ periods.forEach(periodInfo => {
2035
+ const periodAppointments = periodMap.get(periodInfo.period) || [];
2036
+ const canceled = getCanceledAppointments(periodAppointments);
2037
+ const noShow = getNoShowAppointments(periodAppointments);
2038
+
2039
+ const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
2040
+ const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
2041
+
2042
+ const trend: CancellationRateTrend = {
2043
+ period: periodInfo.period,
2044
+ startDate: periodInfo.startDate,
2045
+ endDate: periodInfo.endDate,
2046
+ cancellationRate,
2047
+ noShowRate,
2048
+ totalAppointments: periodAppointments.length,
2049
+ canceledAppointments: canceled.length,
2050
+ noShowAppointments: noShow.length,
2051
+ };
2052
+
2053
+ if (previousCancellationRate > 0 || previousNoShowRate > 0) {
2054
+ const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
2055
+ const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
2056
+
2057
+ trend.previousPeriod = {
2058
+ cancellationRate: previousCancellationRate,
2059
+ noShowRate: previousNoShowRate,
2060
+ cancellationRateChange: cancellationChange.percentageChange,
2061
+ noShowRateChange: noShowChange.percentageChange,
2062
+ direction: cancellationChange.direction, // Use cancellation direction as primary
2063
+ };
2064
+ }
2065
+
2066
+ trends.push(trend);
2067
+ previousCancellationRate = cancellationRate;
2068
+ previousNoShowRate = noShowRate;
2069
+ });
2070
+
2071
+ return trends;
2072
+ }
2073
+
2074
+ // ==========================================
2075
+ // Review Analytics Methods
2076
+ // ==========================================
2077
+
2078
+ /**
2079
+ * Get review metrics for a specific entity (practitioner, procedure, etc.)
2080
+ */
2081
+ async getReviewMetricsByEntity(
2082
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
2083
+ entityId: string,
2084
+ dateRange?: AnalyticsDateRange,
2085
+ filters?: AnalyticsFilters
2086
+ ): Promise<ReviewAnalyticsMetrics | null> {
2087
+ return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
2088
+ }
2089
+
2090
+ /**
2091
+ * Get review metrics for multiple entities (grouped)
2092
+ */
2093
+ async getReviewMetricsByEntities(
2094
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
2095
+ dateRange?: AnalyticsDateRange,
2096
+ filters?: AnalyticsFilters
2097
+ ): Promise<ReviewAnalyticsMetrics[]> {
2098
+ return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
2099
+ }
2100
+
2101
+ /**
2102
+ * Get overall review averages for comparison
2103
+ */
2104
+ async getOverallReviewAverages(
2105
+ dateRange?: AnalyticsDateRange,
2106
+ filters?: AnalyticsFilters
2107
+ ): Promise<OverallReviewAverages> {
2108
+ return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
2109
+ }
2110
+
2111
+ /**
2112
+ * Get review details for a specific entity
2113
+ */
2114
+ async getReviewDetails(
2115
+ entityType: 'practitioner' | 'procedure',
2116
+ entityId: string,
2117
+ dateRange?: AnalyticsDateRange,
2118
+ filters?: AnalyticsFilters
2119
+ ): Promise<ReviewDetail[]> {
2120
+ return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
2121
+ }
2122
+
2123
+ /**
2124
+ * Calculate review trends over time
2125
+ * Groups reviews by period and calculates rating and recommendation metrics
2126
+ *
2127
+ * @param dateRange - Date range for trend analysis
2128
+ * @param period - Period type (week, month, quarter, year)
2129
+ * @param filters - Optional filters for clinic, practitioner, procedure
2130
+ * @param entityType - Optional entity type to group trends by
2131
+ * @returns Array of review trends with percentage changes
2132
+ */
2133
+ async getReviewTrends(
2134
+ dateRange: AnalyticsDateRange,
2135
+ period: TrendPeriod,
2136
+ filters?: AnalyticsFilters,
2137
+ entityType?: 'practitioner' | 'procedure' | 'technology'
2138
+ ): Promise<ReviewTrend[]> {
2139
+ return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
2140
+ }
1631
2141
  }
1632
2142