@blackcode_sa/metaestetics-api 1.13.0 → 1.13.2
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.
- package/dist/admin/index.d.mts +106 -1
- package/dist/admin/index.d.ts +106 -1
- package/dist/admin/index.js +1303 -130
- package/dist/admin/index.mjs +1303 -130
- package/dist/index.d.mts +360 -2
- package/dist/index.d.ts +360 -2
- package/dist/index.js +3422 -1888
- package/dist/index.mjs +3121 -1588
- package/package.json +1 -1
- package/src/services/analytics/README.md +17 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/analytics.service.ts +540 -30
- package/src/services/analytics/index.ts +1 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +32 -4
- package/src/services/analytics/utils/grouping.utils.ts +40 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/appointment/appointment.service.ts +9 -0
- package/src/services/procedure/procedure.service.ts +419 -4
- package/src/services/reviews/reviews.service.ts +58 -7
- package/src/types/analytics/analytics.types.ts +98 -1
- package/src/types/analytics/grouped-analytics.types.ts +25 -0
|
@@ -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, {
|
|
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, {
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
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(
|
|
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
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
productData.
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
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
|
|