@talkpilot/core-db 1.3.0 → 1.3.3

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 (149) hide show
  1. package/README.md +0 -30
  2. package/dist/connection.d.ts.map +1 -1
  3. package/dist/connection.js +10 -0
  4. package/dist/connection.js.map +1 -1
  5. package/dist/index.d.ts +3 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +7 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/municipal/tickets/index.d.ts +1 -2
  10. package/dist/municipal/tickets/index.d.ts.map +1 -1
  11. package/dist/municipal/tickets/index.js +0 -1
  12. package/dist/municipal/tickets/index.js.map +1 -1
  13. package/dist/municipal/tickets/tickets.getters.d.ts +11 -0
  14. package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
  15. package/dist/municipal/tickets/tickets.getters.js +128 -0
  16. package/dist/municipal/tickets/tickets.getters.js.map +1 -1
  17. package/dist/municipal/tickets/tickets.types.d.ts +5 -10
  18. package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
  19. package/dist/talkpilot/calls/calls.types.d.ts +2 -1
  20. package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
  21. package/dist/talkpilot/calls/calls.types.js +3 -0
  22. package/dist/talkpilot/calls/calls.types.js.map +1 -1
  23. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +1 -33
  24. package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
  25. package/dist/talkpilot/calls/dashboard/calls.dashboard.js +146 -131
  26. package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
  27. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +6 -27
  28. package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
  29. package/dist/talkpilot/calls/index.d.ts +0 -3
  30. package/dist/talkpilot/calls/index.d.ts.map +1 -1
  31. package/dist/talkpilot/calls/index.js +0 -3
  32. package/dist/talkpilot/calls/index.js.map +1 -1
  33. package/dist/test-utils/db-utils.d.ts.map +1 -1
  34. package/dist/test-utils/db-utils.js +2 -0
  35. package/dist/test-utils/db-utils.js.map +1 -1
  36. package/dist/test-utils/factories/index.d.ts +1 -0
  37. package/dist/test-utils/factories/index.d.ts.map +1 -1
  38. package/dist/test-utils/factories/index.js +1 -0
  39. package/dist/test-utils/factories/index.js.map +1 -1
  40. package/dist/test-utils/factories/websitalk/scans.d.ts +5 -0
  41. package/dist/test-utils/factories/websitalk/scans.d.ts.map +1 -0
  42. package/dist/test-utils/factories/websitalk/scans.js +25 -0
  43. package/dist/test-utils/factories/websitalk/scans.js.map +1 -0
  44. package/dist/websitalk/index.d.ts +7 -0
  45. package/dist/websitalk/index.d.ts.map +1 -0
  46. package/dist/websitalk/index.js +34 -0
  47. package/dist/websitalk/index.js.map +1 -0
  48. package/dist/websitalk/mongodb-client.d.ts +13 -0
  49. package/dist/websitalk/mongodb-client.d.ts.map +1 -0
  50. package/dist/websitalk/mongodb-client.js +56 -0
  51. package/dist/websitalk/mongodb-client.js.map +1 -0
  52. package/dist/websitalk/scans/index.d.ts +3 -0
  53. package/dist/websitalk/scans/index.d.ts.map +1 -0
  54. package/dist/websitalk/scans/index.js +19 -0
  55. package/dist/websitalk/scans/index.js.map +1 -0
  56. package/dist/websitalk/scans/scans.getters.d.ts +12 -0
  57. package/dist/websitalk/scans/scans.getters.d.ts.map +1 -0
  58. package/dist/websitalk/scans/scans.getters.js +74 -0
  59. package/dist/websitalk/scans/scans.getters.js.map +1 -0
  60. package/dist/websitalk/scans/scans.types.d.ts +45 -0
  61. package/dist/websitalk/scans/scans.types.d.ts.map +1 -0
  62. package/dist/{talkpilot/calls/calls.statistics.types.js → websitalk/scans/scans.types.js} +1 -1
  63. package/dist/websitalk/scans/scans.types.js.map +1 -0
  64. package/package.json +1 -1
  65. package/src/connection.ts +12 -0
  66. package/src/index.ts +9 -0
  67. package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +37 -1
  68. package/src/municipal/tickets/index.ts +1 -2
  69. package/src/municipal/tickets/tickets.getters.ts +140 -0
  70. package/src/municipal/tickets/tickets.types.ts +9 -14
  71. package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +111 -8
  72. package/src/talkpilot/calls/calls.types.ts +2 -4
  73. package/src/talkpilot/calls/dashboard/calls.dashboard.ts +197 -148
  74. package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +12 -25
  75. package/src/talkpilot/calls/index.ts +0 -3
  76. package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +0 -7
  77. package/src/test-utils/db-utils.ts +3 -1
  78. package/src/test-utils/factories/index.ts +1 -0
  79. package/src/test-utils/factories/websitalk/scans.ts +23 -0
  80. package/src/websitalk/index.ts +15 -0
  81. package/src/websitalk/mongodb-client.ts +61 -0
  82. package/src/websitalk/scans/__tests__/scans.spec.ts +218 -0
  83. package/src/websitalk/scans/index.ts +2 -0
  84. package/src/websitalk/scans/scans.getters.ts +113 -0
  85. package/src/websitalk/scans/scans.types.ts +53 -0
  86. package/dist/municipal/tickets/tickets.constants.d.ts +0 -7
  87. package/dist/municipal/tickets/tickets.constants.d.ts.map +0 -1
  88. package/dist/municipal/tickets/tickets.constants.js +0 -10
  89. package/dist/municipal/tickets/tickets.constants.js.map +0 -1
  90. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +0 -12
  91. package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +0 -1
  92. package/dist/municipal/tickets/tickets.deprecated.getters.js +0 -131
  93. package/dist/municipal/tickets/tickets.deprecated.getters.js.map +0 -1
  94. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +0 -45
  95. package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +0 -1
  96. package/dist/municipal/tickets/tickets.statistics.aggregation.js +0 -98
  97. package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +0 -1
  98. package/dist/municipal/tickets/tickets.statistics.dates.d.ts +0 -7
  99. package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +0 -1
  100. package/dist/municipal/tickets/tickets.statistics.dates.js +0 -40
  101. package/dist/municipal/tickets/tickets.statistics.dates.js.map +0 -1
  102. package/dist/municipal/tickets/tickets.statistics.getters.d.ts +0 -9
  103. package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +0 -1
  104. package/dist/municipal/tickets/tickets.statistics.getters.js +0 -55
  105. package/dist/municipal/tickets/tickets.statistics.getters.js.map +0 -1
  106. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +0 -53
  107. package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +0 -1
  108. package/dist/municipal/tickets/tickets.statistics.pipeline.js +0 -112
  109. package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +0 -1
  110. package/dist/municipal/tickets/tickets.statistics.utils.d.ts +0 -7
  111. package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +0 -1
  112. package/dist/municipal/tickets/tickets.statistics.utils.js +0 -40
  113. package/dist/municipal/tickets/tickets.statistics.utils.js.map +0 -1
  114. package/dist/talkpilot/calls/calls.constants.d.ts +0 -17
  115. package/dist/talkpilot/calls/calls.constants.d.ts.map +0 -1
  116. package/dist/talkpilot/calls/calls.constants.js +0 -20
  117. package/dist/talkpilot/calls/calls.constants.js.map +0 -1
  118. package/dist/talkpilot/calls/calls.statistics.getters.d.ts +0 -19
  119. package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +0 -1
  120. package/dist/talkpilot/calls/calls.statistics.getters.js +0 -375
  121. package/dist/talkpilot/calls/calls.statistics.getters.js.map +0 -1
  122. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +0 -12
  123. package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +0 -1
  124. package/dist/talkpilot/calls/calls.statistics.ticketScope.js +0 -37
  125. package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +0 -1
  126. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +0 -17
  127. package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +0 -1
  128. package/dist/talkpilot/calls/calls.statistics.tickets.js +0 -33
  129. package/dist/talkpilot/calls/calls.statistics.tickets.js.map +0 -1
  130. package/dist/talkpilot/calls/calls.statistics.types.d.ts +0 -39
  131. package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +0 -1
  132. package/dist/talkpilot/calls/calls.statistics.types.js.map +0 -1
  133. package/dist/utils/date.utils.d.ts +0 -49
  134. package/dist/utils/date.utils.d.ts.map +0 -1
  135. package/dist/utils/date.utils.js +0 -103
  136. package/dist/utils/date.utils.js.map +0 -1
  137. package/dist/utils/statistics.aggregation.d.ts +0 -20
  138. package/dist/utils/statistics.aggregation.d.ts.map +0 -1
  139. package/dist/utils/statistics.aggregation.js +0 -43
  140. package/dist/utils/statistics.aggregation.js.map +0 -1
  141. package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +0 -104
  142. package/src/municipal/tickets/tickets.constants.ts +0 -8
  143. package/src/municipal/tickets/tickets.statistics.aggregation.ts +0 -113
  144. package/src/municipal/tickets/tickets.statistics.getters.ts +0 -93
  145. package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +0 -281
  146. package/src/talkpilot/calls/calls.constants.ts +0 -20
  147. package/src/talkpilot/calls/calls.statistics.getters.ts +0 -525
  148. package/src/talkpilot/calls/calls.statistics.types.ts +0 -44
  149. package/src/utils/date.utils.ts +0 -116
