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.
Files changed (46) hide show
  1. package/README.md +220 -89
  2. package/apps/api/dist/demo/demoArchive.js +371 -0
  3. package/apps/api/dist/demo/demoCast.js +138 -0
  4. package/apps/api/dist/demo/demoMedia.js +108 -0
  5. package/apps/api/dist/index.js +109 -29
  6. package/apps/api/dist/lib/archiveSchema.js +57 -0
  7. package/apps/api/dist/lib/archiveSync.js +151 -0
  8. package/apps/api/dist/lib/chatKind.js +41 -0
  9. package/apps/api/dist/lib/displayName.js +37 -3
  10. package/apps/api/dist/lib/mediaFsPath.js +27 -0
  11. package/apps/api/dist/lib/percentile.js +23 -0
  12. package/apps/api/dist/lib/query.js +33 -7
  13. package/apps/api/dist/lib/rollupDb.js +253 -0
  14. package/apps/api/dist/lib/searchFilters.js +141 -0
  15. package/apps/api/dist/lib/searchIndex.js +3 -3
  16. package/apps/api/dist/lib/selfIdentity.js +42 -0
  17. package/apps/api/dist/lib/statsCache.js +83 -0
  18. package/apps/api/dist/lib/timezone.js +120 -5
  19. package/apps/api/dist/lib/urlExtract.js +111 -0
  20. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  21. package/apps/api/dist/pathsResolve.js +17 -3
  22. package/apps/api/dist/routes/data.js +168 -78
  23. package/apps/api/dist/routes/settings.js +3 -0
  24. package/apps/api/dist/routes/stats/profiles.js +522 -0
  25. package/apps/api/dist/routes/stats/reports.js +555 -0
  26. package/apps/api/dist/routes/stats/shared.js +75 -0
  27. package/apps/api/dist/routes/stats.js +486 -97
  28. package/apps/api/dist/routes/sync.js +60 -0
  29. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  30. package/apps/api/package.json +31 -0
  31. package/apps/web/dist/apple-touch-icon.png +0 -0
  32. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  33. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  34. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  35. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  36. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  37. package/apps/web/dist/favicon-16x16.png +0 -0
  38. package/apps/web/dist/favicon-32x32.png +0 -0
  39. package/apps/web/dist/favicon-512x512.png +0 -0
  40. package/apps/web/dist/favicon.ico +0 -0
  41. package/apps/web/dist/favicon.svg +59 -0
  42. package/apps/web/dist/index.html +11 -2
  43. package/bin/wacrawl-dashboard.js +64 -15
  44. package/package.json +25 -4
  45. package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
  46. 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, contactMatchSql } from '../lib/displayName.js';
4
- import { bucketDateSql, normalizeBucketDate, parseGranularity, parseLimit, parsePeriod, parseYear, sinceTimestamp, unixSecondsToIso, yearBounds, } from '../lib/query.js';
5
- import { hourInTimezone, resolveStatsTimeZone } from '../lib/timezone.js';
6
- import { USEFUL_WORD_STOP_SET } from '../lib/wordCloudUsefulStopWords.js';
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
- contacts.jid,
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
- contacts.phone AS contact_phone,
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
- LEFT JOIN contacts ON ${contactMatchSql('contacts', `CASE
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 IS NOT NULL AND 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 bounds = yearBounds(year);
131
+ const timeZone = resolveStatsTimeZone(params.timeZone);
132
+ const bounds = yearBounds(year, timeZone);
126
133
  return db.prepare(`
127
- SELECT date(ts, 'unixepoch') AS date, COUNT(*) AS count
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', ts, 'unixepoch') AS day, COUNT(*) AS count
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
- WITH normalized AS (
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
- LEFT JOIN contacts ON ${contactMatchSql('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
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 IS NOT NULL AND 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', ts, 'unixepoch') AS month,
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
- export function getResponseTimes(params, db = getDb()) {
237
- const since = sinceTimestamp(parsePeriod(params.period));
238
- const limit = parseLimit(params.limit, 10, 50);
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
- WITH ordered AS (
280
+ SELECT jid, responder_from_me, gap
281
+ FROM (
241
282
  SELECT
242
- chat_jid,
243
- chat_name,
244
- ts,
245
- from_me,
246
- LAG(ts) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_ts,
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
- SELECT
252
- chat_jid AS jid,
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 <> from_me
262
- AND ts > previous_ts
263
- GROUP BY chat_jid
264
- ORDER BY averageSeconds ASC
265
- LIMIT @limit
266
- `).all({ since, limit });
267
- return rows.map((row) => ({
268
- jid: row.jid,
269
- name: row.name ?? row.jid,
270
- responseCount: row.responseCount,
271
- averageSeconds: row.averageSeconds ?? 0,
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(ts, 'unixepoch') AS 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 today = new Date();
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(/[a-z0-9]{3,}/g) ?? []) {
335
- if (filterMode === 'useful') {
336
- if (/^\d+$/.test(term))
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
- return [...counts.entries()]
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
- statsRouter.get('/overview', (_req, res) => {
351
- res.json(getOverviewStats());
352
- });
353
- statsRouter.get('/top-contacts', (req, res) => {
354
- res.json(getTopContacts(req.query));
355
- });
356
- statsRouter.get('/message-volume', (req, res) => {
357
- res.json(getMessageVolume(req.query));
358
- });
359
- statsRouter.get('/activity-heatmap', (req, res) => {
360
- res.json(getActivityHeatmap(req.query));
361
- });
362
- statsRouter.get('/hour-of-day', (req, res) => {
363
- res.json(getHourOfDayStats(req.query));
364
- });
365
- statsRouter.get('/day-of-week', (req, res) => {
366
- res.json(getDayOfWeekStats(req.query));
367
- });
368
- statsRouter.get('/media-breakdown', (req, res) => {
369
- res.json(getMediaBreakdown(req.query));
370
- });
371
- statsRouter.get('/media-senders', (req, res) => {
372
- res.json(getMediaSenders(req.query));
373
- });
374
- statsRouter.get('/sent-received-ratio', (req, res) => {
375
- res.json(getSentReceivedRatio(req.query));
376
- });
377
- statsRouter.get('/response-times', (req, res) => {
378
- res.json(getResponseTimes(req.query));
379
- });
380
- statsRouter.get('/group-activity', (req, res) => {
381
- res.json(getGroupActivity(req.query));
382
- });
383
- statsRouter.get('/streaks', (req, res) => {
384
- res.json(getMessageStreaks(req.query));
385
- });
386
- statsRouter.get('/word-cloud', (req, res) => {
387
- res.json(getWordCloud(req.query));
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)));