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.
Files changed (44) hide show
  1. package/README.md +220 -105
  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 +20 -0
  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/selfIdentity.js +42 -0
  16. package/apps/api/dist/lib/statsCache.js +83 -0
  17. package/apps/api/dist/lib/timezone.js +102 -0
  18. package/apps/api/dist/lib/wordCloudUsefulStopWords.js +36 -3
  19. package/apps/api/dist/pathsResolve.js +17 -3
  20. package/apps/api/dist/routes/data.js +141 -77
  21. package/apps/api/dist/routes/settings.js +3 -0
  22. package/apps/api/dist/routes/stats/profiles.js +522 -0
  23. package/apps/api/dist/routes/stats/reports.js +555 -0
  24. package/apps/api/dist/routes/stats/shared.js +75 -0
  25. package/apps/api/dist/routes/stats.js +273 -323
  26. package/apps/api/dist/routes/sync.js +60 -0
  27. package/apps/api/dist/scripts/generateDemoArchive.js +24 -0
  28. package/apps/api/package.json +31 -0
  29. package/apps/web/dist/apple-touch-icon.png +0 -0
  30. package/apps/web/dist/assets/charts-BMGh5n-d.js +36 -0
  31. package/apps/web/dist/assets/icons-BdElrSaU.js +1 -0
  32. package/apps/web/dist/assets/index-Bb8Zj42D.css +1 -0
  33. package/apps/web/dist/assets/index-wrxENU1e.js +9 -0
  34. package/apps/web/dist/assets/react-D3RL6ob4.js +3 -0
  35. package/apps/web/dist/favicon-16x16.png +0 -0
  36. package/apps/web/dist/favicon-32x32.png +0 -0
  37. package/apps/web/dist/favicon-512x512.png +0 -0
  38. package/apps/web/dist/favicon.ico +0 -0
  39. package/apps/web/dist/favicon.svg +59 -0
  40. package/apps/web/dist/index.html +11 -2
  41. package/bin/wacrawl-dashboard.js +64 -15
  42. package/package.json +20 -4
  43. package/apps/web/dist/assets/index-CFh_Wz98.js +0 -43
  44. 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 { 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';
7
- import { categorizeDomain, extractUrls, parseDomain } from '../lib/urlExtract.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, };
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 IS NOT NULL AND 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 bounds = yearBounds(year);
131
+ const timeZone = resolveStatsTimeZone(params.timeZone);
132
+ const bounds = yearBounds(year, timeZone);
128
133
  return db.prepare(`
129
- SELECT date(ts, 'unixepoch') AS date, COUNT(*) AS count
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', ts, 'unixepoch') AS day, COUNT(*) AS count
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
- WITH normalized AS (
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 IS NOT NULL AND 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', ts, 'unixepoch') AS month,
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
- export function getResponseTimes(params, db = getDb()) {
239
- const since = sinceTimestamp(parsePeriod(params.period));
240
- 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
+ }
241
279
  const rows = db.prepare(`
242
- WITH ordered AS (
280
+ SELECT jid, responder_from_me, gap
281
+ FROM (
243
282
  SELECT
244
- chat_jid,
245
- chat_name,
246
- ts,
247
- from_me,
248
- LAG(ts) OVER (PARTITION BY chat_jid ORDER BY ts, rowid) AS previous_ts,
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
- SELECT
254
- chat_jid AS jid,
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 <> from_me
264
- AND ts > previous_ts
265
- GROUP BY chat_jid
266
- ORDER BY averageSeconds ASC
267
- LIMIT @limit
268
- `).all({ since, limit });
269
- return rows.map((row) => ({
270
- jid: row.jid,
271
- name: row.name ?? row.jid,
272
- responseCount: row.responseCount,
273
- 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,
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(ts, 'unixepoch') AS 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 today = new Date();
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 30000
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(/[a-z0-9]{3,}/g) ?? []) {
339
- if (filterMode === 'useful') {
340
- if (/^\d+$/.test(term))
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
- return [...counts.entries()]
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
- const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
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 50000
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
- if (!userCounts.has(jid)) {
384
- userCounts.set(jid, { name: displayName, count: 0, emojis: new Map() });
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.count += 1;
396
- userEntry.emojis.set(emoji, (userEntry.emojis.get(emoji) ?? 0) + 1);
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
- const SESSION_GAP_SECONDS = 4 * 3600;
420
- const GHOST_WINDOW_SECONDS = 24 * 3600;
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 m.chat_jid, m.ts, m.from_me
463
- FROM messages m
464
- WHERE m.ts >= @since
465
- ORDER BY m.chat_jid, m.ts, m.rowid
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
- for (let i = 0; i < messages.length; i++) {
509
- if (!messages[i].from_me)
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 (!replied)
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 d = new Date(msg.ts * 1000);
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 { initiationRatio, conversationDepth, ghostScore, relationshipTrajectory, lateNightTexters };
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
- export function getLinkIntelligence(params, db = getDb()) {
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 limit = parseLimit(params.limit, 15, 50);
608
- const rows = db.prepare(`
609
- SELECT
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 date = new Date(row.ts * 1000).toISOString().slice(0, 10);
680
- if (!timelineBuckets.has(date)) {
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 mediaTimeline = [...timelineBuckets.entries()]
695
- .filter(([, b]) => b.images + b.videos + b.audio + b.links > 0)
696
- .sort((a, b) => a[0].localeCompare(b[0]))
697
- .map(([date, b]) => ({ date, ...b }));
698
- // --- Sharing asymmetry ---
699
- const contactMedia = new Map();
700
- for (const row of rows) {
701
- const mt = row.media_type?.toLowerCase() ?? '';
702
- if (!mt)
703
- continue;
704
- const jid = row.jid ?? 'unknown';
705
- const displayName = row.name ?? row.contact_name ?? jid;
706
- if (!contactMedia.has(jid)) {
707
- contactMedia.set(jid, { name: displayName, mediaSent: 0, mediaReceived: 0 });
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
- const entry = contactMedia.get(jid);
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
- export const statsRouter = Router();
780
- statsRouter.get('/overview', (_req, res) => {
781
- res.json(getOverviewStats());
782
- });
783
- statsRouter.get('/top-contacts', (req, res) => {
784
- res.json(getTopContacts(req.query));
785
- });
786
- statsRouter.get('/message-volume', (req, res) => {
787
- res.json(getMessageVolume(req.query));
788
- });
789
- statsRouter.get('/activity-heatmap', (req, res) => {
790
- res.json(getActivityHeatmap(req.query));
791
- });
792
- statsRouter.get('/hour-of-day', (req, res) => {
793
- res.json(getHourOfDayStats(req.query));
794
- });
795
- statsRouter.get('/day-of-week', (req, res) => {
796
- res.json(getDayOfWeekStats(req.query));
797
- });
798
- statsRouter.get('/media-breakdown', (req, res) => {
799
- res.json(getMediaBreakdown(req.query));
800
- });
801
- statsRouter.get('/media-senders', (req, res) => {
802
- res.json(getMediaSenders(req.query));
803
- });
804
- statsRouter.get('/sent-received-ratio', (req, res) => {
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)));