@@ -25,8 +25,8 @@ describe("getDashboardStats", () => {
25
25
 
26
26
  const params = {
27
27
  clientId,
28
- startDate: "2026-05-01",
29
- endDate: "2026-05-31",
28
+ startDate: "2026-05-01T00:00:00.000Z",
29
+ endDate: "2026-05-31T23:59:59.999Z",
30
30
  };
31
31
 
32
32
  const result = await getDashboardStats(params);
@@ -35,12 +35,115 @@ describe("getDashboardStats", () => {
35
35
  expect(result.kpis.totalCalls).toBe(0);
36
36
  expect(result.kpis.completedCount).toBe(0);
37
37
  expect(result.kpis.avgDurationSeconds).toBe(0);
38
- expect(result.charts.volumeData).toEqual([]);
39
- expect(result.charts.heatmap).toEqual({});
40
- expect(result.charts.callLengthBuckets).toEqual({
41
- short: 0,
42
- medium: 0,
43
- long: 0,
38
+ expect(result.kpis.busyCount).toBe(0);
39
+ expect(result.kpis.timeSavedMinutes).toBe(0);
40
+ // ~30 day range -> daily buckets, gap-filled with zeros across the whole range
41
+ expect(result.charts.volumeGranularity).toBe("day");
42
+ expect(result.charts.volumeData.length).toBeGreaterThan(0);
43
+ expect(result.charts.volumeData.every((d) => d.completed === 0)).toBe(true);
44
+ expect(result.charts.volumeData[0].date).toBe("2026-05-01");
45
+ expect(
46
+ result.charts.volumeData[result.charts.volumeData.length - 1].date,
47
+ ).toBe("2026-05-31");
48
+ });
49
+
50
+ it("should pick a bucket size that keeps the volume chart readable across range lengths", async () => {
51
+ const clientId = "client-dash-456";
52
+
53
+ await getClientsConfigCollection().insertOne({
54
+ clientId,
55
+ timezone: "UTC",
56
+ products: {},
57
+ } as any);
58
+
59
+ const oneDay = await getDashboardStats({
60
+ clientId,
61
+ startDate: "2026-05-01T00:00:00.000Z",
62
+ endDate: "2026-05-01T23:59:59.999Z",
63
+ });
64
+ expect(oneDay.charts.volumeGranularity).toBe("hour");
65
+
66
+ const threeWeeks = await getDashboardStats({
67
+ clientId,
68
+ startDate: "2026-05-01T00:00:00.000Z",
69
+ endDate: "2026-05-21T23:59:59.999Z",
70
+ });
71
+ expect(threeWeeks.charts.volumeGranularity).toBe("day");
72
+
73
+ const fourMonths = await getDashboardStats({
74
+ clientId,
75
+ startDate: "2026-01-01T00:00:00.000Z",
76
+ endDate: "2026-04-30T23:59:59.999Z",
77
+ });
78
+ expect(fourMonths.charts.volumeGranularity).toBe("week");
79
+
80
+ const eighteenMonths = await getDashboardStats({
81
+ clientId,
82
+ startDate: "2025-01-01T00:00:00.000Z",
83
+ endDate: "2026-06-30T23:59:59.999Z",
84
+ });
85
+ expect(eighteenMonths.charts.volumeGranularity).toBe("month");
86
+
87
+ const threeYears = await getDashboardStats({
88
+ clientId,
89
+ startDate: "2023-01-01T00:00:00.000Z",
90
+ endDate: "2026-01-01T23:59:59.999Z",
91
+ });
92
+ expect(threeYears.charts.volumeGranularity).toBe("quarter");
93
+
94
+ const fiveYears = await getDashboardStats({
95
+ clientId,
96
+ startDate: "2021-01-01T00:00:00.000Z",
97
+ endDate: "2026-01-01T23:59:59.999Z",
98
+ });
99
+ expect(fiveYears.charts.volumeGranularity).toBe("year");
100
+ });
101
+
102
+ it("should fill gaps with zero-count buckets so the chart has no missing points", async () => {
103
+ const clientId = "client-dash-789";
104
+
105
+ await getClientsConfigCollection().insertOne({
106
+ clientId,
107
+ timezone: "UTC",
108
+ products: {},
109
+ } as any);
110
+
111
+ // Sparse data: only two of the day's hours have calls.
112
+ await getCallsCollection().insertMany([
113
+ {
114
+ clientId,
115
+ status: "completed",
116
+ callLength: 60,
117
+ createdAt: new Date("2026-05-01T09:15:00Z"),
118
+ },
119
+ {
120
+ clientId,
121
+ status: "completed",
122
+ callLength: 90,
123
+ createdAt: new Date("2026-05-01T18:45:00Z"),
124
+ },
125
+ ] as any);
126
+
127
+ const result = await getDashboardStats({
128
+ clientId,
129
+ startDate: "2026-05-01T00:00:00.000Z",
130
+ endDate: "2026-05-01T23:59:59.999Z",
44
131
  });
132
+
133
+ expect(result.charts.volumeGranularity).toBe("hour");
134
+ // A full day of hourly buckets, with no gaps, sorted chronologically.
135
+ expect(result.charts.volumeData).toHaveLength(24);
136
+ expect(result.charts.volumeData[0].date).toBe("2026-05-01T00:00");
137
+ expect(result.charts.volumeData[23].date).toBe("2026-05-01T23:00");
138
+
139
+ const populated = result.charts.volumeData.filter((d) => d.completed > 0);
140
+ expect(populated).toHaveLength(2);
141
+ expect(populated.map((d) => d.date)).toEqual([
142
+ "2026-05-01T09:00",
143
+ "2026-05-01T18:00",
144
+ ]);
145
+
146
+ const empty = result.charts.volumeData.filter((d) => d.completed === 0);
147
+ expect(empty).toHaveLength(22);
45
148
  });
46
149
  });
@@ -2,10 +2,8 @@ import { ObjectId, Sort, WithId } from "mongodb";
2
2
  import { TranscriptionSegment } from "../results";
3
3
  import { LeadProperty } from "../leads";
4
4
 
5
- import {
6
- CONFERENCE_ROLE_CUSTOMER,
7
- CONFERENCE_ROLE_SUPERVISOR,
8
- } from "./calls.constants";
5
+ export const CONFERENCE_ROLE_CUSTOMER = "customer" as const;
6
+ export const CONFERENCE_ROLE_SUPERVISOR = "supervisor" as const;
9
7
 
10
8
  export type ConferenceRole =
11
9
  | typeof CONFERENCE_ROLE_CUSTOMER
@@ -3,173 +3,237 @@ 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";
6
8
  import {
7
9
  DashboardAggregationResult,
8
10
  DashboardDailyTrendMetric,
9
- DashboardHeatmapMetric,
10
11
  DashboardReportQuery,
11
12
  DashboardReportResponse,
12
13
  DashboardSummaryMetrics,
13
- RawDailyAggregationResult,
14
- RawHourlyAggregationResult,
14
+ DashboardVolumeGranularity,
15
+ } from "./calls.dashboard.types";
16
+
17
+ export type {
18
+ DashboardVolumeGranularity,
19
+ DashboardReportQuery,
20
+ DashboardReportResponse,
15
21
  } from "./calls.dashboard.types";
16
- import { CallLengthThresholds } from "src/utils/shared.types";
17
22
 
18
23
  const DEFAULT_KPI_DATA = {
19
- totalCalls: 0,
20
- totalDuration: 0,
21
24
  completedCount: 0,
22
- failedCount: 0,
23
- noAnswerCount: 0,
24
25
  busyCount: 0,
26
+ answeredDuration: 0,
25
27
  };
26
28
 
27
29
  dayjs.extend(utc);
28
30
  dayjs.extend(timezonePlugin);
31
+ dayjs.extend(isoWeek);
32
+ dayjs.extend(quarterOfYear);
29
33
 
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
- ];
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
+ }
52
60
  }
