@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.
- package/README.md +0 -30
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +10 -0
- package/dist/connection.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/municipal/tickets/index.d.ts +1 -2
- package/dist/municipal/tickets/index.d.ts.map +1 -1
- package/dist/municipal/tickets/index.js +0 -1
- package/dist/municipal/tickets/index.js.map +1 -1
- package/dist/municipal/tickets/tickets.getters.d.ts +11 -0
- package/dist/municipal/tickets/tickets.getters.d.ts.map +1 -1
- package/dist/municipal/tickets/tickets.getters.js +128 -0
- package/dist/municipal/tickets/tickets.getters.js.map +1 -1
- package/dist/municipal/tickets/tickets.types.d.ts +5 -10
- package/dist/municipal/tickets/tickets.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.types.d.ts +2 -1
- package/dist/talkpilot/calls/calls.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/calls.types.js +3 -0
- package/dist/talkpilot/calls/calls.types.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts +1 -33
- package/dist/talkpilot/calls/dashboard/calls.dashboard.d.ts.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js +146 -131
- package/dist/talkpilot/calls/dashboard/calls.dashboard.js.map +1 -1
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts +6 -27
- package/dist/talkpilot/calls/dashboard/calls.dashboard.types.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.d.ts +0 -3
- package/dist/talkpilot/calls/index.d.ts.map +1 -1
- package/dist/talkpilot/calls/index.js +0 -3
- package/dist/talkpilot/calls/index.js.map +1 -1
- package/dist/test-utils/db-utils.d.ts.map +1 -1
- package/dist/test-utils/db-utils.js +2 -0
- package/dist/test-utils/db-utils.js.map +1 -1
- package/dist/test-utils/factories/index.d.ts +1 -0
- package/dist/test-utils/factories/index.d.ts.map +1 -1
- package/dist/test-utils/factories/index.js +1 -0
- package/dist/test-utils/factories/index.js.map +1 -1
- package/dist/test-utils/factories/websitalk/scans.d.ts +5 -0
- package/dist/test-utils/factories/websitalk/scans.d.ts.map +1 -0
- package/dist/test-utils/factories/websitalk/scans.js +25 -0
- package/dist/test-utils/factories/websitalk/scans.js.map +1 -0
- package/dist/websitalk/index.d.ts +7 -0
- package/dist/websitalk/index.d.ts.map +1 -0
- package/dist/websitalk/index.js +34 -0
- package/dist/websitalk/index.js.map +1 -0
- package/dist/websitalk/mongodb-client.d.ts +13 -0
- package/dist/websitalk/mongodb-client.d.ts.map +1 -0
- package/dist/websitalk/mongodb-client.js +56 -0
- package/dist/websitalk/mongodb-client.js.map +1 -0
- package/dist/websitalk/scans/index.d.ts +3 -0
- package/dist/websitalk/scans/index.d.ts.map +1 -0
- package/dist/websitalk/scans/index.js +19 -0
- package/dist/websitalk/scans/index.js.map +1 -0
- package/dist/websitalk/scans/scans.getters.d.ts +12 -0
- package/dist/websitalk/scans/scans.getters.d.ts.map +1 -0
- package/dist/websitalk/scans/scans.getters.js +74 -0
- package/dist/websitalk/scans/scans.getters.js.map +1 -0
- package/dist/websitalk/scans/scans.types.d.ts +45 -0
- package/dist/websitalk/scans/scans.types.d.ts.map +1 -0
- package/dist/{talkpilot/calls/calls.statistics.types.js → websitalk/scans/scans.types.js} +1 -1
- package/dist/websitalk/scans/scans.types.js.map +1 -0
- package/package.json +1 -1
- package/src/connection.ts +12 -0
- package/src/index.ts +9 -0
- package/src/municipal/tickets/__tests__/tickets.getters.spec.ts +37 -1
- package/src/municipal/tickets/index.ts +1 -2
- package/src/municipal/tickets/tickets.getters.ts +140 -0
- package/src/municipal/tickets/tickets.types.ts +9 -14
- package/src/talkpilot/calls/__tests__/calls.dashboard.spec.ts +111 -8
- package/src/talkpilot/calls/calls.types.ts +2 -4
- package/src/talkpilot/calls/dashboard/calls.dashboard.ts +197 -148
- package/src/talkpilot/calls/dashboard/calls.dashboard.types.ts +12 -25
- package/src/talkpilot/calls/index.ts +0 -3
- package/src/talkpilot/clientsConfig/__tests__/clientsConfig.spec.ts +0 -7
- package/src/test-utils/db-utils.ts +3 -1
- package/src/test-utils/factories/index.ts +1 -0
- package/src/test-utils/factories/websitalk/scans.ts +23 -0
- package/src/websitalk/index.ts +15 -0
- package/src/websitalk/mongodb-client.ts +61 -0
- package/src/websitalk/scans/__tests__/scans.spec.ts +218 -0
- package/src/websitalk/scans/index.ts +2 -0
- package/src/websitalk/scans/scans.getters.ts +113 -0
- package/src/websitalk/scans/scans.types.ts +53 -0
- package/dist/municipal/tickets/tickets.constants.d.ts +0 -7
- package/dist/municipal/tickets/tickets.constants.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.constants.js +0 -10
- package/dist/municipal/tickets/tickets.constants.js.map +0 -1
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts +0 -12
- package/dist/municipal/tickets/tickets.deprecated.getters.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.deprecated.getters.js +0 -131
- package/dist/municipal/tickets/tickets.deprecated.getters.js.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts +0 -45
- package/dist/municipal/tickets/tickets.statistics.aggregation.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.aggregation.js +0 -98
- package/dist/municipal/tickets/tickets.statistics.aggregation.js.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts +0 -7
- package/dist/municipal/tickets/tickets.statistics.dates.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.dates.js +0 -40
- package/dist/municipal/tickets/tickets.statistics.dates.js.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts +0 -9
- package/dist/municipal/tickets/tickets.statistics.getters.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.getters.js +0 -55
- package/dist/municipal/tickets/tickets.statistics.getters.js.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts +0 -53
- package/dist/municipal/tickets/tickets.statistics.pipeline.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.pipeline.js +0 -112
- package/dist/municipal/tickets/tickets.statistics.pipeline.js.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts +0 -7
- package/dist/municipal/tickets/tickets.statistics.utils.d.ts.map +0 -1
- package/dist/municipal/tickets/tickets.statistics.utils.js +0 -40
- package/dist/municipal/tickets/tickets.statistics.utils.js.map +0 -1
- package/dist/talkpilot/calls/calls.constants.d.ts +0 -17
- package/dist/talkpilot/calls/calls.constants.d.ts.map +0 -1
- package/dist/talkpilot/calls/calls.constants.js +0 -20
- package/dist/talkpilot/calls/calls.constants.js.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts +0 -19
- package/dist/talkpilot/calls/calls.statistics.getters.d.ts.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.getters.js +0 -375
- package/dist/talkpilot/calls/calls.statistics.getters.js.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts +0 -12
- package/dist/talkpilot/calls/calls.statistics.ticketScope.d.ts.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js +0 -37
- package/dist/talkpilot/calls/calls.statistics.ticketScope.js.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts +0 -17
- package/dist/talkpilot/calls/calls.statistics.tickets.d.ts.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.tickets.js +0 -33
- package/dist/talkpilot/calls/calls.statistics.tickets.js.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.types.d.ts +0 -39
- package/dist/talkpilot/calls/calls.statistics.types.d.ts.map +0 -1
- package/dist/talkpilot/calls/calls.statistics.types.js.map +0 -1
- package/dist/utils/date.utils.d.ts +0 -49
- package/dist/utils/date.utils.d.ts.map +0 -1
- package/dist/utils/date.utils.js +0 -103
- package/dist/utils/date.utils.js.map +0 -1
- package/dist/utils/statistics.aggregation.d.ts +0 -20
- package/dist/utils/statistics.aggregation.d.ts.map +0 -1
- package/dist/utils/statistics.aggregation.js +0 -43
- package/dist/utils/statistics.aggregation.js.map +0 -1
- package/src/municipal/tickets/__tests__/tickets.statistics.spec.ts +0 -104
- package/src/municipal/tickets/tickets.constants.ts +0 -8
- package/src/municipal/tickets/tickets.statistics.aggregation.ts +0 -113
- package/src/municipal/tickets/tickets.statistics.getters.ts +0 -93
- package/src/talkpilot/calls/__tests__/calls.statistics.spec.ts +0 -281
- package/src/talkpilot/calls/calls.constants.ts +0 -20
- package/src/talkpilot/calls/calls.statistics.getters.ts +0 -525
- package/src/talkpilot/calls/calls.statistics.types.ts +0 -44
- package/src/utils/date.utils.ts +0 -116
|
@@ -1,525 +0,0 @@
|
|
|
1
|
-
import { getCallsCollection } from "./calls.getters";
|
|
2
|
-
import {
|
|
3
|
-
HOURS_PER_DAY,
|
|
4
|
-
STATS_MAX_TIME_MS,
|
|
5
|
-
TREND_MONTH_BUCKET_MIN_MONTHS,
|
|
6
|
-
TREND_WEEK_BUCKET_MIN_MONTHS,
|
|
7
|
-
} from "./calls.constants";
|
|
8
|
-
import {
|
|
9
|
-
addDaysToDateStr,
|
|
10
|
-
DAY_MS,
|
|
11
|
-
dateAtNoonUtc,
|
|
12
|
-
formatYmdInTz,
|
|
13
|
-
getWeekdayInTz,
|
|
14
|
-
getYmdInTz,
|
|
15
|
-
incrementYmd,
|
|
16
|
-
parseYmd,
|
|
17
|
-
toUtcDate,
|
|
18
|
-
ymdToStr,
|
|
19
|
-
} from "../../utils/date.utils";
|
|
20
|
-
import { ticketStatsDateScopeFromYmd } from "../../municipal/tickets/tickets.statistics.getters";
|
|
21
|
-
import type {
|
|
22
|
-
CallRoutingFilter,
|
|
23
|
-
CallsHourlyBucket,
|
|
24
|
-
CallsRoutingAggregation,
|
|
25
|
-
CallsStatsFilter,
|
|
26
|
-
CallsSummaryAggregation,
|
|
27
|
-
CallsTrendBucket,
|
|
28
|
-
ResolvedTimeBucket,
|
|
29
|
-
} from "./calls.statistics.types";
|
|
30
|
-
|
|
31
|
-
const emptySummary = (): CallsSummaryAggregation => ({
|
|
32
|
-
totalCalls: 0,
|
|
33
|
-
avgDurationSeconds: 0,
|
|
34
|
-
openTickets: 0,
|
|
35
|
-
draftTickets: 0,
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
const emptyRouting = (): CallsRoutingAggregation => ({
|
|
39
|
-
aiHandled: 0,
|
|
40
|
-
transferred: 0,
|
|
41
|
-
hijacked: 0,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
const isTransferred = { $eq: ["$redirectedCall", true] };
|
|
45
|
-
|
|
46
|
-
const isHijacked = {
|
|
47
|
-
$and: [
|
|
48
|
-
{ $eq: ["$isConferenceCall", true] },
|
|
49
|
-
{ $ne: ["$redirectedCall", true] },
|
|
50
|
-
],
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const countIf = (condition: Record<string, unknown>) => ({
|
|
54
|
-
$sum: { $cond: [condition, 1, 0] },
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const needsTicketStatusFilter = (filters?: CallsStatsFilter["ticketStatusFilters"]) => {
|
|
58
|
-
if (!filters?.length) return false;
|
|
59
|
-
const hasOpened = filters.includes("opened");
|
|
60
|
-
const hasNotOpened = filters.includes("not_opened");
|
|
61
|
-
return hasOpened !== hasNotOpened;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
const routingMatch = (routing?: CallRoutingFilter[]) => {
|
|
65
|
-
if (!routing?.length) return null;
|
|
66
|
-
|
|
67
|
-
const clauses: Record<string, unknown>[] = [];
|
|
68
|
-
if (routing.includes("transferred")) clauses.push({ redirectedCall: true });
|
|
69
|
-
if (routing.includes("hijacked")) {
|
|
70
|
-
clauses.push({ isConferenceCall: true, redirectedCall: { $ne: true } });
|
|
71
|
-
}
|
|
72
|
-
if (routing.includes("ai_only")) {
|
|
73
|
-
clauses.push({
|
|
74
|
-
redirectedCall: { $ne: true },
|
|
75
|
-
$or: [
|
|
76
|
-
{ isConferenceCall: { $ne: true } },
|
|
77
|
-
{ isConferenceCall: { $exists: false } },
|
|
78
|
-
],
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
return clauses.length ? { $or: clauses } : null;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const ticketStatusMatch = (filter: CallsStatsFilter) => {
|
|
86
|
-
if (!needsTicketStatusFilter(filter.ticketStatusFilters)) return null;
|
|
87
|
-
|
|
88
|
-
const sids = filter.callSidsWithTickets ?? [];
|
|
89
|
-
const openedOnly = filter.ticketStatusFilters?.includes("opened");
|
|
90
|
-
return { callSid: openedOnly ? { $in: sids } : { $nin: sids } };
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
const hourMatch = (filter: CallsStatsFilter): Record<string, unknown> | null => {
|
|
94
|
-
const { hourFrom, hourTo } = filter;
|
|
95
|
-
if (hourFrom == null && hourTo == null) return null;
|
|
96
|
-
|
|
97
|
-
const hour: Record<string, unknown> = {};
|
|
98
|
-
if (hourFrom != null) hour.$gte = hourFrom;
|
|
99
|
-
if (hourTo != null) hour.$lte = hourTo;
|
|
100
|
-
return { hour };
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
const createdAtMatchForFilter = (
|
|
104
|
-
filter: CallsStatsFilter,
|
|
105
|
-
): { createdAt: { $gte: Date; $lte: Date } } => {
|
|
106
|
-
const bounds = ticketStatsDateScopeFromYmd(
|
|
107
|
-
filter.startStr,
|
|
108
|
-
filter.endStr,
|
|
109
|
-
filter.timezone,
|
|
110
|
-
);
|
|
111
|
-
return { createdAt: { $gte: bounds.from!, $lte: bounds.to! } };
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
const buildCallsMatchStages = (filter: CallsStatsFilter): Record<string, unknown>[] => {
|
|
115
|
-
const hourRange = hourMatch(filter);
|
|
116
|
-
const routing = routingMatch(filter.callRouting);
|
|
117
|
-
const ticketMatch = ticketStatusMatch(filter);
|
|
118
|
-
|
|
119
|
-
return [
|
|
120
|
-
{
|
|
121
|
-
$match: {
|
|
122
|
-
clientId: filter.clientId,
|
|
123
|
-
isIncomingCall: true,
|
|
124
|
-
...createdAtMatchForFilter(filter),
|
|
125
|
-
},
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
$addFields: {
|
|
129
|
-
dateLocal: {
|
|
130
|
-
$dateToString: {
|
|
131
|
-
format: "%Y-%m-%d",
|
|
132
|
-
date: "$createdAt",
|
|
133
|
-
timezone: filter.timezone,
|
|
134
|
-
},
|
|
135
|
-
},
|
|
136
|
-
hour: { $hour: { date: "$createdAt", timezone: filter.timezone } },
|
|
137
|
-
},
|
|
138
|
-
},
|
|
139
|
-
{ $match: { dateLocal: { $gte: filter.startStr, $lte: filter.endStr } } },
|
|
140
|
-
...(hourRange ? [{ $match: hourRange }] : []),
|
|
141
|
-
...(routing ? [{ $match: routing }] : []),
|
|
142
|
-
...(ticketMatch ? [{ $match: ticketMatch }] : []),
|
|
143
|
-
];
|
|
144
|
-
};
|
|
145
|
-
|
|
146
|
-
const aggregateCalls = (
|
|
147
|
-
filter: CallsStatsFilter,
|
|
148
|
-
...tail: Record<string, unknown>[]
|
|
149
|
-
) =>
|
|
150
|
-
getCallsCollection().aggregate([...buildCallsMatchStages(filter), ...tail], {
|
|
151
|
-
maxTimeMS: STATS_MAX_TIME_MS,
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
const trendBucketExpr = (bucket: ResolvedTimeBucket, timezone: string) =>
|
|
155
|
-
bucket === "day"
|
|
156
|
-
? { bucket: "$dateLocal" }
|
|
157
|
-
: {
|
|
158
|
-
bucket: {
|
|
159
|
-
$dateToString: {
|
|
160
|
-
format: "%Y-%m-%d",
|
|
161
|
-
date: { $dateTrunc: { date: "$createdAt", unit: bucket, timezone } },
|
|
162
|
-
timezone,
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
const mapTrendRow = (row: {
|
|
168
|
-
_id: unknown;
|
|
169
|
-
totalCalls: number;
|
|
170
|
-
openTickets: number;
|
|
171
|
-
}): CallsTrendBucket => ({
|
|
172
|
-
bucket: String(row._id),
|
|
173
|
-
totalCalls: row.totalCalls,
|
|
174
|
-
openTickets: row.openTickets,
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
export const rangeDurationDays = (startStr: string, endStr: string): number => {
|
|
178
|
-
const start = toUtcDate(startStr).getTime();
|
|
179
|
-
const end = toUtcDate(endStr).getTime();
|
|
180
|
-
return Math.floor((end - start) / DAY_MS) + 1;
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Number of distinct calendar months the [startStr, endStr] range spans, inclusive.
|
|
185
|
-
* Counts month boundaries rather than 30-day periods: e.g. Jan 31 -> Feb 1 returns 2.
|
|
186
|
-
*/
|
|
187
|
-
export const rangeDurationMonths = (startStr: string, endStr: string): number => {
|
|
188
|
-
const [sy, sm] = parseYmd(startStr);
|
|
189
|
-
const [ey, em] = parseYmd(endStr);
|
|
190
|
-
return (ey - sy) * 12 + (em - sm) + 1;
|
|
191
|
-
};
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Chooses how the client calls-over-time bar chart groups each column:
|
|
195
|
-
* one bar per day, week, or calendar month. Short ranges stay daily; longer
|
|
196
|
-
* ones switch to weekly or monthly so the chart does not show hundreds of bars.
|
|
197
|
-
*/
|
|
198
|
-
export const resolveTimeBucketFromRange = (
|
|
199
|
-
startStr: string,
|
|
200
|
-
endStr: string,
|
|
201
|
-
): ResolvedTimeBucket => {
|
|
202
|
-
const months = rangeDurationMonths(startStr, endStr);
|
|
203
|
-
if (months > TREND_MONTH_BUCKET_MIN_MONTHS) return "month";
|
|
204
|
-
if (months > TREND_WEEK_BUCKET_MIN_MONTHS) return "week";
|
|
205
|
-
return "day";
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const weekBucketKeyForDate = (dateStr: string, timezone: string): string => {
|
|
209
|
-
const anchor = dateAtNoonUtc(dateStr);
|
|
210
|
-
const weekStart = new Date(anchor.getTime() - getWeekdayInTz(anchor, timezone) * DAY_MS);
|
|
211
|
-
return formatYmdInTz(weekStart, timezone);
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
const formatWeekBucketRange = (
|
|
215
|
-
weekStartStr: string,
|
|
216
|
-
clipStartStr: string,
|
|
217
|
-
clipEndStr: string,
|
|
218
|
-
): string => {
|
|
219
|
-
const weekEndStr = addDaysToDateStr(weekStartStr, 6);
|
|
220
|
-
const start = weekStartStr < clipStartStr ? clipStartStr : weekStartStr;
|
|
221
|
-
const end = weekEndStr > clipEndStr ? clipEndStr : weekEndStr;
|
|
222
|
-
return `${start}/${end}`;
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
const monthBucketKey = (y: number, m: number): string =>
|
|
226
|
-
`${y}-${String(m).padStart(2, "0")}-01`;
|
|
227
|
-
|
|
228
|
-
const emptyTrendBucket = (bucket: string): CallsTrendBucket => ({
|
|
229
|
-
bucket,
|
|
230
|
-
totalCalls: 0,
|
|
231
|
-
openTickets: 0,
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
const indexTrendRowsByBucket = (rows: CallsTrendBucket[]) =>
|
|
235
|
-
new Map(rows.map((row) => [row.bucket, row]));
|
|
236
|
-
|
|
237
|
-
const fillDailyTrendBuckets = (
|
|
238
|
-
rows: CallsTrendBucket[],
|
|
239
|
-
startStr: string,
|
|
240
|
-
endStr: string,
|
|
241
|
-
): CallsTrendBucket[] => {
|
|
242
|
-
const byBucket = indexTrendRowsByBucket(rows);
|
|
243
|
-
const filled: CallsTrendBucket[] = [];
|
|
244
|
-
|
|
245
|
-
for (let cursor = startStr; cursor <= endStr; cursor = ymdToStr(...incrementYmd(...parseYmd(cursor)))) {
|
|
246
|
-
filled.push(byBucket.get(cursor) ?? emptyTrendBucket(cursor));
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
return filled;
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
const fillMonthlyTrendBuckets = (
|
|
253
|
-
rows: CallsTrendBucket[],
|
|
254
|
-
startStr: string,
|
|
255
|
-
endStr: string,
|
|
256
|
-
timezone: string,
|
|
257
|
-
): CallsTrendBucket[] => {
|
|
258
|
-
const byBucket = indexTrendRowsByBucket(rows);
|
|
259
|
-
const startParts = getYmdInTz(dateAtNoonUtc(startStr), timezone);
|
|
260
|
-
const endParts = getYmdInTz(dateAtNoonUtc(endStr), timezone);
|
|
261
|
-
|
|
262
|
-
const filled: CallsTrendBucket[] = [];
|
|
263
|
-
let { y, m } = startParts;
|
|
264
|
-
|
|
265
|
-
while (y < endParts.y || (y === endParts.y && m <= endParts.m)) {
|
|
266
|
-
const bucket = monthBucketKey(y, m);
|
|
267
|
-
filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
|
|
268
|
-
m += 1;
|
|
269
|
-
if (m > 12) {
|
|
270
|
-
m = 1;
|
|
271
|
-
y += 1;
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
return filled;
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
const fillWeeklyTrendBuckets = (
|
|
279
|
-
rows: CallsTrendBucket[],
|
|
280
|
-
startStr: string,
|
|
281
|
-
endStr: string,
|
|
282
|
-
timezone: string,
|
|
283
|
-
): CallsTrendBucket[] => {
|
|
284
|
-
const byBucket = indexTrendRowsByBucket(rows);
|
|
285
|
-
const filled: CallsTrendBucket[] = [];
|
|
286
|
-
const seen = new Set<string>();
|
|
287
|
-
|
|
288
|
-
for (let i = 0, days = rangeDurationDays(startStr, endStr); i < days; i += 1) {
|
|
289
|
-
const bucket = formatWeekBucketRange(
|
|
290
|
-
weekBucketKeyForDate(addDaysToDateStr(startStr, i), timezone),
|
|
291
|
-
startStr,
|
|
292
|
-
endStr,
|
|
293
|
-
);
|
|
294
|
-
if (seen.has(bucket)) continue;
|
|
295
|
-
seen.add(bucket);
|
|
296
|
-
filled.push(byBucket.get(bucket) ?? emptyTrendBucket(bucket));
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
return filled;
|
|
300
|
-
};
|
|
301
|
-
|
|
302
|
-
const fillTrendBuckets = (
|
|
303
|
-
rows: CallsTrendBucket[],
|
|
304
|
-
bucket: ResolvedTimeBucket,
|
|
305
|
-
startStr: string,
|
|
306
|
-
endStr: string,
|
|
307
|
-
timezone: string,
|
|
308
|
-
): CallsTrendBucket[] => {
|
|
309
|
-
switch (bucket) {
|
|
310
|
-
case "day":
|
|
311
|
-
return fillDailyTrendBuckets(rows, startStr, endStr);
|
|
312
|
-
case "week":
|
|
313
|
-
return fillWeeklyTrendBuckets(rows, startStr, endStr, timezone);
|
|
314
|
-
case "month":
|
|
315
|
-
return fillMonthlyTrendBuckets(rows, startStr, endStr, timezone);
|
|
316
|
-
default: {
|
|
317
|
-
return fillDailyTrendBuckets(rows, startStr, endStr);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
};
|
|
321
|
-
|
|
322
|
-
const resolveHourRange = (filter: CallsStatsFilter): { hourFrom: number; hourTo: number } => {
|
|
323
|
-
const { hourFrom, hourTo } = filter;
|
|
324
|
-
if (hourFrom == null && hourTo == null) return { hourFrom: 0, hourTo: HOURS_PER_DAY - 1 };
|
|
325
|
-
return {
|
|
326
|
-
hourFrom: hourFrom ?? 0,
|
|
327
|
-
hourTo: hourTo ?? HOURS_PER_DAY - 1,
|
|
328
|
-
};
|
|
329
|
-
};
|
|
330
|
-
|
|
331
|
-
const roundOneDecimal = (value: number): number => Math.round(value * 10) / 10;
|
|
332
|
-
|
|
333
|
-
const toHourlyDistribution = (
|
|
334
|
-
rows: { _id: unknown; totalCalls: number }[],
|
|
335
|
-
hourRange: { hourFrom: number; hourTo: number },
|
|
336
|
-
daysInRange: number,
|
|
337
|
-
): CallsHourlyBucket[] => {
|
|
338
|
-
const counts = new Map(rows.map((r) => [Number(r._id), r.totalCalls]));
|
|
339
|
-
const { hourFrom, hourTo } = hourRange;
|
|
340
|
-
const buckets: CallsHourlyBucket[] = [];
|
|
341
|
-
|
|
342
|
-
for (let hour = hourFrom; hour <= hourTo; hour += 1) {
|
|
343
|
-
const totalCalls = counts.get(hour) ?? 0;
|
|
344
|
-
buckets.push({
|
|
345
|
-
hour,
|
|
346
|
-
totalCalls,
|
|
347
|
-
avgCalls: roundOneDecimal(daysInRange > 0 ? totalCalls / daysInRange : 0),
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
return buckets;
|
|
352
|
-
};
|
|
353
|
-
|
|
354
|
-
export const aggregateCallsSummary = async (
|
|
355
|
-
filter: CallsStatsFilter,
|
|
356
|
-
): Promise<CallsSummaryAggregation> => {
|
|
357
|
-
const hasTickets = Boolean(filter.callSidsWithTickets?.length || filter.callSidsWithDraftTickets?.length);
|
|
358
|
-
const hasStatusFilter = needsTicketStatusFilter(filter.ticketStatusFilters);
|
|
359
|
-
|
|
360
|
-
if (!hasTickets && !hasStatusFilter) {
|
|
361
|
-
const result = await aggregateCalls(filter, {
|
|
362
|
-
$group: { _id: null, totalCalls: { $sum: 1 }, avgDurationSeconds: { $avg: "$callLength" } },
|
|
363
|
-
}).next();
|
|
364
|
-
if (!result) return emptySummary();
|
|
365
|
-
return {
|
|
366
|
-
totalCalls: result.totalCalls,
|
|
367
|
-
avgDurationSeconds: roundOneDecimal(result.avgDurationSeconds ?? 0),
|
|
368
|
-
openTickets: 0,
|
|
369
|
-
draftTickets: 0,
|
|
370
|
-
};
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
// Single pass: project per-call data, handle ticket status filter and counting in JS.
|
|
374
|
-
// Avoids embedding callSidsWithTickets inside any MongoDB pipeline stage.
|
|
375
|
-
const rows = await aggregateCalls(
|
|
376
|
-
{ ...filter, ticketStatusFilters: undefined },
|
|
377
|
-
{ $project: { _id: 0, callSid: 1, callLength: 1 } },
|
|
378
|
-
).toArray();
|
|
379
|
-
|
|
380
|
-
const ticketSet = new Set(filter.callSidsWithTickets ?? []);
|
|
381
|
-
const draftSet = new Set(filter.callSidsWithDraftTickets ?? []);
|
|
382
|
-
const openedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("opened");
|
|
383
|
-
const notOpenedOnly = hasStatusFilter && filter.ticketStatusFilters!.includes("not_opened");
|
|
384
|
-
|
|
385
|
-
let totalCalls = 0;
|
|
386
|
-
let totalDuration = 0;
|
|
387
|
-
let openTickets = 0;
|
|
388
|
-
let draftTickets = 0;
|
|
389
|
-
|
|
390
|
-
for (const row of rows) {
|
|
391
|
-
const sid = row.callSid as string;
|
|
392
|
-
const hasTicket = ticketSet.has(sid);
|
|
393
|
-
if (openedOnly && !hasTicket) continue;
|
|
394
|
-
if (notOpenedOnly && hasTicket) continue;
|
|
395
|
-
totalCalls += 1;
|
|
396
|
-
totalDuration += (row.callLength as number | undefined) ?? 0;
|
|
397
|
-
if (hasTicket) openTickets += 1;
|
|
398
|
-
if (draftSet.has(sid)) draftTickets += 1;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (!totalCalls) return emptySummary();
|
|
402
|
-
|
|
403
|
-
return {
|
|
404
|
-
totalCalls,
|
|
405
|
-
avgDurationSeconds: roundOneDecimal(totalDuration / totalCalls),
|
|
406
|
-
openTickets,
|
|
407
|
-
draftTickets,
|
|
408
|
-
};
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
export const aggregateCallsTrend = async (
|
|
412
|
-
filter: CallsStatsFilter,
|
|
413
|
-
bucket: ResolvedTimeBucket,
|
|
414
|
-
): Promise<CallsTrendBucket[]> => {
|
|
415
|
-
const ticketSet = filter.callSidsWithTickets?.length
|
|
416
|
-
? new Set(filter.callSidsWithTickets)
|
|
417
|
-
: null;
|
|
418
|
-
|
|
419
|
-
if (ticketSet) {
|
|
420
|
-
const rows = await aggregateCalls(
|
|
421
|
-
{ ...filter, ticketStatusFilters: undefined },
|
|
422
|
-
{ $addFields: trendBucketExpr(bucket, filter.timezone) },
|
|
423
|
-
{ $project: { _id: 0, bucket: 1, callSid: 1 } },
|
|
424
|
-
).toArray();
|
|
425
|
-
|
|
426
|
-
const openedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("opened");
|
|
427
|
-
const notOpenedOnly = needsTicketStatusFilter(filter.ticketStatusFilters) && filter.ticketStatusFilters!.includes("not_opened");
|
|
428
|
-
|
|
429
|
-
const byBucket = new Map<string, { totalCalls: number; openTickets: number }>();
|
|
430
|
-
for (const row of rows) {
|
|
431
|
-
const sid = row.callSid as string;
|
|
432
|
-
const hasTicket = ticketSet.has(sid);
|
|
433
|
-
if (openedOnly && !hasTicket) continue;
|
|
434
|
-
if (notOpenedOnly && hasTicket) continue;
|
|
435
|
-
const label =
|
|
436
|
-
bucket === "week"
|
|
437
|
-
? formatWeekBucketRange(String(row.bucket), filter.startStr, filter.endStr)
|
|
438
|
-
: String(row.bucket);
|
|
439
|
-
const cur = byBucket.get(label) ?? { totalCalls: 0, openTickets: 0 };
|
|
440
|
-
cur.totalCalls += 1;
|
|
441
|
-
if (hasTicket) cur.openTickets += 1;
|
|
442
|
-
byBucket.set(label, cur);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const mappedRows = [...byBucket.entries()]
|
|
446
|
-
.map(([label, counts]) => ({ bucket: label, ...counts }));
|
|
447
|
-
|
|
448
|
-
return fillTrendBuckets(mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone);
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
const rows = await aggregateCalls(
|
|
452
|
-
filter,
|
|
453
|
-
{ $addFields: trendBucketExpr(bucket, filter.timezone) },
|
|
454
|
-
{ $group: { _id: "$bucket", totalCalls: { $sum: 1 } } },
|
|
455
|
-
{ $sort: { _id: 1 } },
|
|
456
|
-
).toArray();
|
|
457
|
-
|
|
458
|
-
const mappedRows =
|
|
459
|
-
bucket === "week"
|
|
460
|
-
? rows.map((row) => ({
|
|
461
|
-
bucket: formatWeekBucketRange(String(row._id), filter.startStr, filter.endStr),
|
|
462
|
-
totalCalls: row.totalCalls as number,
|
|
463
|
-
openTickets: 0,
|
|
464
|
-
}))
|
|
465
|
-
: rows.map((row) =>
|
|
466
|
-
mapTrendRow({ _id: row._id, totalCalls: row.totalCalls as number, openTickets: 0 }),
|
|
467
|
-
);
|
|
468
|
-
|
|
469
|
-
return fillTrendBuckets(mappedRows, bucket, filter.startStr, filter.endStr, filter.timezone);
|
|
470
|
-
};
|
|
471
|
-
|
|
472
|
-
export const aggregateCallsHourlyByRange = async (
|
|
473
|
-
filter: CallsStatsFilter,
|
|
474
|
-
): Promise<CallsHourlyBucket[]> => {
|
|
475
|
-
const rows = await aggregateCalls(
|
|
476
|
-
filter,
|
|
477
|
-
{ $group: { _id: "$hour", totalCalls: { $sum: 1 } } },
|
|
478
|
-
{ $sort: { _id: 1 } },
|
|
479
|
-
).toArray();
|
|
480
|
-
|
|
481
|
-
return toHourlyDistribution(
|
|
482
|
-
rows.map((r) => ({ _id: r._id, totalCalls: r.totalCalls as number })),
|
|
483
|
-
resolveHourRange(filter),
|
|
484
|
-
rangeDurationDays(filter.startStr, filter.endStr),
|
|
485
|
-
);
|
|
486
|
-
};
|
|
487
|
-
|
|
488
|
-
export const aggregateCallsRouting = async (
|
|
489
|
-
filter: CallsStatsFilter,
|
|
490
|
-
): Promise<CallsRoutingAggregation> => {
|
|
491
|
-
const result = await aggregateCalls(
|
|
492
|
-
filter,
|
|
493
|
-
{
|
|
494
|
-
$group: {
|
|
495
|
-
_id: null,
|
|
496
|
-
transferred: countIf(isTransferred),
|
|
497
|
-
hijacked: countIf(isHijacked),
|
|
498
|
-
totalCalls: { $sum: 1 },
|
|
499
|
-
},
|
|
500
|
-
},
|
|
501
|
-
{
|
|
502
|
-
$addFields: {
|
|
503
|
-
aiHandled: {
|
|
504
|
-
$subtract: ["$totalCalls", { $add: ["$transferred", "$hijacked"] }],
|
|
505
|
-
},
|
|
506
|
-
},
|
|
507
|
-
},
|
|
508
|
-
).next();
|
|
509
|
-
|
|
510
|
-
if (!result) return emptyRouting();
|
|
511
|
-
|
|
512
|
-
return {
|
|
513
|
-
aiHandled: result.aiHandled,
|
|
514
|
-
transferred: result.transferred,
|
|
515
|
-
hijacked: result.hijacked,
|
|
516
|
-
};
|
|
517
|
-
};
|
|
518
|
-
|
|
519
|
-
export const findFilteredCallSids = async (filter: CallsStatsFilter): Promise<string[]> => {
|
|
520
|
-
const rows = await aggregateCalls(
|
|
521
|
-
filter,
|
|
522
|
-
{ $group: { _id: "$callSid" } },
|
|
523
|
-
).toArray();
|
|
524
|
-
return rows.map((r) => String(r._id)).filter(Boolean);
|
|
525
|
-
};
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export type ResolvedTimeBucket = "day" | "week" | "month";
|
|
2
|
-
|
|
3
|
-
export type TicketStatusFilter = "opened" | "not_opened";
|
|
4
|
-
export type CallRoutingFilter = "ai_only" | "transferred" | "hijacked";
|
|
5
|
-
|
|
6
|
-
export type CallsStatsFilter = {
|
|
7
|
-
clientId: string;
|
|
8
|
-
startStr: string;
|
|
9
|
-
endStr: string;
|
|
10
|
-
timezone: string;
|
|
11
|
-
hourFrom?: number;
|
|
12
|
-
hourTo?: number;
|
|
13
|
-
callRouting?: CallRoutingFilter[];
|
|
14
|
-
callSidsWithTickets?: string[];
|
|
15
|
-
/** Call SIDs whose municipal ticket matches the draft-ticket definition (see tickets statistics getters). */
|
|
16
|
-
callSidsWithDraftTickets?: string[];
|
|
17
|
-
ticketStatusFilters?: TicketStatusFilter[];
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
export type CallsSummaryAggregation = {
|
|
21
|
-
totalCalls: number;
|
|
22
|
-
avgDurationSeconds: number;
|
|
23
|
-
openTickets: number;
|
|
24
|
-
draftTickets: number;
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
export type CallsTrendBucket = {
|
|
28
|
-
/** Day: YYYY-MM-DD; week: YYYY-MM-DD/YYYY-MM-DD (inclusive, clipped to filter); month: YYYY-MM-01 */
|
|
29
|
-
bucket: string;
|
|
30
|
-
totalCalls: number;
|
|
31
|
-
openTickets: number;
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
export type CallsHourlyBucket = {
|
|
35
|
-
hour: number;
|
|
36
|
-
totalCalls: number;
|
|
37
|
-
avgCalls: number;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export type CallsRoutingAggregation = {
|
|
41
|
-
aiHandled: number;
|
|
42
|
-
transferred: number;
|
|
43
|
-
hijacked: number;
|
|
44
|
-
};
|
package/src/utils/date.utils.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
};
|