@talkpilot/core-db 1.3.3 → 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.
Files changed (150) hide show
  1. package/.claude/settings.local.json +7 -0
  2. package/README.md +30 -0
  3. package/dist/connection.d.ts.map +1 -1
  4. package/dist/connection.js +0 -10
  5. package/dist/connection.js.map +1 -1
  6. package/dist/index.d.ts +0 -3
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -7
  9. package/dist/index.js.map +1 -1
  10. package/dist/municipal/tickets/index.d.ts +2 -1
  11. package/dist/municipal/tickets/index.d.ts.map +1 -1
  12. package/dist/municipal/tickets/index.js +1 -0
  13. package/dist/municipal/tickets/index.js.map +1 -1
  14. package/dist/municipal/tickets/tickets.constants.d.ts +7 -0
  15. package/dist/municipal/tickets/tickets.constants.d.ts.map +1 -0
  16. package/dist/municipal/tickets/tickets.constants.js +10 -0
  17. package/dist/municipal/tickets/tickets.constants.js.map +1 -0
  18. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +12 -0
  19. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +1 -0
  20. package/dist/municipal/tickets/tickets.deprecated.getters.js +131 -0
  21. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +1 -0
  22. package/dist/municipal/tickets/tickets.getters.d.ts +0 -11
  23. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  24. package/dist/municipal/tickets/tickets.getters.js +0 -128
  25. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  26. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +35 -0
  27. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +1 -0
  28. package/dist/municipal/tickets/tickets.statistics.aggregation.js +86 -0
  29. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +1 -0
  30. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +7 -0
  31. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +1 -0
  32. package/dist/municipal/tickets/tickets.statistics.dates.js +40 -0
  33. package/dist/municipal/tickets/tickets.statistics.dates.js.map +1 -0
  34. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +8 -0
  35. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +1 -0
  36. package/dist/municipal/tickets/tickets.statistics.getters.js +44 -0
  37. package/dist/municipal/tickets/tickets.statistics.getters.js.map +1 -0
  38. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +53 -0
  39. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +1 -0
  40. package/dist/municipal/tickets/tickets.statistics.pipeline.js +112 -0
  41. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +1 -0
  42. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +7 -0
  43. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +1 -0
  44. package/dist/municipal/tickets/tickets.statistics.utils.js +40 -0
  45. package/dist/municipal/tickets/tickets.statistics.utils.js.map +1 -0
  46. package/dist/municipal/tickets/tickets.types.d.ts +14 -5
  47. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  48. package/dist/talkpilot/calls/calls.constants.d.ts +17 -0
  49. package/dist/talkpilot/calls/calls.constants.d.ts.map +1 -0
  50. package/dist/talkpilot/calls/calls.constants.js +20 -0
  51. package/dist/talkpilot/calls/calls.constants.js.map +1 -0
  52. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +28 -0
  53. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +1 -0
  54. package/dist/talkpilot/calls/calls.statistics.getters.js +424 -0
  55. package/dist/talkpilot/calls/calls.statistics.getters.js.map +1 -0
  56. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +12 -0
  57. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +1 -0
  58. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +37 -0
  59. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +1 -0
  60. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +17 -0
  61. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +1 -0
  62. package/dist/talkpilot/calls/calls.statistics.tickets.js +33 -0
  63. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +1 -0
  64. package/dist/talkpilot/calls/calls.statistics.types.d.ts +39 -0
  65. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +1 -0
  66. package/dist/{websitalk/scans/scans.types.js → talkpilot/calls/calls.statistics.types.js} +1 -1
  67. package/dist/talkpilot/calls/calls.statistics.types.js.map +1 -0
  68. package/dist/talkpilot/calls/calls.types.d.ts +1 -2
  69. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  70. package/dist/talkpilot/calls/calls.types.js +0 -3
  71. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  72. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +33 -1
  73. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
  74. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +131 -146
  75. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
  76. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +27 -6
  77. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
  78. package/dist/talkpilot/calls/index.d.ts +3 -0
  79. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  80. package/dist/talkpilot/calls/index.js +3 -0
  81. package/dist/talkpilot/calls/index.js.map +1 -1
  82. package/dist/test-utils/db-utils.d.ts.map +1 -1
  83. package/dist/test-utils/db-utils.js +0 -2
  84. package/dist/test-utils/db-utils.js.map +1 -1
  85. package/dist/test-utils/factories/index.d.ts +0 -1
  86. package/dist/test-utils/factories/index.d.ts.map +1 -1
  87. package/dist/test-utils/factories/index.js +0 -1
  88. package/dist/test-utils/factories/index.js.map +1 -1
  89. package/dist/utils/date.utils.d.ts +49 -0
  90. package/dist/utils/date.utils.d.ts.map +1 -0
  91. package/dist/utils/date.utils.js +103 -0
  92. package/dist/utils/date.utils.js.map +1 -0
  93. package/dist/utils/statistics.aggregation.d.ts +20 -0
  94. package/dist/utils/statistics.aggregation.d.ts.map +1 -0
  95. package/dist/utils/statistics.aggregation.js +43 -0
  96. package/dist/utils/statistics.aggregation.js.map +1 -0
  97. package/package.json +1 -1
  98. package/src/connection.ts +0 -12
  99. package/src/index.ts +0 -9
  100. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +1 -37
  101. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +51 -0
  102. package/src/municipal/tickets/index.ts +2 -1
  103. package/src/municipal/tickets/tickets.constants.ts +8 -0
  104. package/src/municipal/tickets/tickets.getters.ts +0 -140
  105. package/src/municipal/tickets/tickets.statistics.aggregation.ts +96 -0
  106. package/src/municipal/tickets/tickets.statistics.getters.ts +71 -0
  107. package/src/municipal/tickets/tickets.types.ts +19 -9
  108. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +8 -111
  109. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +344 -0
  110. package/src/talkpilot/calls/calls.constants.ts +20 -0
  111. package/src/talkpilot/calls/calls.statistics.getters.ts +587 -0
  112. package/src/talkpilot/calls/calls.statistics.types.ts +44 -0
  113. package/src/talkpilot/calls/calls.types.ts +4 -2
  114. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +148 -197
  115. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +25 -12
  116. package/src/talkpilot/calls/index.ts +3 -0
  117. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +7 -0
  118. package/src/test-utils/db-utils.ts +1 -3
  119. package/src/test-utils/factories/index.ts +0 -1
  120. package/src/utils/date.utils.ts +116 -0
  121. package/dist/test-utils/factories/websitalk/scans.d.ts +0 -5
  122. package/dist/test-utils/factories/websitalk/scans.d.ts.map +0 -1
  123. package/dist/test-utils/factories/websitalk/scans.js +0 -25
  124. package/dist/test-utils/factories/websitalk/scans.js.map +0 -1
  125. package/dist/websitalk/index.d.ts +0 -7
  126. package/dist/websitalk/index.d.ts.map +0 -1
  127. package/dist/websitalk/index.js +0 -34
  128. package/dist/websitalk/index.js.map +0 -1
  129. package/dist/websitalk/mongodb-client.d.ts +0 -13
  130. package/dist/websitalk/mongodb-client.d.ts.map +0 -1
  131. package/dist/websitalk/mongodb-client.js +0 -56
  132. package/dist/websitalk/mongodb-client.js.map +0 -1
  133. package/dist/websitalk/scans/index.d.ts +0 -3
  134. package/dist/websitalk/scans/index.d.ts.map +0 -1
  135. package/dist/websitalk/scans/index.js +0 -19
  136. package/dist/websitalk/scans/index.js.map +0 -1
  137. package/dist/websitalk/scans/scans.getters.d.ts +0 -12
  138. package/dist/websitalk/scans/scans.getters.d.ts.map +0 -1
  139. package/dist/websitalk/scans/scans.getters.js +0 -74
  140. package/dist/websitalk/scans/scans.getters.js.map +0 -1
  141. package/dist/websitalk/scans/scans.types.d.ts +0 -45
  142. package/dist/websitalk/scans/scans.types.d.ts.map +0 -1
  143. package/dist/websitalk/scans/scans.types.js.map +0 -1
  144. package/src/test-utils/factories/websitalk/scans.ts +0 -23
  145. package/src/websitalk/index.ts +0 -15
  146. package/src/websitalk/mongodb-client.ts +0 -61
  147. package/src/websitalk/scans/__tests__/scans.spec.ts +0 -218
  148. package/src/websitalk/scans/index.ts +0 -2
  149. package/src/websitalk/scans/scans.getters.ts +0 -113
  150. package/src/websitalk/scans/scans.types.ts +0 -53