53
61
 
54
- function buildDailyDataPipeline(timezone: string) {
55
- return [
56
- {
57
- $group: {
58
- _id: {
59
- $dateToString: {
60
- format: "%Y-%m-%d",
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
+
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";
109
+ }
110
+
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: {
61
129
  date: "$createdAt",
62
- timezone: timezone,
130
+ unit: "week",
131
+ timezone,
132
+ startOfWeek: "monday",
63
133
  },
64
134
  },
65
- count: { $sum: 1 },
66
- completed: {
67
- $sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
68
- },
135
+ timezone,
69
136
  },
70
- },
71
- { $sort: { _id: 1 } },
72
- ];
73
- }
137
+ };
138
+ }
74
139
 
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
- },
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
+ ],
86
163
  },
87
- hour: { $hour: { date: "$createdAt", timezone: timezone } },
88
164
  },
89
- count: { $sum: 1 },
90
- },
165
+ ],
166
+ };
167
+ }
168
+
169
+ return {
170
+ $dateToString: {
171
+ format: VOLUME_DATE_FORMATS[granularity],
172
+ date: "$createdAt",
173
+ timezone,
91
174
  },
92
- ];
175
+ };
93
176
  }
94
177
 
95
- export function buildCallLengthBucketsPipeline(
96
- thresholds: CallLengthThresholds,
97
- ) {
98
- const { shortThreshold, mediumThreshold } = thresholds;
99
-
178
+ function buildKpisPipeline() {
100
179
  return [
101
180
  {
102
181
  $group: {
103
182
  _id: null,
104
- short: {
105
- $sum: { $cond: [{ $lt: ["$callLength", shortThreshold] }, 1, 0] },
183
+ completedCount: {
184
+ $sum: { $cond: [{ $eq: ["$status", "completed"] }, 1, 0] },
106
185
  },
107
- medium: {
186
+ busyCount: {
187
+ $sum: { $cond: [{ $eq: ["$status", "busy"] }, 1, 0] },
188
+ },
189
+ answeredDuration: {
108
190
  $sum: {
109
191
  $cond: [
110
- {
111
- $and: [
112
- { $gte: ["$callLength", shortThreshold] },
113
- { $lte: ["$callLength", mediumThreshold] },
114
- ],
115
- },
116
- 1,
192
+ { $in: ["$status", ["completed", "busy"]] },
193
+ "$callLength",
117
194
  0,
118
195
  ],
119
196
  },
120
197
  },
121
- long: {
122
- $sum: { $cond: [{ $gt: ["$callLength", mediumThreshold] }, 1, 0] },
123
- },
124
198
  },
125
199
  },
126
200
  ];
127
201
  }
128
202
 
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;
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
+ ];
157
218
  }
158
219
 
159
220
  export async function getDashboardStats(
160
221
  params: DashboardReportQuery,
161
222
  ): Promise<DashboardReportResponse> {
162
- const { clientId, startDate, endDate } = params;
223
+ const {
224
+ clientId,
225
+ startDate,
226
+ endDate,
227
+ granularity: requestedGranularity,
228
+ } = params;
163
229
  const clientConfig = await getClientConfig(clientId);
164
230
  const timezone = clientConfig?.timezone ?? "UTC";
165
231
 
166
- const startDateObj = dayjs.tz(startDate, timezone).startOf("day").toDate();
167
- const endDateObj = dayjs.tz(endDate, timezone).endOf("day").toDate();
232
+ const startDateObj = dayjs(startDate).tz(timezone).startOf("day").toDate();
233
+ const endDateObj = dayjs(endDate).tz(timezone).endOf("day").toDate();
168
234
 
169
- const thresholds: CallLengthThresholds = {
170
- shortThreshold: clientConfig?.callLengthThresholds?.shortThreshold ?? 60,
171
- mediumThreshold: clientConfig?.callLengthThresholds?.mediumThreshold ?? 180,
172
- };
235
+ const granularity =
236
+ requestedGranularity ?? resolveVolumeGranularity(startDateObj, endDateObj);
173
237
 
174
238
  const pipeline = [
175
239
  {
@@ -181,9 +245,7 @@ export async function getDashboardStats(
181
245
  {
182
246
  $facet: {
183
247
  kpis: buildKpisPipeline(),
184
- dailyData: buildDailyDataPipeline(timezone),
185
- hourlyData: buildHourlyDataPipeline(timezone),
186
- callLengthBuckets: buildCallLengthBucketsPipeline(thresholds),
248
+ volumeData: buildVolumeDataPipeline(timezone, granularity),
187
249
  },
188
250
  },
189
251
  ];
@@ -194,49 +256,36 @@ export async function getDashboardStats(
194
256
  .toArray();
195
257
 
196
258
  const kpiData = aggregatedResult?.kpis?.[0] ?? DEFAULT_KPI_DATA;
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
- };
259
+ const volumeDataRaw = aggregatedResult?.volumeData ?? [];
260
+
261
+ const answeredCount = kpiData.completedCount + kpiData.busyCount;
262
+ const answeredDuration = kpiData.answeredDuration ?? 0;
204
263
 
205
264
  const kpis: DashboardSummaryMetrics = {
206
- totalCalls: kpiData.totalCalls,
265
+ totalCalls: answeredCount,
207
266
  avgDurationSeconds:
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,
267
+ answeredCount > 0 ? Math.round(answeredDuration / answeredCount) : 0,
268
+ timeSavedMinutes: Math.round(answeredDuration / 60),
216
269
  completedCount: kpiData.completedCount,
217
- failedCount: kpiData.failedCount,
218
- noAnswerCount: kpiData.noAnswerCount,
219
270
  busyCount: kpiData.busyCount,
220
271
  };
221
272
 
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);
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
+ );
229
283
 
230
284
  const response: DashboardReportResponse = {
231
285
  kpis,
232
286
  charts: {
233
287
  volumeData,
234
- heatmap,
235
- callLengthBuckets: {
236
- short: callLengthRaw.short,
237
- medium: callLengthRaw.medium,
238
- long: callLengthRaw.long,
239
- },
288
+ volumeGranularity: granularity,
240
289
  },
241
290
  };
242
291
  return response;
@@ -5,37 +5,35 @@ export type DashboardHeatmapMetric = {
5
5
 
6
6
  export type DashboardDailyTrendMetric = {
7
7
  date: string;
8
- count: number;
9
8
  completed: number;
10
9
  };
11
10
 
12
- export type DashboardDurationSegments = {
13
- short: number;
14
- medium: number;
15
- long: number;
16
- };
11
+ export type DashboardVolumeGranularity =
12
+ | "hour"
13
+ | "day"
14
+ | "week"
15
+ | "month"
16
+ | "quarter"
17
+ | "year";
17
18
 
18
19
  export type DashboardSummaryMetrics = {
19
20
  totalCalls: number;
20
21
  avgDurationSeconds: number;
21
22
  timeSavedMinutes: number;
22
- successRate: number;
23
23
  completedCount: number;
24
- failedCount: number;
25
- noAnswerCount: number;
26
24
  busyCount: number;
27
25
  };
28
26
 
29
27
  export type DashboardVisualData = {
30
28
  volumeData: DashboardDailyTrendMetric[];
31
- heatmap: Record<string, DashboardHeatmapMetric[]>;
32
- callLengthBuckets: DashboardDurationSegments;
29
+ volumeGranularity: DashboardVolumeGranularity;
33
30
  };
34
31
 
35
32
  export type DashboardReportQuery = {
36
33
  clientId: string;
37
34
  startDate: string;
38
35
  endDate: string;
36
+ granularity?: DashboardVolumeGranularity;
39
37
  };
40
38
 
41
39
  export type DashboardReportResponse = {
@@ -43,28 +41,17 @@ export type DashboardReportResponse = {
43
41
  charts: DashboardVisualData;
44
42
  };
45
43
  type RawKpiResult = {
46
- totalCalls: number;
47
- totalDuration: number | null;
48
44
  completedCount: number;
49
- failedCount: number;
50
- noAnswerCount: number;
51
45
  busyCount: number;
46
+ answeredDuration: number | null;
52
47
  };
53
48
 
54
- export type RawDailyAggregationResult = {
49
+ export type RawVolumeAggregationResult = {
55
50
  _id: string;
56
- count: number;
57
51
  completed: number;
58
52
  };
59
53
 
60
- export type RawHourlyAggregationResult = {
61
- _id: { day: string; hour: number };
62
- count: number;
63
- };
64
-
65
54
  export interface DashboardAggregationResult {
66
55
  kpis: RawKpiResult[];
67
- dailyData: RawDailyAggregationResult[];
68
- hourlyData: RawHourlyAggregationResult[];
69
- callLengthBuckets: DashboardDurationSegments[];
56
+ volumeData: RawVolumeAggregationResult[];
70
57
  }
@@ -1,6 +1,3 @@
1
- export * from "./calls.constants";
2
1
  export * from "./calls.types";
3
2
  export * from "./calls.getters";
4
- export * from "./calls.statistics.types";
5
- export * from "./calls.statistics.getters";
6
3
  export * from "./dashboard/calls.dashboard";
@@ -36,10 +36,6 @@ 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
- },
43
39
  },
44
40
  },
45
41
  });
@@ -57,9 +53,6 @@ describe("db.clientsConfig", () => {
57
53
  timezone: "Asia/Jerusalem",
58
54
  language: "he",
59
55
  defaultBaseUrl: "https://acme.example",
60
- scheduler: {
61
- type: "cron",
62
- },
63
56
  },
64
57
  },
65
58
  });