wacrawl-dashboard 0.1.2 → 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 -105
- 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 +20 -0
- 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/selfIdentity.js +42 -0
- package/apps/api/dist/lib/statsCache.js +83 -0
- package/apps/api/dist/lib/timezone.js +102 -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 +141 -77
- 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 +273 -323
- 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 +20 -4
- package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
- package/apps/web/dist/assets/index-DJiLhgJZ.css +0 -1
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { getDb } from '../db.js';
|
|
3
|
-
import { cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../lib/displayName.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
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, };
|
|
8
12
|
export function getOverviewStats(db = getDb()) {
|
|
9
13
|
const messageRow = db.prepare(`
|
|
10
14
|
SELECT
|
|
@@ -86,7 +90,7 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
86
90
|
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
|
|
87
91
|
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS sentByThem
|
|
88
92
|
FROM normalized_messages
|
|
89
|
-
WHERE jid
|
|
93
|
+
WHERE ${isPersonJidSql('jid')}
|
|
90
94
|
GROUP BY jid
|
|
91
95
|
ORDER BY messageCount DESC
|
|
92
96
|
LIMIT @limit
|
|
@@ -105,7 +109,7 @@ export function getMessageVolume(params, db = getDb()) {
|
|
|
105
109
|
const fallbackGranularity = period === 'year' || period === 'all' ? 'month' : 'day';
|
|
106
110
|
const granularity = parseGranularity(params.granularity, fallbackGranularity);
|
|
107
111
|
const since = sinceTimestamp(period);
|
|
108
|
-
const bucketSql = bucketDateSql(granularity);
|
|
112
|
+
const bucketSql = bucketDateSql(granularity, resolveStatsTimeZone(params.timeZone));
|
|
109
113
|
const rows = db.prepare(`
|
|
110
114
|
SELECT
|
|
111
115
|
${bucketSql} AS date,
|
|
@@ -124,9 +128,10 @@ export function getMessageVolume(params, db = getDb()) {
|
|
|
124
128
|
}
|
|
125
129
|
export function getActivityHeatmap(params, db = getDb()) {
|
|
126
130
|
const year = parseYear(params.year);
|
|
127
|
-
const
|
|
131
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
132
|
+
const bounds = yearBounds(year, timeZone);
|
|
128
133
|
return db.prepare(`
|
|
129
|
-
SELECT date(
|
|
134
|
+
SELECT date(${tsModifierSql(timeZone)}) AS date, COUNT(*) AS count
|
|
130
135
|
FROM messages
|
|
131
136
|
WHERE ts >= @start AND ts < @end
|
|
132
137
|
GROUP BY date
|
|
@@ -136,6 +141,15 @@ export function getActivityHeatmap(params, db = getDb()) {
|
|
|
136
141
|
export function getHourOfDayStats(params, db = getDb()) {
|
|
137
142
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
138
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
|
+
}
|
|
139
153
|
const rows = db.prepare(`
|
|
140
154
|
SELECT ts
|
|
141
155
|
FROM messages
|
|
@@ -157,7 +171,7 @@ export function getDayOfWeekStats(params, db = getDb()) {
|
|
|
157
171
|
const labels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
158
172
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
159
173
|
const rows = db.prepare(`
|
|
160
|
-
SELECT strftime('%w',
|
|
174
|
+
SELECT strftime('%w', ${tsModifierSql(resolveStatsTimeZone(params.timeZone))}) AS day, COUNT(*) AS count
|
|
161
175
|
FROM messages
|
|
162
176
|
WHERE ts >= @since
|
|
163
177
|
GROUP BY day
|
|
@@ -187,9 +201,9 @@ export function getMediaSenders(params, db = getDb()) {
|
|
|
187
201
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
188
202
|
const limit = parseLimit(params.limit, 10, 50);
|
|
189
203
|
const rows = db.prepare(`
|
|
190
|
-
|
|
204
|
+
WITH normalized AS (
|
|
191
205
|
SELECT
|
|
192
|
-
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,
|
|
193
207
|
${cleanDisplayNameSql('CASE WHEN from_me = 1 THEN COALESCE(chats.name, chat_name) ELSE sender_name END')} AS message_name,
|
|
194
208
|
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
195
209
|
media_size
|
|
@@ -204,7 +218,7 @@ export function getMediaSenders(params, db = getDb()) {
|
|
|
204
218
|
COUNT(*) AS mediaCount,
|
|
205
219
|
COALESCE(SUM(media_size), 0) AS totalBytes
|
|
206
220
|
FROM normalized
|
|
207
|
-
WHERE jid
|
|
221
|
+
WHERE ${isPersonJidSql('jid')}
|
|
208
222
|
GROUP BY jid
|
|
209
223
|
ORDER BY mediaCount DESC, name COLLATE NOCASE ASC
|
|
210
224
|
LIMIT @limit
|
|
@@ -220,7 +234,7 @@ export function getSentReceivedRatio(params, db = getDb()) {
|
|
|
220
234
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
221
235
|
const rows = db.prepare(`
|
|
222
236
|
SELECT
|
|
223
|
-
strftime('%Y-%m-01',
|
|
237
|
+
strftime('%Y-%m-01', ${tsModifierSql(resolveStatsTimeZone(params.timeZone))}) AS month,
|
|
224
238
|
SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sent,
|
|
225
239
|
SUM(CASE WHEN from_me = 0 THEN 1 ELSE 0 END) AS received
|
|
226
240
|
FROM messages
|
|
@@ -235,43 +249,148 @@ export function getSentReceivedRatio(params, db = getDb()) {
|
|
|
235
249
|
ratio: row.received ? Number((row.sent / row.received).toFixed(2)) : null,
|
|
236
250
|
}));
|
|
237
251
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
}
|
|
241
279
|
const rows = db.prepare(`
|
|
242
|
-
|
|
280
|
+
SELECT jid, responder_from_me, gap
|
|
281
|
+
FROM (
|
|
243
282
|
SELECT
|
|
244
|
-
chat_jid,
|
|
245
|
-
|
|
246
|
-
ts,
|
|
247
|
-
from_me,
|
|
248
|
-
|
|
249
|
-
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
|
|
250
288
|
FROM messages
|
|
251
289
|
WHERE ts >= @since
|
|
252
290
|
)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
COALESCE(${cleanDisplayNameSql('MAX(chats.name)')}, ${cleanDisplayNameSql('MAX(chat_name)')}, ${contactDisplayNameSql('chat_contacts')}, ${cleanDisplayNameSql('chat_jid')}, 'Unknown') AS name,
|
|
256
|
-
COUNT(*) AS responseCount,
|
|
257
|
-
ROUND(AVG(ts - previous_ts)) AS averageSeconds
|
|
258
|
-
FROM ordered
|
|
259
|
-
LEFT JOIN chats ON chats.jid = ordered.chat_jid
|
|
260
|
-
${contactLeftJoins('chat_contacts', 'ordered.chat_jid')}
|
|
261
|
-
WHERE previous_ts IS NOT NULL
|
|
291
|
+
WHERE gap IS NOT NULL
|
|
292
|
+
AND gap > 0
|
|
262
293
|
AND previous_from_me IS NOT NULL
|
|
263
|
-
AND previous_from_me <>
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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,
|
|
274
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
|
+
};
|
|
275
394
|
}
|
|
276
395
|
export function getGroupActivity(params, db = getDb()) {
|
|
277
396
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
@@ -298,8 +417,9 @@ export function getGroupActivity(params, db = getDb()) {
|
|
|
298
417
|
}
|
|
299
418
|
export function getMessageStreaks(params, db = getDb()) {
|
|
300
419
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
420
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
301
421
|
const rows = db.prepare(`
|
|
302
|
-
SELECT DISTINCT date(
|
|
422
|
+
SELECT DISTINCT date(${tsModifierSql(timeZone)}) AS date
|
|
303
423
|
FROM messages
|
|
304
424
|
WHERE ts >= @since
|
|
305
425
|
ORDER BY date ASC
|
|
@@ -313,8 +433,7 @@ export function getMessageStreaks(params, db = getDb()) {
|
|
|
313
433
|
longestStreak = Math.max(longestStreak, activeRun);
|
|
314
434
|
previousTime = time;
|
|
315
435
|
}
|
|
316
|
-
const
|
|
317
|
-
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`);
|
|
318
437
|
const lastTime = rows.length ? Date.parse(`${rows[rows.length - 1].date}T00:00:00.000Z`) : null;
|
|
319
438
|
const currentStreak = lastTime != null && todayUtc - lastTime <= 86_400_000 ? activeRun : 0;
|
|
320
439
|
return { currentStreak, longestStreak };
|
|
@@ -322,6 +441,8 @@ export function getMessageStreaks(params, db = getDb()) {
|
|
|
322
441
|
function parseWordCloudFilter(value) {
|
|
323
442
|
return value === 'useful' ? 'useful' : 'all';
|
|
324
443
|
}
|
|
444
|
+
/** Messages scanned for terms; the newest ones, when the archive is larger. */
|
|
445
|
+
const WORD_CLOUD_MAX_ROWS = 30_000;
|
|
325
446
|
export function getWordCloud(params, db = getDb()) {
|
|
326
447
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
327
448
|
const limit = parseLimit(params.limit, 50, 200);
|
|
@@ -331,26 +452,24 @@ export function getWordCloud(params, db = getDb()) {
|
|
|
331
452
|
FROM messages
|
|
332
453
|
WHERE ts >= @since AND text IS NOT NULL AND TRIM(text) <> ''
|
|
333
454
|
ORDER BY ts DESC
|
|
334
|
-
LIMIT
|
|
455
|
+
LIMIT ${WORD_CLOUD_MAX_ROWS}
|
|
335
456
|
`).all({ since });
|
|
336
457
|
const counts = new Map();
|
|
337
458
|
for (const row of rows) {
|
|
338
|
-
for (const term of (row.text ?? '').toLowerCase().match(
|
|
339
|
-
if (filterMode === 'useful')
|
|
340
|
-
|
|
341
|
-
continue;
|
|
342
|
-
if (USEFUL_WORD_STOP_SET.has(term))
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
459
|
+
for (const term of (row.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
|
|
460
|
+
if (filterMode === 'useful' && isNoiseToken(term))
|
|
461
|
+
continue;
|
|
345
462
|
counts.set(term, (counts.get(term) ?? 0) + 1);
|
|
346
463
|
}
|
|
347
464
|
}
|
|
348
|
-
|
|
465
|
+
const terms = [...counts.entries()]
|
|
349
466
|
.map(([text, value]) => ({ text, value }))
|
|
350
467
|
.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text))
|
|
351
468
|
.slice(0, limit);
|
|
469
|
+
return { terms, scan: scanCoverage(rows.length, WORD_CLOUD_MAX_ROWS) };
|
|
352
470
|
}
|
|
353
|
-
|
|
471
|
+
/** Text messages scanned for emoji; the newest ones, when the archive is larger. */
|
|
472
|
+
const EMOJI_MAX_ROWS = 50_000;
|
|
354
473
|
export function getEmojiAnalytics(params, db = getDb()) {
|
|
355
474
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
356
475
|
const limit = parseLimit(params.limit, 15, 50);
|
|
@@ -358,7 +477,7 @@ export function getEmojiAnalytics(params, db = getDb()) {
|
|
|
358
477
|
SELECT
|
|
359
478
|
messages.text,
|
|
360
479
|
messages.from_me,
|
|
361
|
-
CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END AS jid,
|
|
480
|
+
${canonicalJidSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')} AS jid,
|
|
362
481
|
${cleanDisplayNameSql(`CASE WHEN messages.from_me = 1 THEN COALESCE(chats.name, messages.chat_name) ELSE messages.sender_name END`)} AS name,
|
|
363
482
|
${contactDisplayNameSql('contacts')} AS contact_name
|
|
364
483
|
FROM messages
|
|
@@ -366,7 +485,7 @@ export function getEmojiAnalytics(params, db = getDb()) {
|
|
|
366
485
|
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
367
486
|
WHERE messages.ts >= @since AND messages.text IS NOT NULL AND messages.text <> ''
|
|
368
487
|
ORDER BY messages.ts DESC
|
|
369
|
-
LIMIT
|
|
488
|
+
LIMIT ${EMOJI_MAX_ROWS}
|
|
370
489
|
`).all({ since });
|
|
371
490
|
const allCounts = new Map();
|
|
372
491
|
const sentCounts = new Map();
|
|
@@ -379,11 +498,15 @@ export function getEmojiAnalytics(params, db = getDb()) {
|
|
|
379
498
|
continue;
|
|
380
499
|
totalEmojiCount += emojis.length;
|
|
381
500
|
const jid = row.jid ?? 'unknown';
|
|
501
|
+
const isPerson = !jid.endsWith('@g.us') && !jid.endsWith('@status') && !jid.endsWith('@broadcast');
|
|
382
502
|
const displayName = row.name ?? row.contact_name ?? jid;
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
385
509
|
}
|
|
386
|
-
const userEntry = userCounts.get(jid);
|
|
387
510
|
for (const emoji of emojis) {
|
|
388
511
|
allCounts.set(emoji, (allCounts.get(emoji) ?? 0) + 1);
|
|
389
512
|
if (row.from_me) {
|
|
@@ -392,8 +515,10 @@ export function getEmojiAnalytics(params, db = getDb()) {
|
|
|
392
515
|
else {
|
|
393
516
|
receivedCounts.set(emoji, (receivedCounts.get(emoji) ?? 0) + 1);
|
|
394
517
|
}
|
|
395
|
-
userEntry
|
|
396
|
-
|
|
518
|
+
if (userEntry) {
|
|
519
|
+
userEntry.count += 1;
|
|
520
|
+
userEntry.emojis.set(emoji, (userEntry.emojis.get(emoji) ?? 0) + 1);
|
|
521
|
+
}
|
|
397
522
|
}
|
|
398
523
|
}
|
|
399
524
|
const sortMap = (map, n) => [...map.entries()]
|
|
@@ -414,33 +539,11 @@ export function getEmojiAnalytics(params, db = getDb()) {
|
|
|
414
539
|
totalEmojiCount,
|
|
415
540
|
uniqueEmojiCount: allCounts.size,
|
|
416
541
|
topEmojiUsers,
|
|
542
|
+
scan: scanCoverage(rows.length, EMOJI_MAX_ROWS),
|
|
417
543
|
};
|
|
418
544
|
}
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
function resolveChatNames(jids, db) {
|
|
422
|
-
const map = new Map();
|
|
423
|
-
if (jids.length === 0)
|
|
424
|
-
return map;
|
|
425
|
-
const stmt = db.prepare(`
|
|
426
|
-
SELECT
|
|
427
|
-
c.jid,
|
|
428
|
-
COALESCE(
|
|
429
|
-
${cleanDisplayNameSql('c.name')},
|
|
430
|
-
${contactDisplayNameSql('chat_contacts')},
|
|
431
|
-
${cleanDisplayNameSql('c.jid')},
|
|
432
|
-
'Unknown'
|
|
433
|
-
) AS name
|
|
434
|
-
FROM chats c
|
|
435
|
-
${contactLeftJoins('chat_contacts', 'c.jid')}
|
|
436
|
-
WHERE c.jid = @jid
|
|
437
|
-
`);
|
|
438
|
-
for (const jid of jids) {
|
|
439
|
-
const row = stmt.get({ jid });
|
|
440
|
-
map.set(jid, row?.name ?? jid);
|
|
441
|
-
}
|
|
442
|
-
return map;
|
|
443
|
-
}
|
|
545
|
+
/** Upper bound on rows pulled into memory for session analysis (3 narrow columns each). */
|
|
546
|
+
const CONVERSATION_DYNAMICS_MAX_ROWS = 250_000;
|
|
444
547
|
function computeTrajectoryDirection(trend) {
|
|
445
548
|
if (trend.length < 4)
|
|
446
549
|
return 'stable';
|
|
@@ -458,11 +561,17 @@ export function getConversationDynamics(params, db = getDb()) {
|
|
|
458
561
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
459
562
|
const limit = parseLimit(params.limit, 10, 50);
|
|
460
563
|
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
564
|
+
const ghostCutoff = Math.floor(Date.now() / 1000) - GHOST_WINDOW_SECONDS;
|
|
461
565
|
const rows = db.prepare(`
|
|
462
|
-
SELECT
|
|
463
|
-
FROM
|
|
464
|
-
|
|
465
|
-
|
|
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
|
|
466
575
|
`).all({ since });
|
|
467
576
|
const chatGroups = new Map();
|
|
468
577
|
for (const row of rows) {
|
|
@@ -505,18 +614,17 @@ export function getConversationDynamics(params, db = getDb()) {
|
|
|
505
614
|
rawDepth.push({ jid, avgDepth: Math.round((messages.length / totalSessions) * 10) / 10, sessions: totalSessions, total: messages.length });
|
|
506
615
|
let ghosted = 0;
|
|
507
616
|
let totalSent = 0;
|
|
508
|
-
|
|
509
|
-
|
|
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;
|
|
510
622
|
continue;
|
|
511
|
-
totalSent++;
|
|
512
|
-
let replied = false;
|
|
513
|
-
for (let j = i + 1; j < messages.length; j++) {
|
|
514
|
-
if (!messages[j].from_me) {
|
|
515
|
-
replied = messages[j].ts - messages[i].ts <= GHOST_WINDOW_SECONDS;
|
|
516
|
-
break;
|
|
517
|
-
}
|
|
518
623
|
}
|
|
519
|
-
if (
|
|
624
|
+
if (message.ts > ghostCutoff)
|
|
625
|
+
continue;
|
|
626
|
+
totalSent++;
|
|
627
|
+
if (nextReceivedTs === null || nextReceivedTs - message.ts > GHOST_WINDOW_SECONDS)
|
|
520
628
|
ghosted++;
|
|
521
629
|
}
|
|
522
630
|
if (totalSent > 0) {
|
|
@@ -537,8 +645,7 @@ export function getConversationDynamics(params, db = getDb()) {
|
|
|
537
645
|
rawLateNight.push({ jid, lateNight, workHours: workHrs, other, total: messages.length });
|
|
538
646
|
const monthCounts = new Map();
|
|
539
647
|
for (const msg of messages) {
|
|
540
|
-
const
|
|
541
|
-
const month = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
|
|
648
|
+
const month = monthInTimezone(msg.ts, timeZone);
|
|
542
649
|
monthCounts.set(month, (monthCounts.get(month) ?? 0) + 1);
|
|
543
650
|
}
|
|
544
651
|
rawTrajectory.push({ jid, monthCounts, total: messages.length });
|
|
@@ -600,228 +707,71 @@ export function getConversationDynamics(params, db = getDb()) {
|
|
|
600
707
|
direction: computeTrajectoryDirection(trend),
|
|
601
708
|
};
|
|
602
709
|
});
|
|
603
|
-
return {
|
|
710
|
+
return {
|
|
711
|
+
initiationRatio,
|
|
712
|
+
conversationDepth,
|
|
713
|
+
ghostScore,
|
|
714
|
+
relationshipTrajectory,
|
|
715
|
+
lateNightTexters,
|
|
716
|
+
scan: scanCoverage(rows.length, CONVERSATION_DYNAMICS_MAX_ROWS),
|
|
717
|
+
};
|
|
604
718
|
}
|
|
605
|
-
|
|
719
|
+
/**
|
|
720
|
+
* Hour × weekday grid: the week's rhythm as one picture.
|
|
721
|
+
*/
|
|
722
|
+
export function getWeeklyRhythm(params, db = getDb()) {
|
|
606
723
|
const since = sinceTimestamp(parsePeriod(params.period));
|
|
607
|
-
const
|
|
608
|
-
const rows = db.prepare(
|
|
609
|
-
|
|
610
|
-
messages.text,
|
|
611
|
-
messages.ts,
|
|
612
|
-
messages.from_me,
|
|
613
|
-
messages.media_type,
|
|
614
|
-
CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END AS jid,
|
|
615
|
-
${cleanDisplayNameSql(`CASE WHEN messages.from_me = 1 THEN COALESCE(chats.name, messages.chat_name) ELSE messages.sender_name END`)} AS name,
|
|
616
|
-
${contactDisplayNameSql('contacts')} AS contact_name
|
|
617
|
-
FROM messages
|
|
618
|
-
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
|
|
619
|
-
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
620
|
-
WHERE messages.ts >= @since
|
|
621
|
-
ORDER BY messages.ts ASC
|
|
622
|
-
`).all({ since });
|
|
623
|
-
// --- Domain leaderboard + categories ---
|
|
624
|
-
const domainCounts = new Map();
|
|
625
|
-
const linksByDate = new Map();
|
|
626
|
-
const contactLinks = new Map();
|
|
627
|
-
let totalLinks = 0;
|
|
628
|
-
for (const row of rows) {
|
|
629
|
-
const urls = extractUrls(row.text);
|
|
630
|
-
if (urls.length === 0)
|
|
631
|
-
continue;
|
|
632
|
-
totalLinks += urls.length;
|
|
633
|
-
const date = new Date(row.ts * 1000).toISOString().slice(0, 10);
|
|
634
|
-
linksByDate.set(date, (linksByDate.get(date) ?? 0) + urls.length);
|
|
635
|
-
const jid = row.jid ?? 'unknown';
|
|
636
|
-
const displayName = row.name ?? row.contact_name ?? jid;
|
|
637
|
-
if (!contactLinks.has(jid)) {
|
|
638
|
-
contactLinks.set(jid, { name: displayName, sent: 0, received: 0 });
|
|
639
|
-
}
|
|
640
|
-
const entry = contactLinks.get(jid);
|
|
641
|
-
if (row.from_me) {
|
|
642
|
-
entry.sent += urls.length;
|
|
643
|
-
}
|
|
644
|
-
else {
|
|
645
|
-
entry.received += urls.length;
|
|
646
|
-
}
|
|
647
|
-
for (const url of urls) {
|
|
648
|
-
const domain = parseDomain(url);
|
|
649
|
-
if (domain) {
|
|
650
|
-
domainCounts.set(domain, (domainCounts.get(domain) ?? 0) + 1);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
const domainLeaderboard = [...domainCounts.entries()]
|
|
655
|
-
.sort((a, b) => b[1] - a[1])
|
|
656
|
-
.slice(0, limit)
|
|
657
|
-
.map(([domain, count]) => ({ domain, count, category: categorizeDomain(domain) }));
|
|
658
|
-
const uniqueDomains = domainCounts.size;
|
|
659
|
-
// --- Link categories ---
|
|
660
|
-
const catCounts = new Map();
|
|
661
|
-
for (const [domain, count] of domainCounts) {
|
|
662
|
-
const cat = categorizeDomain(domain);
|
|
663
|
-
if (!catCounts.has(cat)) {
|
|
664
|
-
catCounts.set(cat, { count: 0, domains: new Map() });
|
|
665
|
-
}
|
|
666
|
-
const catEntry = catCounts.get(cat);
|
|
667
|
-
catEntry.count += count;
|
|
668
|
-
catEntry.domains.set(domain, (catEntry.domains.get(domain) ?? 0) + count);
|
|
669
|
-
}
|
|
670
|
-
const linkCategories = [...catCounts.entries()]
|
|
671
|
-
.sort((a, b) => b[1].count - a[1].count)
|
|
672
|
-
.map(([category, { count, domains }]) => {
|
|
673
|
-
const topDomain = [...domains.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
674
|
-
return { category: category, count, topDomain };
|
|
675
|
-
});
|
|
676
|
-
// --- Media timeline density ---
|
|
677
|
-
const timelineBuckets = new Map();
|
|
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);
|
|
678
727
|
for (const row of rows) {
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
timelineBuckets.set(date, { images: 0, videos: 0, audio: 0, links: 0 });
|
|
682
|
-
}
|
|
683
|
-
const bucket = timelineBuckets.get(date);
|
|
684
|
-
const mt = row.media_type?.toLowerCase() ?? '';
|
|
685
|
-
if (mt === 'image')
|
|
686
|
-
bucket.images++;
|
|
687
|
-
else if (mt === 'video')
|
|
688
|
-
bucket.videos++;
|
|
689
|
-
else if (mt === 'audio')
|
|
690
|
-
bucket.audio++;
|
|
691
|
-
const urls = extractUrls(row.text);
|
|
692
|
-
bucket.links += urls.length;
|
|
728
|
+
const { weekday, hour } = weekdayHourInTimezone(row.ts, timeZone);
|
|
729
|
+
counts[weekday * 24 + hour] += 1;
|
|
693
730
|
}
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
const
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
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 };
|
|
734
|
+
}
|
|
735
|
+
export const statsRouter = Router();
|
|
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;
|
|
708
748
|
}
|
|
709
|
-
|
|
710
|
-
if (row.from_me)
|
|
711
|
-
entry.mediaSent++;
|
|
712
|
-
else
|
|
713
|
-
entry.mediaReceived++;
|
|
714
|
-
}
|
|
715
|
-
const sharingAsymmetry = [...new Set([...contactMedia.keys(), ...contactLinks.keys()])]
|
|
716
|
-
.map((jid) => {
|
|
717
|
-
const media = contactMedia.get(jid) ?? { name: jid, mediaSent: 0, mediaReceived: 0 };
|
|
718
|
-
const links = contactLinks.get(jid) ?? { name: jid, sent: 0, received: 0 };
|
|
719
|
-
const name = media.name !== jid ? media.name : links.name;
|
|
720
|
-
return {
|
|
721
|
-
jid,
|
|
722
|
-
name,
|
|
723
|
-
mediaSent: media.mediaSent,
|
|
724
|
-
mediaReceived: media.mediaReceived,
|
|
725
|
-
linksSent: links.sent,
|
|
726
|
-
linksReceived: links.received,
|
|
727
|
-
};
|
|
728
|
-
})
|
|
729
|
-
.filter((s) => s.mediaSent + s.mediaReceived + s.linksSent + s.linksReceived > 0)
|
|
730
|
-
.sort((a, b) => (b.mediaSent + b.mediaReceived + b.linksSent + b.linksReceived) - (a.mediaSent + a.mediaReceived + a.linksSent + a.linksReceived))
|
|
731
|
-
.slice(0, limit);
|
|
732
|
-
// --- First shared per contact ---
|
|
733
|
-
const firstSharedRows = db.prepare(`
|
|
734
|
-
SELECT
|
|
735
|
-
m.chat_jid,
|
|
736
|
-
${cleanDisplayNameSql('COALESCE(chats.name, first_msg.chat_name)')} AS chat_name,
|
|
737
|
-
${contactDisplayNameSql('fcontacts')} AS contact_name,
|
|
738
|
-
first_msg.text AS first_text,
|
|
739
|
-
first_msg.ts AS first_ts,
|
|
740
|
-
first_media.media_type AS first_media_type,
|
|
741
|
-
first_media.ts AS first_media_ts
|
|
742
|
-
FROM (
|
|
743
|
-
SELECT chat_jid, MIN(rowid) AS first_rowid
|
|
744
|
-
FROM messages
|
|
745
|
-
WHERE ts >= @since
|
|
746
|
-
GROUP BY chat_jid
|
|
747
|
-
) m
|
|
748
|
-
LEFT JOIN messages first_msg ON first_msg.rowid = m.first_rowid
|
|
749
|
-
LEFT JOIN chats ON chats.jid = m.chat_jid
|
|
750
|
-
${contactLeftJoins('fcontacts', 'm.chat_jid')}
|
|
751
|
-
LEFT JOIN (
|
|
752
|
-
SELECT chat_jid, MIN(rowid) AS media_rowid
|
|
753
|
-
FROM messages
|
|
754
|
-
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
755
|
-
GROUP BY chat_jid
|
|
756
|
-
) fm ON fm.chat_jid = m.chat_jid
|
|
757
|
-
LEFT JOIN messages first_media ON first_media.rowid = fm.media_rowid
|
|
758
|
-
ORDER BY first_msg.ts ASC
|
|
759
|
-
LIMIT @limit
|
|
760
|
-
`).all({ since, limit });
|
|
761
|
-
const firstShared = firstSharedRows.map((row) => ({
|
|
762
|
-
jid: row.chat_jid,
|
|
763
|
-
name: row.chat_name ?? row.contact_name ?? row.chat_jid,
|
|
764
|
-
firstMessageText: row.first_text,
|
|
765
|
-
firstMessageDate: unixSecondsToIso(row.first_ts) ?? new Date(0).toISOString(),
|
|
766
|
-
firstMediaType: row.first_media_type,
|
|
767
|
-
firstMediaDate: row.first_media_ts ? unixSecondsToIso(row.first_media_ts) : null,
|
|
768
|
-
}));
|
|
769
|
-
return {
|
|
770
|
-
domainLeaderboard,
|
|
771
|
-
mediaTimeline,
|
|
772
|
-
sharingAsymmetry,
|
|
773
|
-
linkCategories,
|
|
774
|
-
firstShared,
|
|
775
|
-
totalLinks,
|
|
776
|
-
uniqueDomains,
|
|
749
|
+
res.json(getCached(key, () => compute(req.query)));
|
|
777
750
|
};
|
|
778
751
|
}
|
|
779
|
-
|
|
780
|
-
statsRouter.get('/
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
statsRouter.get('/
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
statsRouter.get('/
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
statsRouter.get('/
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
statsRouter.get('/
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
statsRouter.get('/
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
statsRouter.get('/
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
statsRouter.get('/
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
statsRouter.get('/
|
|
805
|
-
res.json(getSentReceivedRatio(req.query));
|
|
806
|
-
});
|
|
807
|
-
statsRouter.get('/response-times', (req, res) => {
|
|
808
|
-
res.json(getResponseTimes(req.query));
|
|
809
|
-
});
|
|
810
|
-
statsRouter.get('/group-activity', (req, res) => {
|
|
811
|
-
res.json(getGroupActivity(req.query));
|
|
812
|
-
});
|
|
813
|
-
statsRouter.get('/streaks', (req, res) => {
|
|
814
|
-
res.json(getMessageStreaks(req.query));
|
|
815
|
-
});
|
|
816
|
-
statsRouter.get('/word-cloud', (req, res) => {
|
|
817
|
-
res.json(getWordCloud(req.query));
|
|
818
|
-
});
|
|
819
|
-
statsRouter.get('/emoji-analytics', (req, res) => {
|
|
820
|
-
res.json(getEmojiAnalytics(req.query));
|
|
821
|
-
});
|
|
822
|
-
statsRouter.get('/conversation-dynamics', (req, res) => {
|
|
823
|
-
res.json(getConversationDynamics(req.query));
|
|
824
|
-
});
|
|
825
|
-
statsRouter.get('/link-intelligence', (req, res) => {
|
|
826
|
-
res.json(getLinkIntelligence(req.query));
|
|
827
|
-
});
|
|
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)));
|