@@ -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
- DashboardVolumeGranularity,
15
- } from "./calls.dashboard.types";
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
- if (spanDays <= 2) return "hour";
104
- if (spanDays <= 60) return "day";
105
- if (spanDays <= 365) return "week";
106
- if (spanDays <= 730) return "month";
107
- if (spanDays <= 1460) return "quarter";
108
- return "year";
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
- const VOLUME_DATE_FORMATS: Partial<Record<DashboardVolumeGranularity, string>> =
112
- {
113
- hour: "%Y-%m-%dT%H:00",
114
- day: "%Y-%m-%d",
115
- month: "%Y-%m",
116
- year: "%Y",
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
- unit: "week",
131
- timezone,
132
- startOfWeek: "monday",
62
+ timezone: timezone,
133
63
  },
134
64
  },
135
- timezone,
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
- if (granularity === "quarter") {
141
- return {
142
- $concat: [
143
- { $toString: { $year: { date: "$createdAt", timezone } } },
144
- "-Q",
145
- {
146
- $toString: {
147
- $add: [
148
- {
149
- $trunc: {
150
- $divide: [
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 buildKpisPipeline() {
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
- completedCount: {
184
- $sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
104
+ short: {
105
+ $sum: { $cond: [{ $lt: ["$callLength", shortThreshold] }, 1, 0] },
185
106
  },
186
- busyCount: {
187
- $sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
188
- },
189
- answeredDuration: {
107
+ medium: {
190
108
  $sum: {
191
109
  $cond: [
192
- { $in: ["$status", ["completed", "busy"]] },
193
- "$callLength",
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 buildVolumeDataPipeline(
204
- timezone: string,
205
- granularity: DashboardVolumeGranularity,
206
- ) {
207
- return [
208
- {
209
- $group: {
210
- _id: buildVolumeBucketIdExpression(timezone, granularity),
211
- completed: {
212
- $sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
213
- },
214
- },
215
- },
216
- { $sort: { _id: 1 } },
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(startDate).tz(timezone).startOf("day").toDate();
233
- const endDateObj = dayjs(endDate).tz(timezone).endOf("day").toDate();
166
+ const startDateObj = dayjs.tz(startDate, timezone).startOf("day").toDate();
167
+ const endDateObj = dayjs.tz(endDate, timezone).endOf("day").toDate();
234
168
 
235
- const granularity =
236
- requestedGranularity ?? resolveVolumeGranularity(startDateObj, endDateObj);
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
- volumeData: buildVolumeDataPipeline(timezone, granularity),
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 volumeDataRaw = aggregatedResult?.volumeData ?? [];
260
-
261
- const answeredCount = kpiData.completedCount + kpiData.busyCount;
262
- const answeredDuration = kpiData.answeredDuration ?? 0;
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: answeredCount,
206
+ totalCalls: kpiData.totalCalls,
266
207
  avgDurationSeconds:
267
- answeredCount > 0 ? Math.round(answeredDuration / answeredCount) : 0,
268
- timeSavedMinutes: Math.round(answeredDuration / 60),
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 bucketKeys = generateBucketKeys(
274
- startDateObj,
275
- endDateObj,
276
- granularity,
277
- timezone,
278
- );
279
- const volumeData: DashboardDailyTrendMetric[] = fillVolumeGaps(
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
- volumeGranularity: granularity,
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 DashboardVolumeGranularity =
12
- | "hour"
13
- | "day"
14
- | "week"
15
- | "month"
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
- volumeGranularity: DashboardVolumeGranularity;
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 RawVolumeAggregationResult = {
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
- volumeData: RawVolumeAggregationResult[];
67
+ dailyData: RawDailyAggregationResult[];
68
+ hourlyData: RawHourlyAggregationResult[];
69
+ callLengthBuckets: DashboardDurationSegments[];
57
70
  }
@@ -1,3 +1,6 @@
1
+ export * from "./calls.constants";
1
2
  export * from "./calls.types";
2
3
  export * from "./calls.getters";
4
+ export * from "./calls.statistics.types";
5
+ export * from "./calls.statistics.getters";
3
6
  export * from "./dashboard/calls.dashboard";
@@ -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
  });
@@ -1,6 +1,6 @@
1
1
  import { MongoMemoryServer } from "mongodb-memory-server";
2
2
  import { MongoClient, Db } from "mongodb";
3
- import { setDb, setMunicipalDataDb, setWebsitalkDb } from "../index";
3
+ import { setDb, setMunicipalDataDb } from "../index";
4
4
 
5
5
  let mongoServer: MongoMemoryServer;
6
6
  let client: MongoClient;
@@ -13,10 +13,8 @@ export async function initTestDb(): Promise<Db> {
13
13
  await client.connect();
14
14
  const talkpilotDb = client.db();
15
15
  const municipalDb = client.db("municipal-data");
16
- const websitalkDb = client.db("website-talk");
17
16
  setDb(talkpilotDb);
18
17
  setMunicipalDataDb(municipalDb);
19
- setWebsitalkDb(websitalkDb);
20
18
  return talkpilotDb;
21
19
  }
22
20
 
@@ -10,4 +10,3 @@ export * from "./municipal/cities";
10
10
  export * from "./municipal/departmentsSubjects";
11
11
  export * from "./municipal/streets";
12
12
  export * from "./municipal/tickets";
13
- export * from "./websitalk/scans";
@@ -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
+ };
@@ -1,5 +0,0 @@
1
- import { Factory } from "fishery";
2
- import type { Scan } from "../../../websitalk";
3
- export declare const scanFactory: Factory<Scan, any, Scan, import("fishery").DeepPartialObject<Scan>>;
4
- export declare function createScanFixture(overrides?: Partial<Scan>): Scan;
5
- //# sourceMappingURL=scans.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"scans.d.ts","sourceRoot":"","sources":["../../../../src/test-utils/factories/websitalk/scans.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAElC,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,oBAAoB,CAAC;AAE/C,eAAO,MAAM,WAAW,qEAcrB,CAAC;AAEJ,wBAAgB,iBAAiB,CAAC,SAAS,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAEjE"}