@talkpilot/core-db 1.3.1 → 1.3.4
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/.claude/settings.local.json +7 -0
- package/README.md +30 -0
- package/dist/municipal/tickets/index.d.ts +2 -1
- package/dist/municipal/tickets/index.d.ts.map +1 -1
- package/dist/municipal/tickets/index.js +1 -0
- package/dist/municipal/tickets/index.js.map +1 -1
- package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
- package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.constants.js +10 -0
- package/dist/municipal/tickets/tickets.constants.js.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
- package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
- package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.getters.js +0 -128
- package/dist/municipal/tickets/tickets.getters.js.map +1 -1
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +35 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js +86 -0
- package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts +8 -0
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js +44 -0
- package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
- package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
- package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
- package/dist/municipal/tickets/tickets.types.d.ts +14 -5
- package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
- package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.constants.js +20 -0
- package/dist/talkpilot/calls/calls.constants.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts +28 -0
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js +424 -0
- package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
- package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
- package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
- package/dist/talkpilot/calls/calls.statistics.types.js +3 -0
- package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
- package/dist/talkpilot/calls/calls.types.d.ts +1 -2
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.types.js +0 -3
- package/dist/talkpilot/calls/calls.types.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +33 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js +131 -146
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +27 -6
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.d.ts +3 -0
- package/dist/talkpilot/calls/index.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.js +3 -0
- package/dist/talkpilot/calls/index.js.map +1 -1
- package/dist/utils/date.utils.d.ts +49 -0
- package/dist/utils/date.utils.d.ts.map +1 -0
- package/dist/utils/date.utils.js +103 -0
- package/dist/utils/date.utils.js.map +1 -0
- package/dist/utils/statistics.aggregation.d.ts +20 -0
- package/dist/utils/statistics.aggregation.d.ts.map +1 -0
- package/dist/utils/statistics.aggregation.js +43 -0
- package/dist/utils/statistics.aggregation.js.map +1 -0
- package/package.json +1 -1
- package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
- package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +51 -0
- package/src/municipal/tickets/index.ts +2 -1
- package/src/municipal/tickets/tickets.constants.ts +8 -0
- package/src/municipal/tickets/tickets.getters.ts +0 -140
- package/src/municipal/tickets/tickets.statistics.aggregation.ts +96 -0
- package/src/municipal/tickets/tickets.statistics.getters.ts +71 -0
- package/src/municipal/tickets/tickets.types.ts +19 -9
- package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +8 -111
- package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +344 -0
- package/src/talkpilot/calls/calls.constants.ts +20 -0
- package/src/talkpilot/calls/calls.statistics.getters.ts +587 -0
- package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
- package/src/talkpilot/calls/calls.types.ts +4 -2
- package/src/talkpilot/calls/dashboard/calls.dashboard.ts +148 -197
- package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +25 -12
- package/src/talkpilot/calls/index.ts +3 -0
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +7 -0
- package/src/utils/date.utils.ts +116 -0
|
@@ -3,237 +3,173 @@ import { getCallsCollection } from "../calls.getters";
|
|
|
3
3
|
import { getClientConfig } from "../../clientsConfig";
|
|
4
4
|
import utc from "dayjs/plugin/utc";
|
|
5
5
|
import timezonePlugin from "dayjs/plugin/timezone";
|
|
6
|
-
import isoWeek from "dayjs/plugin/isoWeek";
|
|
7
|
-
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
|
8
6
|
import {
|
|
9
7
|
DashboardAggregationResult,
|
|
10
8
|
DashboardDailyTrendMetric,
|
|
9
|
+
DashboardHeatmapMetric,
|
|
11
10
|
DashboardReportQuery,
|
|
12
11
|
DashboardReportResponse,
|
|
13
12
|
DashboardSummaryMetrics,
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export type {
|
|
18
|
-
DashboardVolumeGranularity,
|
|
19
|
-
DashboardReportQuery,
|
|
20
|
-
DashboardReportResponse,
|
|
13
|
+
RawDailyAggregationResult,
|
|
14
|
+
RawHourlyAggregationResult,
|
|
21
15
|
} from "./calls.dashboard.types";
|
|
16
|
+
import { CallLengthThresholds } from "src/utils/shared.types";
|
|
22
17
|
|
|
23
18
|
const DEFAULT_KPI_DATA = {
|
|
19
|
+
totalCalls: 0,
|
|
20
|
+
totalDuration: 0,
|
|
24
21
|
completedCount: 0,
|
|
22
|
+
failedCount: 0,
|
|
23
|
+
noAnswerCount: 0,
|
|
25
24
|
busyCount: 0,
|
|
26
|
-
answeredDuration: 0,
|
|
27
25
|
};
|
|
28
26
|
|
|
29
27
|
dayjs.extend(utc);
|
|
30
28
|
dayjs.extend(timezonePlugin);
|
|
31
|
-
dayjs.extend(isoWeek);
|
|
32
|
-
dayjs.extend(quarterOfYear);
|
|
33
|
-
|
|
34
|
-
const BUCKET_UNIT: Record<DashboardVolumeGranularity, string> = {
|
|
35
|
-
hour: "hour",
|
|
36
|
-
day: "day",
|
|
37
|
-
week: "isoWeek",
|
|
38
|
-
month: "month",
|
|
39
|
-
quarter: "quarter",
|
|
40
|
-
year: "year",
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
function formatBucketKey(
|
|
44
|
-
bucketStart: dayjs.Dayjs,
|
|
45
|
-
granularity: DashboardVolumeGranularity,
|
|
46
|
-
): string {
|
|
47
|
-
switch (granularity) {
|
|
48
|
-
case "hour":
|
|
49
|
-
return bucketStart.format("YYYY-MM-DDTHH:00");
|
|
50
|
-
case "day":
|
|
51
|
-
case "week":
|
|
52
|
-
return bucketStart.format("YYYY-MM-DD");
|
|
53
|
-
case "month":
|
|
54
|
-
return bucketStart.format("YYYY-MM");
|
|
55
|
-
case "quarter":
|
|
56
|
-
return `${bucketStart.format("YYYY")}-Q${bucketStart.quarter()}`;
|
|
57
|
-
case "year":
|
|
58
|
-
return bucketStart.format("YYYY");
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function generateBucketKeys(
|
|
63
|
-
startDateObj: Date,
|
|
64
|
-
endDateObj: Date,
|
|
65
|
-
granularity: DashboardVolumeGranularity,
|
|
66
|
-
timezone: string,
|
|
67
|
-
): string[] {
|
|
68
|
-
const unitName = BUCKET_UNIT[granularity];
|
|
69
|
-
const startOfUnit = unitName as dayjs.OpUnitType;
|
|
70
|
-
const stepUnit = (
|
|
71
|
-
unitName === "isoWeek" ? "week" : unitName
|
|
72
|
-
) as dayjs.ManipulateType;
|
|
73
|
-
const end = dayjs(endDateObj).tz(timezone);
|
|
74
|
-
let cursor = dayjs(startDateObj).tz(timezone).startOf(startOfUnit);
|
|
75
|
-
|
|
76
|
-
const keys: string[] = [];
|
|
77
|
-
while (cursor.isBefore(end) || cursor.isSame(end, startOfUnit)) {
|
|
78
|
-
keys.push(formatBucketKey(cursor, granularity));
|
|
79
|
-
cursor = cursor.add(1, stepUnit);
|
|
80
|
-
}
|
|
81
|
-
return keys;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function fillVolumeGaps(
|
|
85
|
-
bucketKeys: string[],
|
|
86
|
-
volumeDataRaw: { date: string; completed: number }[],
|
|
87
|
-
): DashboardDailyTrendMetric[] {
|
|
88
|
-
const completedByKey = new Map(
|
|
89
|
-
volumeDataRaw.map((d) => [d.date, d.completed]),
|
|
90
|
-
);
|
|
91
|
-
return bucketKeys.map((date) => ({
|
|
92
|
-
date,
|
|
93
|
-
completed: completedByKey.get(date) ?? 0,
|
|
94
|
-
}));
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function resolveVolumeGranularity(
|
|
98
|
-
startDateObj: Date,
|
|
99
|
-
endDateObj: Date,
|
|
100
|
-
): DashboardVolumeGranularity {
|
|
101
|
-
const spanDays = dayjs(endDateObj).diff(dayjs(startDateObj), "day");
|
|
102
29
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
30
|
+
function buildKpisPipeline() {
|
|
31
|
+
return [
|
|
32
|
+
{
|
|
33
|
+
$group: {
|
|
34
|
+
_id: null,
|
|
35
|
+
totalCalls: { $sum: 1 },
|
|
36
|
+
totalDuration: { $sum: "$callLength" },
|
|
37
|
+
completedCount: {
|
|
38
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
39
|
+
},
|
|
40
|
+
failedCount: {
|
|
41
|
+
$sum: { $cond: [{ $eq: ["$status", "failed"] }, 1, 0] },
|
|
42
|
+
},
|
|
43
|
+
noAnswerCount: {
|
|
44
|
+
$sum: { $cond: [{ $eq: ["$status", "no-answer"] }, 1, 0] },
|
|
45
|
+
},
|
|
46
|
+
busyCount: {
|
|
47
|
+
$sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
];
|
|
109
52
|
}
|
|
110
53
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
function buildVolumeBucketIdExpression(
|
|
120
|
-
timezone: string,
|
|
121
|
-
granularity: DashboardVolumeGranularity,
|
|
122
|
-
) {
|
|
123
|
-
if (granularity === "week") {
|
|
124
|
-
return {
|
|
125
|
-
$dateToString: {
|
|
126
|
-
format: "%Y-%m-%d",
|
|
127
|
-
date: {
|
|
128
|
-
$dateTrunc: {
|
|
54
|
+
function buildDailyDataPipeline(timezone: string) {
|
|
55
|
+
return [
|
|
56
|
+
{
|
|
57
|
+
$group: {
|
|
58
|
+
_id: {
|
|
59
|
+
$dateToString: {
|
|
60
|
+
format: "%Y-%m-%d",
|
|
129
61
|
date: "$createdAt",
|
|
130
|
-
|
|
131
|
-
timezone,
|
|
132
|
-
startOfWeek: "monday",
|
|
62
|
+
timezone: timezone,
|
|
133
63
|
},
|
|
134
64
|
},
|
|
135
|
-
|
|
65
|
+
count: { $sum: 1 },
|
|
66
|
+
completed: {
|
|
67
|
+
$sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
|
|
68
|
+
},
|
|
136
69
|
},
|
|
137
|
-
}
|
|
138
|
-
|
|
70
|
+
},
|
|
71
|
+
{ $sort: { _id: 1 } },
|
|
72
|
+
];
|
|
73
|
+
}
|
|
139
74
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
{
|
|
152
|
-
$subtract: [
|
|
153
|
-
{ $month: { date: "$createdAt", timezone } },
|
|
154
|
-
1,
|
|
155
|
-
],
|
|
156
|
-
},
|
|
157
|
-
3,
|
|
158
|
-
],
|
|
159
|
-
},
|
|
160
|
-
},
|
|
161
|
-
1,
|
|
162
|
-
],
|
|
75
|
+
function buildHourlyDataPipeline(timezone: string) {
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
$group: {
|
|
79
|
+
_id: {
|
|
80
|
+
day: {
|
|
81
|
+
$dateToString: {
|
|
82
|
+
format: "%Y-%m-%d",
|
|
83
|
+
date: "$createdAt",
|
|
84
|
+
timezone: timezone,
|
|
85
|
+
},
|
|
163
86
|
},
|
|
87
|
+
hour: { $hour: { date: "$createdAt", timezone: timezone } },
|
|
164
88
|
},
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
return {
|
|
170
|
-
$dateToString: {
|
|
171
|
-
format: VOLUME_DATE_FORMATS[granularity],
|
|
172
|
-
date: "$createdAt",
|
|
173
|
-
timezone,
|
|
89
|
+
count: { $sum: 1 },
|
|
90
|
+
},
|
|
174
91
|
},
|
|
175
|
-
|
|
92
|
+
];
|
|
176
93
|
}
|
|
177
94
|
|
|
178
|
-
function
|
|
95
|
+
export function buildCallLengthBucketsPipeline(
|
|
96
|
+
thresholds: CallLengthThresholds,
|
|
97
|
+
) {
|
|
98
|
+
const { shortThreshold, mediumThreshold } = thresholds;
|
|
99
|
+
|
|
179
100
|
return [
|
|
180
101
|
{
|
|
181
102
|
$group: {
|
|
182
103
|
_id: null,
|
|
183
|
-
|
|
184
|
-
$sum: { $cond: [{ $
|
|
104
|
+
short: {
|
|
105
|
+
$sum: { $cond: [{ $lt: ["$callLength", shortThreshold] }, 1, 0] },
|
|
185
106
|
},
|
|
186
|
-
|
|
187
|
-
$sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
|
|
188
|
-
},
|
|
189
|
-
answeredDuration: {
|
|
107
|
+
medium: {
|
|
190
108
|
$sum: {
|
|
191
109
|
$cond: [
|
|
192
|
-
{
|
|
193
|
-
|
|
110
|
+
{
|
|
111
|
+
$and: [
|
|
112
|
+
{ $gte: ["$callLength", shortThreshold] },
|
|
113
|
+
{ $lte: ["$callLength", mediumThreshold] },
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
1,
|
|
194
117
|
0,
|
|
195
118
|
],
|
|
196
119
|
},
|
|
197
120
|
},
|
|
121
|
+
long: {
|
|
122
|
+
$sum: { $cond: [{ $gt: ["$callLength", mediumThreshold] }, 1, 0] },
|
|
123
|
+
},
|
|
198
124
|
},
|
|
199
125
|
},
|
|
200
126
|
];
|
|
201
127
|
}
|
|
202
128
|
|
|
203
|
-
function
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
]
|
|
129
|
+
function buildHeatmapData(
|
|
130
|
+
hourlyDataRaw: RawHourlyAggregationResult[],
|
|
131
|
+
): Record<string, DashboardHeatmapMetric[]> {
|
|
132
|
+
const heatmapMap = new Map<string, Map<number, number>>();
|
|
133
|
+
|
|
134
|
+
for (const bucket of hourlyDataRaw) {
|
|
135
|
+
const dayKey = bucket._id.day;
|
|
136
|
+
const hour = bucket._id.hour;
|
|
137
|
+
if (!heatmapMap.has(dayKey)) heatmapMap.set(dayKey, new Map());
|
|
138
|
+
heatmapMap.get(dayKey)!.set(hour, bucket.count);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const toSortedBuckets = (
|
|
142
|
+
map: Map<number, number>,
|
|
143
|
+
): DashboardHeatmapMetric[] =>
|
|
144
|
+
Array.from(map.entries())
|
|
145
|
+
.sort(([a], [b]) => a - b)
|
|
146
|
+
.map(([h, c]) => ({
|
|
147
|
+
hour: h,
|
|
148
|
+
calls: c,
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
const heatmap: Record<string, DashboardHeatmapMetric[]> = {};
|
|
152
|
+
for (const [day, hourMap] of Array.from(heatmapMap.entries()).sort()) {
|
|
153
|
+
heatmap[day] = toSortedBuckets(hourMap);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return heatmap;
|
|
218
157
|
}
|
|
219
158
|
|
|
220
159
|
export async function getDashboardStats(
|
|
221
160
|
params: DashboardReportQuery,
|
|
222
161
|
): Promise<DashboardReportResponse> {
|
|
223
|
-
const {
|
|
224
|
-
clientId,
|
|
225
|
-
startDate,
|
|
226
|
-
endDate,
|
|
227
|
-
granularity: requestedGranularity,
|
|
228
|
-
} = params;
|
|
162
|
+
const { clientId, startDate, endDate } = params;
|
|
229
163
|
const clientConfig = await getClientConfig(clientId);
|
|
230
164
|
const timezone = clientConfig?.timezone ?? "UTC";
|
|
231
165
|
|
|
232
|
-
const startDateObj = dayjs
|
|
233
|
-
const endDateObj = dayjs
|
|
166
|
+
const startDateObj = dayjs.tz(startDate, timezone).startOf("day").toDate();
|
|
167
|
+
const endDateObj = dayjs.tz(endDate, timezone).endOf("day").toDate();
|
|
234
168
|
|
|
235
|
-
const
|
|
236
|
-
|
|
169
|
+
const thresholds: CallLengthThresholds = {
|
|
170
|
+
shortThreshold: clientConfig?.callLengthThresholds?.shortThreshold ?? 60,
|
|
171
|
+
mediumThreshold: clientConfig?.callLengthThresholds?.mediumThreshold ?? 180,
|
|
172
|
+
};
|
|
237
173
|
|
|
238
174
|
const pipeline = [
|
|
239
175
|
{
|
|
@@ -245,7 +181,9 @@ export async function getDashboardStats(
|
|
|
245
181
|
{
|
|
246
182
|
$facet: {
|
|
247
183
|
kpis: buildKpisPipeline(),
|
|
248
|
-
|
|
184
|
+
dailyData: buildDailyDataPipeline(timezone),
|
|
185
|
+
hourlyData: buildHourlyDataPipeline(timezone),
|
|
186
|
+
callLengthBuckets: buildCallLengthBucketsPipeline(thresholds),
|
|
249
187
|
},
|
|
250
188
|
},
|
|
251
189
|
];
|
|
@@ -256,36 +194,49 @@ export async function getDashboardStats(
|
|
|
256
194
|
.toArray();
|
|
257
195
|
|
|
258
196
|
const kpiData = aggregatedResult?.kpis?.[0] ?? DEFAULT_KPI_DATA;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
const
|
|
262
|
-
|
|
197
|
+
const dailyDataRaw = aggregatedResult?.dailyData ?? [];
|
|
198
|
+
const hourlyDataRaw = aggregatedResult?.hourlyData ?? [];
|
|
199
|
+
const callLengthRaw = aggregatedResult?.callLengthBuckets?.[0] ?? {
|
|
200
|
+
short: 0,
|
|
201
|
+
medium: 0,
|
|
202
|
+
long: 0,
|
|
203
|
+
};
|
|
263
204
|
|
|
264
205
|
const kpis: DashboardSummaryMetrics = {
|
|
265
|
-
totalCalls:
|
|
206
|
+
totalCalls: kpiData.totalCalls,
|
|
266
207
|
avgDurationSeconds:
|
|
267
|
-
|
|
268
|
-
|
|
208
|
+
kpiData.totalCalls > 0
|
|
209
|
+
? Math.round((kpiData.totalDuration ?? 0) / kpiData.totalCalls)
|
|
210
|
+
: 0,
|
|
211
|
+
timeSavedMinutes: Math.round((kpiData.totalDuration ?? 0) / 60),
|
|
212
|
+
successRate:
|
|
213
|
+
kpiData.totalCalls > 0
|
|
214
|
+
? Math.round((kpiData.completedCount / kpiData.totalCalls) * 1000) / 10
|
|
215
|
+
: 0,
|
|
269
216
|
completedCount: kpiData.completedCount,
|
|
217
|
+
failedCount: kpiData.failedCount,
|
|
218
|
+
noAnswerCount: kpiData.noAnswerCount,
|
|
270
219
|
busyCount: kpiData.busyCount,
|
|
271
220
|
};
|
|
272
221
|
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
280
|
-
bucketKeys,
|
|
281
|
-
volumeDataRaw.map((d) => ({ date: d._id, completed: d.completed })),
|
|
282
|
-
);
|
|
222
|
+
const volumeData: DashboardDailyTrendMetric[] = dailyDataRaw.map((d) => ({
|
|
223
|
+
date: d._id,
|
|
224
|
+
count: d.count,
|
|
225
|
+
completed: d.completed,
|
|
226
|
+
}));
|
|
227
|
+
|
|
228
|
+
const heatmap = buildHeatmapData(hourlyDataRaw);
|
|
283
229
|
|
|
284
230
|
const response: DashboardReportResponse = {
|
|
285
231
|
kpis,
|
|
286
232
|
charts: {
|
|
287
233
|
volumeData,
|
|
288
|
-
|
|
234
|
+
heatmap,
|
|
235
|
+
callLengthBuckets: {
|
|
236
|
+
short: callLengthRaw.short,
|
|
237
|
+
medium: callLengthRaw.medium,
|
|
238
|
+
long: callLengthRaw.long,
|
|
239
|
+
},
|
|
289
240
|
},
|
|
290
241
|
};
|
|
291
242
|
return response;
|
|
@@ -5,35 +5,37 @@ export type DashboardHeatmapMetric = {
|
|
|
5
5
|
|
|
6
6
|
export type DashboardDailyTrendMetric = {
|
|
7
7
|
date: string;
|
|
8
|
+
count: number;
|
|
8
9
|
completed: number;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
|
-
export type
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
| "quarter"
|
|
17
|
-
| "year";
|
|
12
|
+
export type DashboardDurationSegments = {
|
|
13
|
+
short: number;
|
|
14
|
+
medium: number;
|
|
15
|
+
long: number;
|
|
16
|
+
};
|
|
18
17
|
|
|
19
18
|
export type DashboardSummaryMetrics = {
|
|
20
19
|
totalCalls: number;
|
|
21
20
|
avgDurationSeconds: number;
|
|
22
21
|
timeSavedMinutes: number;
|
|
22
|
+
successRate: number;
|
|
23
23
|
completedCount: number;
|
|
24
|
+
failedCount: number;
|
|
25
|
+
noAnswerCount: number;
|
|
24
26
|
busyCount: number;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export type DashboardVisualData = {
|
|
28
30
|
volumeData: DashboardDailyTrendMetric[];
|
|
29
|
-
|
|
31
|
+
heatmap: Record<string, DashboardHeatmapMetric[]>;
|
|
32
|
+
callLengthBuckets: DashboardDurationSegments;
|
|
30
33
|
};
|
|
31
34
|
|
|
32
35
|
export type DashboardReportQuery = {
|
|
33
36
|
clientId: string;
|
|
34
37
|
startDate: string;
|
|
35
38
|
endDate: string;
|
|
36
|
-
granularity?: DashboardVolumeGranularity;
|
|
37
39
|
};
|
|
38
40
|
|
|
39
41
|
export type DashboardReportResponse = {
|
|
@@ -41,17 +43,28 @@ export type DashboardReportResponse = {
|
|
|
41
43
|
charts: DashboardVisualData;
|
|
42
44
|
};
|
|
43
45
|
type RawKpiResult = {
|
|
46
|
+
totalCalls: number;
|
|
47
|
+
totalDuration: number | null;
|
|
44
48
|
completedCount: number;
|
|
49
|
+
failedCount: number;
|
|
50
|
+
noAnswerCount: number;
|
|
45
51
|
busyCount: number;
|
|
46
|
-
answeredDuration: number | null;
|
|
47
52
|
};
|
|
48
53
|
|
|
49
|
-
export type
|
|
54
|
+
export type RawDailyAggregationResult = {
|
|
50
55
|
_id: string;
|
|
56
|
+
count: number;
|
|
51
57
|
completed: number;
|
|
52
58
|
};
|
|
53
59
|
|
|
60
|
+
export type RawHourlyAggregationResult = {
|
|
61
|
+
_id: { day: string; hour: number };
|
|
62
|
+
count: number;
|
|
63
|
+
};
|
|
64
|
+
|
|
54
65
|
export interface DashboardAggregationResult {
|
|
55
66
|
kpis: RawKpiResult[];
|
|
56
|
-
|
|
67
|
+
dailyData: RawDailyAggregationResult[];
|
|
68
|
+
hourlyData: RawHourlyAggregationResult[];
|
|
69
|
+
callLengthBuckets: DashboardDurationSegments[];
|
|
57
70
|
}
|
|
@@ -36,6 +36,10 @@ describe("db.clientsConfig", () => {
|
|
|
36
36
|
timezone: "Asia/Jerusalem",
|
|
37
37
|
language: "he",
|
|
38
38
|
defaultBaseUrl: "https://acme.example",
|
|
39
|
+
scheduler: {
|
|
40
|
+
type: "cron",
|
|
41
|
+
schedule: "0 9 * * 1-5",
|
|
42
|
+
},
|
|
39
43
|
},
|
|
40
44
|
},
|
|
41
45
|
});
|
|
@@ -53,6 +57,9 @@ describe("db.clientsConfig", () => {
|
|
|
53
57
|
timezone: "Asia/Jerusalem",
|
|
54
58
|
language: "he",
|
|
55
59
|
defaultBaseUrl: "https://acme.example",
|
|
60
|
+
scheduler: {
|
|
61
|
+
type: "cron",
|
|
62
|
+
},
|
|
56
63
|
},
|
|
57
64
|
},
|
|
58
65
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared timezone-aware date helpers for statistics aggregations.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Milliseconds in a single day. */
|
|
6
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
/** Maps a short weekday name (as produced by Intl) to its index (Sun = 0). */
|
|
9
|
+
export const WEEKDAY: Record<string, number> = {
|
|
10
|
+
Sun: 0,
|
|
11
|
+
Mon: 1,
|
|
12
|
+
Tue: 2,
|
|
13
|
+
Wed: 3,
|
|
14
|
+
Thu: 4,
|
|
15
|
+
Fri: 5,
|
|
16
|
+
Sat: 6,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Parses a `YYYY-MM-DD` string into `[year, month, day]` numbers. */
|
|
20
|
+
export const parseYmd = (dateStr: string): [number, number, number] => {
|
|
21
|
+
const [y, m, d] = dateStr.split("-").map(Number);
|
|
22
|
+
return [y, m, d];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Builds a zero-padded `YYYY-MM-DD` string from numeric parts. */
|
|
26
|
+
export const ymdToStr = (y: number, m: number, d: number): string =>
|
|
27
|
+
`${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
28
|
+
|
|
29
|
+
/** Parses a `YYYY-MM-DD` string as midnight UTC. */
|
|
30
|
+
export const toUtcDate = (dateStr: string): Date =>
|
|
31
|
+
new Date(`${dateStr}T00:00:00.000Z`);
|
|
32
|
+
|
|
33
|
+
/** Formats a date as a `YYYY-MM-DD` string in UTC. */
|
|
34
|
+
export const formatDate = (date: Date): string => date.toISOString().slice(0, 10);
|
|
35
|
+
|
|
36
|
+
/** Adds `days` to a `YYYY-MM-DD` string, returning the resulting `YYYY-MM-DD`. */
|
|
37
|
+
export const addDaysToDateStr = (dateStr: string, days: number): string =>
|
|
38
|
+
formatDate(new Date(toUtcDate(dateStr).getTime() + days * DAY_MS));
|
|
39
|
+
|
|
40
|
+
/** Next civil calendar day (proleptic Gregorian). Avoids DST 24h steps that can stall iteration. */
|
|
41
|
+
export const incrementYmd = (
|
|
42
|
+
y: number,
|
|
43
|
+
m: number,
|
|
44
|
+
d: number,
|
|
45
|
+
): [number, number, number] => {
|
|
46
|
+
const next = new Date(Date.UTC(y, m - 1, d + 1));
|
|
47
|
+
return [next.getUTCFullYear(), next.getUTCMonth() + 1, next.getUTCDate()];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Formats a date as the calendar day in the given timezone.
|
|
52
|
+
* `en-CA` yields ISO-8601 `YYYY-MM-DD`, which is lexicographically sortable
|
|
53
|
+
* (string compare == chronological compare) and matches MongoDB's `%Y-%m-%d`.
|
|
54
|
+
*/
|
|
55
|
+
export const formatYmdInTz = (date: Date, timezone: string): string =>
|
|
56
|
+
new Intl.DateTimeFormat("en-CA", { timeZone: timezone }).format(date);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Returns the calendar day in the given timezone as numeric parts.
|
|
60
|
+
* Reads parts by `type` (not string order), so `en-US` is fine here.
|
|
61
|
+
*/
|
|
62
|
+
export const getYmdInTz = (date: Date, timezone: string) => {
|
|
63
|
+
const parts = Object.fromEntries(
|
|
64
|
+
new Intl.DateTimeFormat("en-US", {
|
|
65
|
+
timeZone: timezone,
|
|
66
|
+
year: "numeric",
|
|
67
|
+
month: "numeric",
|
|
68
|
+
day: "numeric",
|
|
69
|
+
})
|
|
70
|
+
.formatToParts(date)
|
|
71
|
+
.map((p) => [p.type, Number(p.value)]),
|
|
72
|
+
);
|
|
73
|
+
return { y: parts.year, m: parts.month, d: parts.day };
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Weekday index (Sun = 0) of a date in the given timezone. */
|
|
77
|
+
export const getWeekdayInTz = (date: Date, timezone: string): number =>
|
|
78
|
+
WEEKDAY[
|
|
79
|
+
new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short" }).format(
|
|
80
|
+
date,
|
|
81
|
+
)
|
|
82
|
+
] ?? 0;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Returns the given `YYYY-MM-DD` day at noon UTC — a stable anchor inside the
|
|
86
|
+
* calendar day, far from midnight so timezone/DST shifts can't cross a date boundary.
|
|
87
|
+
*/
|
|
88
|
+
export const dateAtNoonUtc = (dateStr: string): Date => {
|
|
89
|
+
const [y, m, d] = parseYmd(dateStr);
|
|
90
|
+
return new Date(Date.UTC(y, m - 1, d, 12, 0, 0));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* UTC instant at the start of the given `YYYY-MM-DD` calendar day in `timezone`.
|
|
95
|
+
* Binary-searches the boundary so it stays correct across DST transitions.
|
|
96
|
+
*/
|
|
97
|
+
export const startOfCalendarDayInTz = (dateStr: string, timezone: string): Date => {
|
|
98
|
+
const anchor = dateAtNoonUtc(dateStr).getTime();
|
|
99
|
+
let low = anchor - 2 * DAY_MS;
|
|
100
|
+
let high = anchor + DAY_MS;
|
|
101
|
+
|
|
102
|
+
while (high - low > 1 /* ms */) {
|
|
103
|
+
const mid = Math.floor((low + high) / 2);
|
|
104
|
+
if (formatYmdInTz(new Date(mid), timezone) >= dateStr) high = mid;
|
|
105
|
+
else low = mid;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return new Date(high);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** UTC instant at the last millisecond of the given calendar day in `timezone`. */
|
|
112
|
+
export const endOfCalendarDayInTz = (dateStr: string, timezone: string): Date => {
|
|
113
|
+
const [y, m, d] = parseYmd(dateStr);
|
|
114
|
+
const nextDay = new Date(Date.UTC(y, m - 1, d + 1)).toISOString().slice(0, 10);
|
|
115
|
+
return new Date(startOfCalendarDayInTz(nextDay, timezone).getTime() - 1);
|
|
116
|
+
};
|