wacrawl-dashboard 0.1.1 → 0.2.0
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 +220 -89
- package/apps/api/dist/demo/demoArchive.js +371 -0
- package/apps/api/dist/demo/demoCast.js +138 -0
- package/apps/api/dist/demo/demoMedia.js +108 -0
- package/apps/api/dist/index.js +109 -29
- package/apps/api/dist/lib/archiveSchema.js +57 -0
- package/apps/api/dist/lib/archiveSync.js +151 -0
- package/apps/api/dist/lib/chatKind.js +41 -0
- package/apps/api/dist/lib/displayName.js +37 -3
- package/apps/api/dist/lib/mediaFsPath.js +27 -0
- package/apps/api/dist/lib/percentile.js +23 -0
- package/apps/api/dist/lib/query.js +33 -7
- package/apps/api/dist/lib/rollupDb.js +253 -0
- package/apps/api/dist/lib/searchFilters.js +141 -0
- package/apps/api/dist/lib/searchIndex.js +3 -3
- package/apps/api/dist/lib/selfIdentity.js +42 -0
- package/apps/api/dist/lib/statsCache.js +83 -0
- package/apps/api/dist/lib/timezone.js +120 -5
- package/apps/api/dist/lib/urlExtract.js +111 -0
- package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
- package/apps/api/dist/pathsResolve.js +17 -3
- package/apps/api/dist/routes/data.js +168 -78
- package/apps/api/dist/routes/settings.js +3 -0
- package/apps/api/dist/routes/stats/profiles.js +522 -0
- package/apps/api/dist/routes/stats/reports.js +555 -0
- package/apps/api/dist/routes/stats/shared.js +75 -0
- package/apps/api/dist/routes/stats.js +486 -97
- package/apps/api/dist/routes/sync.js +60 -0
- package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
- package/apps/api/package.json +31 -0
- package/apps/web/dist/apple-touch-icon.png +0 -0
- package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
- package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
- package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
- package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
- package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
- package/apps/web/dist/favicon-16x16.png +0 -0
- package/apps/web/dist/favicon-32x32.png +0 -0
- package/apps/web/dist/favicon-512x512.png +0 -0
- package/apps/web/dist/favicon.ico +0 -0
- package/apps/web/dist/favicon.svg +59 -0
- package/apps/web/dist/index.html +11 -2
- package/bin/wacrawl-dashboard.js +64 -15
- package/package.json +25 -4
- package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
- package/apps/web/dist/assets/index-Dfsoi6vG.js +0 -43
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { getDb } from '../db.js';
|
|
3
|
-
import { cleanDisplayNameSql, contactDisplayNameSql,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
3
|
+
import { canonicalJidSql, cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins, isPersonJidSql } from '../lib/displayName.js';
|
|
4
|
+
import { mean } from '../lib/percentile.js';
|
|
5
|
+
import { etagFor, getCached, statsCacheKey } from '../lib/statsCache.js';
|
|
6
|
+
import { bucketDateSql, normalizeBucketDate, parseGranularity, parseLimit, parsePeriod, parseYear, scanCoverage, sinceTimestamp, unixSecondsToIso, yearBounds, } from '../lib/query.js';
|
|
7
|
+
import { dateInTimezone, hourInTimezone, monthInTimezone, resolveStatsTimeZone, SERVER_TIME_ZONE, tsModifierSql, weekdayHourInTimezone, } from '../lib/timezone.js';
|
|
8
|
+
import { getContactProfile, getGroupDirectory, getGroupLifecycle, getGroupProfile, } from './stats/profiles.js';
|
|
9
|
+
import { getArchivedChats, getDistinctiveWords, getDormancy, getLinkIntelligence, getYearInReview, } from './stats/reports.js';
|
|
10
|
+
import { ChatNotFoundError, EMOJI_REGEX, GHOST_WINDOW_SECONDS, isNoiseToken, NotAGroupError, resolveChatNames, SESSION_GAP_SECONDS, summarizeGaps, WORD_TOKEN_RE, } from './stats/shared.js';
|
|
11
|
+
export { ChatNotFoundError, getArchivedChats, getContactProfile, getDistinctiveWords, getDormancy, getGroupDirectory, getGroupLifecycle, getGroupProfile, getLinkIntelligence, getYearInReview, NotAGroupError, };
|
|
7
12
|
export function getOverviewStats(db = getDb()) {
|
|
8
13
|
const messageRow = db.prepare(`
|
|
9
14
|
SELECT
|
|
@@ -32,14 +37,15 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
32
37
|
WITH normalized_messages AS (
|
|
33
38
|
SELECT
|
|
34
39
|
COALESCE(
|
|
35
|
-
|
|
40
|
+
contacts_jid.jid,
|
|
41
|
+
contacts_lid.jid,
|
|
36
42
|
CASE
|
|
37
43
|
WHEN from_me = 1 THEN chat_jid
|
|
38
44
|
ELSE COALESCE(sender_jid, chat_jid)
|
|
39
45
|
END
|
|
40
46
|
) AS jid,
|
|
41
47
|
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
42
|
-
|
|
48
|
+
COALESCE(contacts_jid.phone, contacts_lid.phone) AS contact_phone,
|
|
43
49
|
${cleanDisplayNameSql(`
|
|
44
50
|
CASE
|
|
45
51
|
WHEN from_me = 1 THEN messages.chat_name
|
|
@@ -64,7 +70,7 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
64
70
|
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
65
71
|
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
66
72
|
END
|
|
67
|
-
|
|
73
|
+
${contactLeftJoins('contacts', `CASE
|
|
68
74
|
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
69
75
|
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
70
76
|
END`)}
|
|
@@ -84,7 +90,7 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
84
90
|
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
|
|
85
91
|
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS sentByThem
|
|
86
92
|
FROM normalized_messages
|
|
87
|
-
WHERE jid
|
|
93
|
+
WHERE ${isPersonJidSql('jid')}
|
|
88
94
|
GROUP BY jid
|
|
89
95
|
ORDER BY messageCount DESC
|
|
90
96
|
LIMIT @limit
|
|
@@ -103,7 +109,7 @@ export function getMessageVolume(params, db = getDb()) {
|
|
|
103
109
|
const fallbackGranularity = period === 'year' || period === 'all' ? 'month' : 'day';
|
|
104
110
|
const granularity = parseGranularity(params.granularity, fallbackGranularity);
|
|
105
111
|
const since = sinceTimestamp(period);
|
|
106
|
-
const bucketSql = bucketDateSql(granularity);
|
|
112
|
+
const bucketSql = bucketDateSql(granularity, resolveStatsTimeZone(params.timeZone));
|
|
107
113
|
const rows = db.prepare(`
|
|
108
114
|
SELECT
|
|
109
115
|
${bucketSql} AS date,
|
|
@@ -122,9 +128,10 @@ export function getMessageVolume(params, db = getDb()) {
|
|
|
122
128
|
}
|
|
123
129
|
export function getActivityHeatmap(params, db = getDb()) {
|
|
124
130
|
const year = parseYear(params.year);
|
|
125
|
-
const
|
|
131
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
132
|
+
const bounds = yearBounds(year, timeZone);
|
|
126
133
|
return db.prepare(`
|
|
127
|
-
SELECT date(
|
|
134
|
+
SELECT date(${tsModifierSql(timeZone)}) AS date, COUNT(*) AS count
|
|
128
135
|
FROM messages
|
|
129
136
|
WHERE ts >= @start AND ts < @end
|
|
130
137
|
GROUP BY date
|
|
@@ -134,6 +141,15 @@ export function getActivityHeatmap(params, db = getDb()) {
|
|
|
134
141
|
export function getHourOfDayStats(params, db = getDb()) {
|
|
135
142
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
136
143
|
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
144
|
+
if (timeZone === 'UTC' || timeZone === SERVER_TIME_ZONE) {
|
|
145
|
+
return db.prepare(`
|
|
146
|
+
SELECT CAST(strftime('%H', ${tsModifierSql(timeZone)}) AS INTEGER) AS hour, COUNT(*) AS count
|
|
147
|
+
FROM messages
|
|
148
|
+
WHERE ts >= @since
|
|
149
|
+
GROUP BY hour
|
|
150
|
+
ORDER BY hour ASC
|
|
151
|
+
`).all({ since });
|
|
152
|
+
}
|
|
137
153
|
const rows = db.prepare(`
|
|
138
154
|
SELECT ts
|
|
139
155
|
FROM messages
|
|
@@ -155,7 +171,7 @@ export function getDayOfWeekStats(params, db = getDb()) {
|
|
|
155
171
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
156
172
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
157
173
|
const rows = db.prepare(`
|
|
158
|
-
SELECT strftime('%w',
|
|
174
|
+
SELECT strftime('%w', ${tsModifierSql(resolveStatsTimeZone(params.timeZone))}) AS day, COUNT(*) AS count
|
|
159
175
|
FROM messages
|
|
160
176
|
WHERE ts >= @since
|
|
161
177
|
GROUP BY day
|
|
@@ -185,15 +201,15 @@ export function getMediaSenders(params, db = getDb()) {
|
|
|
185
201
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
186
202
|
const limit = parseLimit(params.limit, 10, 50);
|
|
187
203
|
const rows = db.prepare(`
|
|
188
|
-
|
|
204
|
+
WITH normalized AS (
|
|
189
205
|
SELECT
|
|
190
|
-
CASE WHEN from_me = 1 THEN chat_jid ELSE COALESCE(sender_jid, chat_jid) END AS jid,
|
|
206
|
+
${canonicalJidSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')} AS jid,
|
|
191
207
|
${cleanDisplayNameSql('CASE WHEN from_me = 1 THEN COALESCE(chats.name, chat_name) ELSE sender_name END')} AS message_name,
|
|
192
208
|
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
193
209
|
media_size
|
|
194
210
|
FROM messages
|
|
195
211
|
LEFT JOIN chats ON chats.jid = CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END
|
|
196
|
-
|
|
212
|
+
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
197
213
|
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
198
214
|
)
|
|
199
215
|
SELECT
|
|
@@ -202,7 +218,7 @@ export function getMediaSenders(params, db = getDb()) {
|
|
|
202
218
|
COUNT(*) AS mediaCount,
|
|
203
219
|
COALESCE(SUM(media_size), 0) AS totalBytes
|
|
204
220
|
FROM normalized
|
|
205
|
-
WHERE jid
|
|
221
|
+
WHERE ${isPersonJidSql('jid')}
|
|
206
222
|
GROUP BY jid
|
|
207
223
|
ORDER BY mediaCount DESC, name COLLATE NOCASE ASC
|
|
208
224
|
LIMIT @limit
|
|
@@ -218,7 +234,7 @@ export function getSentReceivedRatio(params, db = getDb()) {
|
|
|
218
234
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
219
235
|
const rows = db.prepare(`
|
|
220
236
|
SELECT
|
|
221
|
-
strftime('%Y-%m-01',
|
|
237
|
+
strftime('%Y-%m-01', ${tsModifierSql(resolveStatsTimeZone(params.timeZone))}) AS month,
|
|
222
238
|
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
223
239
|
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS received
|
|
224
240
|
FROM messages
|
|
@@ -233,43 +249,148 @@ export function getSentReceivedRatio(params, db = getDb()) {
|
|
|
233
249
|
ratio: row.received ? Number((row.sent / row.received).toFixed(2)) : null,
|
|
234
250
|
}));
|
|
235
251
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
252
|
+
/** Backstop on reply gaps pulled into memory for percentile work (2 narrow columns each). */
|
|
253
|
+
const REPLY_GAP_MAX_ROWS = 400_000;
|
|
254
|
+
const REPLY_GAP_CACHE_TTL_MS = 60_000;
|
|
255
|
+
const replyGapCache = new WeakMap();
|
|
256
|
+
function dbMutationSignature(db) {
|
|
257
|
+
const changesRow = db.prepare('SELECT total_changes() AS changes').get();
|
|
258
|
+
const dataVersion = Number(db.pragma('data_version', { simple: true }) ?? 0);
|
|
259
|
+
return `${changesRow.changes}:${dataVersion}`;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Every turn-taking gap in the window: one row per message that reversed the
|
|
263
|
+
* direction of the conversation, carrying who sent it and how long they took.
|
|
264
|
+
*
|
|
265
|
+
* Shared by the per-chat leaderboard and the archive-wide latency distribution.
|
|
266
|
+
*/
|
|
267
|
+
function selectReplyGaps(periodKey, since, db) {
|
|
268
|
+
let perDb = replyGapCache.get(db);
|
|
269
|
+
if (!perDb) {
|
|
270
|
+
perDb = new Map();
|
|
271
|
+
replyGapCache.set(db, perDb);
|
|
272
|
+
}
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
const signature = dbMutationSignature(db);
|
|
275
|
+
const cached = perDb.get(periodKey);
|
|
276
|
+
if (cached && cached.signature === signature && cached.expiresAt > now) {
|
|
277
|
+
return cached.rows;
|
|
278
|
+
}
|
|
239
279
|
const rows = db.prepare(`
|
|
240
|
-
|
|
280
|
+
SELECT jid, responder_from_me, gap
|
|
281
|
+
FROM (
|
|
241
282
|
SELECT
|
|
242
|
-
chat_jid,
|
|
243
|
-
|
|
244
|
-
ts,
|
|
245
|
-
from_me,
|
|
246
|
-
|
|
247
|
-
LAG(from_me) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_from_me
|
|
283
|
+
chat_jid AS jid,
|
|
284
|
+
from_me AS responder_from_me,
|
|
285
|
+
ts - LAG(ts) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS gap,
|
|
286
|
+
LAG(from_me) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_from_me,
|
|
287
|
+
ts
|
|
248
288
|
FROM messages
|
|
249
289
|
WHERE ts >= @since
|
|
250
290
|
)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
COALESCE(${cleanDisplayNameSql('MAX(chats.name)')}, ${cleanDisplayNameSql('MAX(chat_name)')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('chat_jid')}, 'Unknown') AS name,
|
|
254
|
-
COUNT(*) AS responseCount,
|
|
255
|
-
ROUND(AVG(ts - previous_ts)) AS averageSeconds
|
|
256
|
-
FROM ordered
|
|
257
|
-
LEFT JOIN chats ON chats.jid = ordered.chat_jid
|
|
258
|
-
LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'ordered.chat_jid')}
|
|
259
|
-
WHERE previous_ts IS NOT NULL
|
|
291
|
+
WHERE gap IS NOT NULL
|
|
292
|
+
AND gap > 0
|
|
260
293
|
AND previous_from_me IS NOT NULL
|
|
261
|
-
AND previous_from_me <>
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
294
|
+
AND previous_from_me <> responder_from_me
|
|
295
|
+
ORDER BY ts DESC
|
|
296
|
+
LIMIT ${REPLY_GAP_MAX_ROWS}
|
|
297
|
+
`).all({ since });
|
|
298
|
+
perDb.set(periodKey, {
|
|
299
|
+
signature,
|
|
300
|
+
expiresAt: now + REPLY_GAP_CACHE_TTL_MS,
|
|
301
|
+
rows,
|
|
302
|
+
});
|
|
303
|
+
return rows;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Per-chat turnaround, reported as percentiles and split by who was replying.
|
|
307
|
+
*/
|
|
308
|
+
export function getResponseTimes(params, db = getDb()) {
|
|
309
|
+
const period = parsePeriod(params.period);
|
|
310
|
+
const since = sinceTimestamp(period);
|
|
311
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
312
|
+
const minResponses = parseLimit(params.minResponses, 5, 1000);
|
|
313
|
+
const perChat = new Map();
|
|
314
|
+
for (const row of selectReplyGaps(period, since, db)) {
|
|
315
|
+
let entry = perChat.get(row.jid);
|
|
316
|
+
if (!entry) {
|
|
317
|
+
entry = { mine: [], theirs: [] };
|
|
318
|
+
perChat.set(row.jid, entry);
|
|
319
|
+
}
|
|
320
|
+
(row.responder_from_me ? entry.mine : entry.theirs).push(row.gap);
|
|
321
|
+
}
|
|
322
|
+
const ranked = [...perChat.entries()]
|
|
323
|
+
.map(([jid, { mine, theirs }]) => {
|
|
324
|
+
const all = [...mine, ...theirs];
|
|
325
|
+
const overall = summarizeGaps(all);
|
|
326
|
+
return {
|
|
327
|
+
jid,
|
|
328
|
+
responseCount: all.length,
|
|
329
|
+
averageSeconds: Math.round(mean(all)),
|
|
330
|
+
medianSeconds: overall.median,
|
|
331
|
+
p90Seconds: overall.p90,
|
|
332
|
+
myResponseCount: mine.length,
|
|
333
|
+
myMedianSeconds: mine.length ? summarizeGaps(mine).median : null,
|
|
334
|
+
theirResponseCount: theirs.length,
|
|
335
|
+
theirMedianSeconds: theirs.length ? summarizeGaps(theirs).median : null,
|
|
336
|
+
};
|
|
337
|
+
})
|
|
338
|
+
.filter((stat) => stat.responseCount >= minResponses)
|
|
339
|
+
.sort((a, b) => a.medianSeconds - b.medianSeconds || b.responseCount - a.responseCount)
|
|
340
|
+
.slice(0, limit);
|
|
341
|
+
const names = resolveChatNames(ranked.map((stat) => stat.jid), db);
|
|
342
|
+
return ranked.map((stat) => ({ ...stat, name: names.get(stat.jid) ?? stat.jid }));
|
|
343
|
+
}
|
|
344
|
+
/** Bucket edges in seconds; the last bucket is open-ended. */
|
|
345
|
+
const REPLY_LATENCY_BUCKETS = [
|
|
346
|
+
{ from: 0, to: 60, label: '< 1 min' },
|
|
347
|
+
{ from: 60, to: 300, label: '1–5 min' },
|
|
348
|
+
{ from: 300, to: 900, label: '5–15 min' },
|
|
349
|
+
{ from: 900, to: 3600, label: '15–60 min' },
|
|
350
|
+
{ from: 3600, to: 4 * 3600, label: '1–4 h' },
|
|
351
|
+
{ from: 4 * 3600, to: 12 * 3600, label: '4–12 h' },
|
|
352
|
+
{ from: 12 * 3600, to: 86_400, label: '12–24 h' },
|
|
353
|
+
{ from: 86_400, to: 3 * 86_400, label: '1–3 d' },
|
|
354
|
+
{ from: 3 * 86_400, to: null, label: '3 d +' },
|
|
355
|
+
];
|
|
356
|
+
/**
|
|
357
|
+
* Archive-wide shape of how fast replies happen, split by who is replying.
|
|
358
|
+
*/
|
|
359
|
+
export function getReplyLatency(params, db = getDb()) {
|
|
360
|
+
const period = parsePeriod(params.period);
|
|
361
|
+
const since = sinceTimestamp(period);
|
|
362
|
+
const rows = selectReplyGaps(period, since, db);
|
|
363
|
+
const buckets = REPLY_LATENCY_BUCKETS.map((bucket) => ({
|
|
364
|
+
fromSeconds: bucket.from,
|
|
365
|
+
toSeconds: bucket.to,
|
|
366
|
+
label: bucket.label,
|
|
367
|
+
mine: 0,
|
|
368
|
+
theirs: 0,
|
|
272
369
|
}));
|
|
370
|
+
const mine = [];
|
|
371
|
+
const theirs = [];
|
|
372
|
+
for (const row of rows) {
|
|
373
|
+
const index = REPLY_LATENCY_BUCKETS.findIndex((b) => b.to === null || row.gap < b.to);
|
|
374
|
+
const bucket = buckets[index === -1 ? buckets.length - 1 : index];
|
|
375
|
+
if (row.responder_from_me) {
|
|
376
|
+
bucket.mine += 1;
|
|
377
|
+
mine.push(row.gap);
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
bucket.theirs += 1;
|
|
381
|
+
theirs.push(row.gap);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const side = (gaps) => {
|
|
385
|
+
const { median, p90 } = summarizeGaps(gaps);
|
|
386
|
+
return { count: gaps.length, medianSeconds: median, p90Seconds: p90 };
|
|
387
|
+
};
|
|
388
|
+
return {
|
|
389
|
+
buckets,
|
|
390
|
+
mine: side(mine),
|
|
391
|
+
theirs: side(theirs),
|
|
392
|
+
scan: scanCoverage(rows.length, REPLY_GAP_MAX_ROWS),
|
|
393
|
+
};
|
|
273
394
|
}
|
|
274
395
|
export function getGroupActivity(params, db = getDb()) {
|
|
275
396
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
@@ -296,8 +417,9 @@ export function getGroupActivity(params, db = getDb()) {
|
|
|
296
417
|
}
|
|
297
418
|
export function getMessageStreaks(params, db = getDb()) {
|
|
298
419
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
420
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
299
421
|
const rows = db.prepare(`
|
|
300
|
-
SELECT DISTINCT date(
|
|
422
|
+
SELECT DISTINCT date(${tsModifierSql(timeZone)}) AS date
|
|
301
423
|
FROM messages
|
|
302
424
|
WHERE ts >= @since
|
|
303
425
|
ORDER BY date ASC
|
|
@@ -311,8 +433,7 @@ export function getMessageStreaks(params, db = getDb()) {
|
|
|
311
433
|
longestStreak = Math.max(longestStreak, activeRun);
|
|
312
434
|
previousTime = time;
|
|
313
435
|
}
|
|
314
|
-
const
|
|
315
|
-
const todayUtc = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
436
|
+
const todayUtc = Date.parse(`${dateInTimezone(Math.floor(Date.now() / 1000), timeZone)}T00:00:00.000Z`);
|
|
316
437
|
const lastTime = rows.length ? Date.parse(`${rows[rows.length - 1].date}T00:00:00.000Z`) : null;
|
|
317
438
|
const currentStreak = lastTime != null && todayUtc - lastTime <= 86_400_000 ? activeRun : 0;
|
|
318
439
|
return { currentStreak, longestStreak };
|
|
@@ -320,6 +441,8 @@ export function getMessageStreaks(params, db = getDb()) {
|
|
|
320
441
|
function parseWordCloudFilter(value) {
|
|
321
442
|
return value === 'useful' ? 'useful' : 'all';
|
|
322
443
|
}
|
|
444
|
+
/** Messages scanned for terms; the newest ones, when the archive is larger. */
|
|
445
|
+
const WORD_CLOUD_MAX_ROWS = 30_000;
|
|
323
446
|
export function getWordCloud(params, db = getDb()) {
|
|
324
447
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
325
448
|
const limit = parseLimit(params.limit, 50, 200);
|
|
@@ -328,61 +451,327 @@ export function getWordCloud(params, db = getDb()) {
|
|
|
328
451
|
SELECT text
|
|
329
452
|
FROM messages
|
|
330
453
|
WHERE ts >= @since AND text IS NOT NULL AND TRIM(text) <> ''
|
|
454
|
+
ORDER BY ts DESC
|
|
455
|
+
LIMIT ${WORD_CLOUD_MAX_ROWS}
|
|
331
456
|
`).all({ since });
|
|
332
457
|
const counts = new Map();
|
|
333
458
|
for (const row of rows) {
|
|
334
|
-
for (const term of (row.text ?? '').toLowerCase().match(
|
|
335
|
-
if (filterMode === 'useful')
|
|
336
|
-
|
|
337
|
-
continue;
|
|
338
|
-
if (USEFUL_WORD_STOP_SET.has(term))
|
|
339
|
-
continue;
|
|
340
|
-
}
|
|
459
|
+
for (const term of (row.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
460
|
+
if (filterMode === 'useful' && isNoiseToken(term))
|
|
461
|
+
continue;
|
|
341
462
|
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
342
463
|
}
|
|
343
464
|
}
|
|
344
|
-
|
|
465
|
+
const terms = [...counts.entries()]
|
|
345
466
|
.map(([text, value]) => ({ text, value }))
|
|
346
467
|
.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text))
|
|
347
468
|
.slice(0, limit);
|
|
469
|
+
return { terms, scan: scanCoverage(rows.length, WORD_CLOUD_MAX_ROWS) };
|
|
470
|
+
}
|
|
471
|
+
/** Text messages scanned for emoji; the newest ones, when the archive is larger. */
|
|
472
|
+
const EMOJI_MAX_ROWS = 50_000;
|
|
473
|
+
export function getEmojiAnalytics(params, db = getDb()) {
|
|
474
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
475
|
+
const limit = parseLimit(params.limit, 15, 50);
|
|
476
|
+
const rows = db.prepare(`
|
|
477
|
+
SELECT
|
|
478
|
+
messages.text,
|
|
479
|
+
messages.from_me,
|
|
480
|
+
${canonicalJidSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')} AS jid,
|
|
481
|
+
${cleanDisplayNameSql(`CASE WHEN messages.from_me = 1 THEN COALESCE(chats.name, messages.chat_name) ELSE messages.sender_name END`)} AS name,
|
|
482
|
+
${contactDisplayNameSql('contacts')} AS contact_name
|
|
483
|
+
FROM messages
|
|
484
|
+
LEFT JOIN chats ON chats.jid = CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END
|
|
485
|
+
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
486
|
+
WHERE messages.ts >= @since AND messages.text IS NOT NULL AND messages.text <> ''
|
|
487
|
+
ORDER BY messages.ts DESC
|
|
488
|
+
LIMIT ${EMOJI_MAX_ROWS}
|
|
489
|
+
`).all({ since });
|
|
490
|
+
const allCounts = new Map();
|
|
491
|
+
const sentCounts = new Map();
|
|
492
|
+
const receivedCounts = new Map();
|
|
493
|
+
const userCounts = new Map();
|
|
494
|
+
let totalEmojiCount = 0;
|
|
495
|
+
for (const row of rows) {
|
|
496
|
+
const emojis = (row.text ?? '').match(EMOJI_REGEX);
|
|
497
|
+
if (!emojis || emojis.length === 0)
|
|
498
|
+
continue;
|
|
499
|
+
totalEmojiCount += emojis.length;
|
|
500
|
+
const jid = row.jid ?? 'unknown';
|
|
501
|
+
const isPerson = !jid.endsWith('@g.us') && !jid.endsWith('@status') && !jid.endsWith('@broadcast');
|
|
502
|
+
const displayName = row.name ?? row.contact_name ?? jid;
|
|
503
|
+
let userEntry;
|
|
504
|
+
if (isPerson) {
|
|
505
|
+
if (!userCounts.has(jid)) {
|
|
506
|
+
userCounts.set(jid, { name: displayName, count: 0, emojis: new Map() });
|
|
507
|
+
}
|
|
508
|
+
userEntry = userCounts.get(jid);
|
|
509
|
+
}
|
|
510
|
+
for (const emoji of emojis) {
|
|
511
|
+
allCounts.set(emoji, (allCounts.get(emoji) ?? 0) + 1);
|
|
512
|
+
if (row.from_me) {
|
|
513
|
+
sentCounts.set(emoji, (sentCounts.get(emoji) ?? 0) + 1);
|
|
514
|
+
}
|
|
515
|
+
else {
|
|
516
|
+
receivedCounts.set(emoji, (receivedCounts.get(emoji) ?? 0) + 1);
|
|
517
|
+
}
|
|
518
|
+
if (userEntry) {
|
|
519
|
+
userEntry.count += 1;
|
|
520
|
+
userEntry.emojis.set(emoji, (userEntry.emojis.get(emoji) ?? 0) + 1);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const sortMap = (map, n) => [...map.entries()]
|
|
525
|
+
.sort((a, b) => b[1] - a[1])
|
|
526
|
+
.slice(0, n)
|
|
527
|
+
.map(([emoji, count]) => ({ emoji, count }));
|
|
528
|
+
const topEmojiUsers = [...userCounts.entries()]
|
|
529
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
530
|
+
.slice(0, limit)
|
|
531
|
+
.map(([jid, entry]) => {
|
|
532
|
+
const topEmoji = [...entry.emojis.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
533
|
+
return { jid, name: entry.name, count: entry.count, topEmoji };
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
topEmojis: sortMap(allCounts, limit),
|
|
537
|
+
topSentEmojis: sortMap(sentCounts, limit),
|
|
538
|
+
topReceivedEmojis: sortMap(receivedCounts, limit),
|
|
539
|
+
totalEmojiCount,
|
|
540
|
+
uniqueEmojiCount: allCounts.size,
|
|
541
|
+
topEmojiUsers,
|
|
542
|
+
scan: scanCoverage(rows.length, EMOJI_MAX_ROWS),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
/** Upper bound on rows pulled into memory for session analysis (3 narrow columns each). */
|
|
546
|
+
const CONVERSATION_DYNAMICS_MAX_ROWS = 250_000;
|
|
547
|
+
function computeTrajectoryDirection(trend) {
|
|
548
|
+
if (trend.length < 4)
|
|
549
|
+
return 'stable';
|
|
550
|
+
const half = Math.floor(trend.length / 2);
|
|
551
|
+
const firstHalfAvg = trend.slice(0, half).reduce((s, p) => s + p.count, 0) / half;
|
|
552
|
+
const secondHalfAvg = trend.slice(half).reduce((s, p) => s + p.count, 0) / (trend.length - half);
|
|
553
|
+
const ratio = secondHalfAvg / (firstHalfAvg || 1);
|
|
554
|
+
if (ratio > 1.25)
|
|
555
|
+
return 'growing';
|
|
556
|
+
if (ratio < 0.75)
|
|
557
|
+
return 'fading';
|
|
558
|
+
return 'stable';
|
|
559
|
+
}
|
|
560
|
+
export function getConversationDynamics(params, db = getDb()) {
|
|
561
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
562
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
563
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
564
|
+
const ghostCutoff = Math.floor(Date.now() / 1000) - GHOST_WINDOW_SECONDS;
|
|
565
|
+
const rows = db.prepare(`
|
|
566
|
+
SELECT chat_jid, ts, from_me
|
|
567
|
+
FROM (
|
|
568
|
+
SELECT chat_jid, ts, from_me, rowid
|
|
569
|
+
FROM messages
|
|
570
|
+
WHERE ts >= @since
|
|
571
|
+
ORDER BY ts DESC, rowid DESC
|
|
572
|
+
LIMIT ${CONVERSATION_DYNAMICS_MAX_ROWS}
|
|
573
|
+
)
|
|
574
|
+
ORDER BY chat_jid, ts, rowid
|
|
575
|
+
`).all({ since });
|
|
576
|
+
const chatGroups = new Map();
|
|
577
|
+
for (const row of rows) {
|
|
578
|
+
let group = chatGroups.get(row.chat_jid);
|
|
579
|
+
if (!group) {
|
|
580
|
+
group = [];
|
|
581
|
+
chatGroups.set(row.chat_jid, group);
|
|
582
|
+
}
|
|
583
|
+
group.push(row);
|
|
584
|
+
}
|
|
585
|
+
const rawInitiation = [];
|
|
586
|
+
const rawDepth = [];
|
|
587
|
+
const rawGhost = [];
|
|
588
|
+
const rawLateNight = [];
|
|
589
|
+
const rawTrajectory = [];
|
|
590
|
+
for (const [jid, messages] of chatGroups) {
|
|
591
|
+
if (messages.length < 2)
|
|
592
|
+
continue;
|
|
593
|
+
let byMe = 0;
|
|
594
|
+
let byThem = 0;
|
|
595
|
+
let sessionStart = 0;
|
|
596
|
+
const sessionSizes = [];
|
|
597
|
+
if (messages[0].from_me)
|
|
598
|
+
byMe++;
|
|
599
|
+
else
|
|
600
|
+
byThem++;
|
|
601
|
+
for (let i = 1; i < messages.length; i++) {
|
|
602
|
+
if (messages[i].ts - messages[i - 1].ts >= SESSION_GAP_SECONDS) {
|
|
603
|
+
sessionSizes.push(i - sessionStart);
|
|
604
|
+
sessionStart = i;
|
|
605
|
+
if (messages[i].from_me)
|
|
606
|
+
byMe++;
|
|
607
|
+
else
|
|
608
|
+
byThem++;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
sessionSizes.push(messages.length - sessionStart);
|
|
612
|
+
const totalSessions = sessionSizes.length;
|
|
613
|
+
rawInitiation.push({ jid, byMe, byThem, total: byMe + byThem });
|
|
614
|
+
rawDepth.push({ jid, avgDepth: Math.round((messages.length / totalSessions) * 10) / 10, sessions: totalSessions, total: messages.length });
|
|
615
|
+
let ghosted = 0;
|
|
616
|
+
let totalSent = 0;
|
|
617
|
+
let nextReceivedTs = null;
|
|
618
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
619
|
+
const message = messages[i];
|
|
620
|
+
if (!message.from_me) {
|
|
621
|
+
nextReceivedTs = message.ts;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
if (message.ts > ghostCutoff)
|
|
625
|
+
continue;
|
|
626
|
+
totalSent++;
|
|
627
|
+
if (nextReceivedTs === null || nextReceivedTs - message.ts > GHOST_WINDOW_SECONDS)
|
|
628
|
+
ghosted++;
|
|
629
|
+
}
|
|
630
|
+
if (totalSent > 0) {
|
|
631
|
+
rawGhost.push({ jid, ghosted, totalSent });
|
|
632
|
+
}
|
|
633
|
+
let lateNight = 0;
|
|
634
|
+
let workHrs = 0;
|
|
635
|
+
let other = 0;
|
|
636
|
+
for (const msg of messages) {
|
|
637
|
+
const hour = hourInTimezone(msg.ts, timeZone);
|
|
638
|
+
if (hour < 5)
|
|
639
|
+
lateNight++;
|
|
640
|
+
else if (hour >= 9 && hour < 17)
|
|
641
|
+
workHrs++;
|
|
642
|
+
else
|
|
643
|
+
other++;
|
|
644
|
+
}
|
|
645
|
+
rawLateNight.push({ jid, lateNight, workHours: workHrs, other, total: messages.length });
|
|
646
|
+
const monthCounts = new Map();
|
|
647
|
+
for (const msg of messages) {
|
|
648
|
+
const month = monthInTimezone(msg.ts, timeZone);
|
|
649
|
+
monthCounts.set(month, (monthCounts.get(month) ?? 0) + 1);
|
|
650
|
+
}
|
|
651
|
+
rawTrajectory.push({ jid, monthCounts, total: messages.length });
|
|
652
|
+
}
|
|
653
|
+
const topInitiation = rawInitiation.filter((r) => r.total >= 3).sort((a, b) => b.total - a.total).slice(0, limit);
|
|
654
|
+
const topDepth = rawDepth.filter((r) => r.sessions >= 3).sort((a, b) => b.avgDepth - a.avgDepth).slice(0, limit);
|
|
655
|
+
const topGhost = rawGhost.filter((r) => r.totalSent >= 3).sort((a, b) => (b.ghosted / b.totalSent) - (a.ghosted / a.totalSent)).slice(0, limit);
|
|
656
|
+
const topLateNight = rawLateNight.filter((r) => r.lateNight > 0).sort((a, b) => b.lateNight - a.lateNight).slice(0, limit);
|
|
657
|
+
const topTrajectory = rawTrajectory.filter((r) => r.monthCounts.size >= 2).sort((a, b) => b.total - a.total).slice(0, limit);
|
|
658
|
+
const allJids = new Set();
|
|
659
|
+
for (const r of topInitiation)
|
|
660
|
+
allJids.add(r.jid);
|
|
661
|
+
for (const r of topDepth)
|
|
662
|
+
allJids.add(r.jid);
|
|
663
|
+
for (const r of topGhost)
|
|
664
|
+
allJids.add(r.jid);
|
|
665
|
+
for (const r of topLateNight)
|
|
666
|
+
allJids.add(r.jid);
|
|
667
|
+
for (const r of topTrajectory)
|
|
668
|
+
allJids.add(r.jid);
|
|
669
|
+
const names = resolveChatNames([...allJids], db);
|
|
670
|
+
const name = (jid) => names.get(jid) ?? jid;
|
|
671
|
+
const initiationRatio = topInitiation.map((r) => ({
|
|
672
|
+
jid: r.jid,
|
|
673
|
+
name: name(r.jid),
|
|
674
|
+
initiatedByMe: r.byMe,
|
|
675
|
+
initiatedByThem: r.byThem,
|
|
676
|
+
ratio: Math.round((r.byMe / r.total) * 100) / 100,
|
|
677
|
+
}));
|
|
678
|
+
const conversationDepth = topDepth.map((r) => ({
|
|
679
|
+
jid: r.jid,
|
|
680
|
+
name: name(r.jid),
|
|
681
|
+
avgMessagesPerSession: r.avgDepth,
|
|
682
|
+
totalSessions: r.sessions,
|
|
683
|
+
}));
|
|
684
|
+
const ghostScore = topGhost.map((r) => ({
|
|
685
|
+
jid: r.jid,
|
|
686
|
+
name: name(r.jid),
|
|
687
|
+
ghostedCount: r.ghosted,
|
|
688
|
+
totalSent: r.totalSent,
|
|
689
|
+
ghostRate: Math.round((r.ghosted / r.totalSent) * 100) / 100,
|
|
690
|
+
}));
|
|
691
|
+
const lateNightTexters = topLateNight.map((r) => ({
|
|
692
|
+
jid: r.jid,
|
|
693
|
+
name: name(r.jid),
|
|
694
|
+
lateNight: r.lateNight,
|
|
695
|
+
workHours: r.workHours,
|
|
696
|
+
otherHours: r.other,
|
|
697
|
+
totalMessages: r.total,
|
|
698
|
+
lateNightPct: Math.round((r.lateNight / r.total) * 100),
|
|
699
|
+
}));
|
|
700
|
+
const relationshipTrajectory = topTrajectory.map((r) => {
|
|
701
|
+
const sorted = [...r.monthCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
702
|
+
const trend = sorted.map(([month, count]) => ({ month, count }));
|
|
703
|
+
return {
|
|
704
|
+
jid: r.jid,
|
|
705
|
+
name: name(r.jid),
|
|
706
|
+
trend,
|
|
707
|
+
direction: computeTrajectoryDirection(trend),
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
initiationRatio,
|
|
712
|
+
conversationDepth,
|
|
713
|
+
ghostScore,
|
|
714
|
+
relationshipTrajectory,
|
|
715
|
+
lateNightTexters,
|
|
716
|
+
scan: scanCoverage(rows.length, CONVERSATION_DYNAMICS_MAX_ROWS),
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Hour × weekday grid: the week's rhythm as one picture.
|
|
721
|
+
*/
|
|
722
|
+
export function getWeeklyRhythm(params, db = getDb()) {
|
|
723
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
724
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
725
|
+
const rows = db.prepare('SELECT ts FROM messages WHERE ts >= @since').all({ since });
|
|
726
|
+
const counts = new Array(7 * 24).fill(0);
|
|
727
|
+
for (const row of rows) {
|
|
728
|
+
const { weekday, hour } = weekdayHourInTimezone(row.ts, timeZone);
|
|
729
|
+
counts[weekday * 24 + hour] += 1;
|
|
730
|
+
}
|
|
731
|
+
const cells = counts.map((count, index) => ({ day: Math.floor(index / 24), hour: index % 24, count }));
|
|
732
|
+
const peak = cells.reduce((best, cell) => (cell.count > (best?.count ?? 0) ? cell : best), null);
|
|
733
|
+
return { cells, peak, total: rows.length };
|
|
348
734
|
}
|
|
349
735
|
export const statsRouter = Router();
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
statsRouter.get('/
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
statsRouter.get('/
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
statsRouter.get('/sent-received-ratio', (
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
statsRouter.get('/
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
statsRouter.get('/
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
statsRouter.get('/
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
statsRouter.get('/
|
|
387
|
-
|
|
388
|
-
|
|
736
|
+
/**
|
|
737
|
+
* Wrap a stats query in the archive-fingerprinted cache.
|
|
738
|
+
*/
|
|
739
|
+
function cachedStats(route, compute) {
|
|
740
|
+
return (req, res) => {
|
|
741
|
+
const key = statsCacheKey(route, req.query);
|
|
742
|
+
const etag = etagFor(key);
|
|
743
|
+
res.setHeader('Cache-Control', 'private, no-cache');
|
|
744
|
+
res.setHeader('ETag', etag);
|
|
745
|
+
if (req.headers['if-none-match'] === etag) {
|
|
746
|
+
res.status(304).end();
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
res.json(getCached(key, () => compute(req.query)));
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
statsRouter.get('/overview', cachedStats('overview', () => getOverviewStats()));
|
|
753
|
+
statsRouter.get('/top-contacts', cachedStats('top-contacts', (query) => getTopContacts(query)));
|
|
754
|
+
statsRouter.get('/message-volume', cachedStats('message-volume', (query) => getMessageVolume(query)));
|
|
755
|
+
statsRouter.get('/activity-heatmap', cachedStats('activity-heatmap', (query) => getActivityHeatmap(query)));
|
|
756
|
+
statsRouter.get('/hour-of-day', cachedStats('hour-of-day', (query) => getHourOfDayStats(query)));
|
|
757
|
+
statsRouter.get('/day-of-week', cachedStats('day-of-week', (query) => getDayOfWeekStats(query)));
|
|
758
|
+
statsRouter.get('/media-breakdown', cachedStats('media-breakdown', (query) => getMediaBreakdown(query)));
|
|
759
|
+
statsRouter.get('/media-senders', cachedStats('media-senders', (query) => getMediaSenders(query)));
|
|
760
|
+
statsRouter.get('/sent-received-ratio', cachedStats('sent-received-ratio', (query) => getSentReceivedRatio(query)));
|
|
761
|
+
statsRouter.get('/response-times', cachedStats('response-times', (query) => getResponseTimes(query)));
|
|
762
|
+
statsRouter.get('/reply-latency', cachedStats('reply-latency', (query) => getReplyLatency(query)));
|
|
763
|
+
statsRouter.get('/group-activity', cachedStats('group-activity', (query) => getGroupActivity(query)));
|
|
764
|
+
statsRouter.get('/streaks', cachedStats('streaks', (query) => getMessageStreaks(query)));
|
|
765
|
+
statsRouter.get('/word-cloud', cachedStats('word-cloud', (query) => getWordCloud(query)));
|
|
766
|
+
statsRouter.get('/emoji-analytics', cachedStats('emoji-analytics', (query) => getEmojiAnalytics(query)));
|
|
767
|
+
statsRouter.get('/conversation-dynamics', cachedStats('conversation-dynamics', (query) => getConversationDynamics(query)));
|
|
768
|
+
statsRouter.get('/year-in-review', cachedStats('year-in-review', (query) => getYearInReview(query)));
|
|
769
|
+
statsRouter.get('/contact-profile', cachedStats('contact-profile', (query) => getContactProfile(query)));
|
|
770
|
+
statsRouter.get('/dormancy', cachedStats('dormancy', (query) => getDormancy(query)));
|
|
771
|
+
statsRouter.get('/group-profile', cachedStats('group-profile', (query) => getGroupProfile(query)));
|
|
772
|
+
statsRouter.get('/groups', cachedStats('groups', (query) => getGroupDirectory(query)));
|
|
773
|
+
statsRouter.get('/link-intelligence', cachedStats('link-intelligence', (query) => getLinkIntelligence(query)));
|
|
774
|
+
statsRouter.get('/weekly-rhythm', cachedStats('weekly-rhythm', (query) => getWeeklyRhythm(query)));
|
|
775
|
+
statsRouter.get('/distinctive-words', cachedStats('distinctive-words', (query) => getDistinctiveWords(query)));
|
|
776
|
+
statsRouter.get('/group-lifecycle', cachedStats('group-lifecycle', (query) => getGroupLifecycle(query)));
|
|
777
|
+
statsRouter.get('/archived-chats', cachedStats('archived-chats', (query) => getArchivedChats(query)));
|