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
@@ -0,0 +1,522 @@
1
+ import { getDb } from '../../db.js';
2
+ import { tableExists } from '../../lib/chatKind.js';
3
+ import { canonicalJidSql, cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../../lib/displayName.js';
4
+ import { percentile } from '../../lib/percentile.js';
5
+ import { nameLikePattern, parseLimit, scanCoverage, unixSecondsToIso } from '../../lib/query.js';
6
+ import { resolveSelfJids } from '../../lib/selfIdentity.js';
7
+ import { dateInTimezone, firstQueryString, hourInTimezone, monthInTimezone, resolveStatsTimeZone, tsModifierSql, } from '../../lib/timezone.js';
8
+ import { ChatNotFoundError, DAY_SECONDS, EMOJI_REGEX, isNoiseToken, NotAGroupError, SESSION_GAP_SECONDS, summarizeGaps, topCounted, WORD_TOKEN_RE, } from './shared.js';
9
+ /** Messages of one chat pulled in for the profile's per-message analysis. */
10
+ const CONTACT_PROFILE_MAX_ROWS = 100_000;
11
+ /**
12
+ * Everything one relationship looks like, in a single response.
13
+ */
14
+ export function getContactProfile(params, db = getDb()) {
15
+ const jid = firstQueryString(params.jid);
16
+ if (!jid)
17
+ throw new ChatNotFoundError('');
18
+ const limit = parseLimit(params.limit, 15, 100);
19
+ const timeZone = resolveStatsTimeZone(params.timeZone);
20
+ const now = Math.floor(Date.now() / 1000);
21
+ const identity = db.prepare(`
22
+ SELECT
23
+ chats.jid AS jid,
24
+ chats.kind AS kind,
25
+ COALESCE(
26
+ ${cleanDisplayNameSql('chats.name')},
27
+ ${contactDisplayNameSql('chat_contacts')},
28
+ ${cleanDisplayNameSql('chats.jid')},
29
+ 'Unknown'
30
+ ) AS name,
31
+ COALESCE(chat_contacts_jid.phone, chat_contacts_lid.phone) AS phone
32
+ FROM chats
33
+ ${contactLeftJoins('chat_contacts', 'chats.jid')}
34
+ WHERE chats.jid = @jid
35
+ `).get({ jid });
36
+ if (!identity)
37
+ throw new ChatNotFoundError(jid);
38
+ const totalsRow = db.prepare(`
39
+ SELECT
40
+ COUNT(*) AS messages,
41
+ SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
42
+ SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS media,
43
+ MIN(ts) AS firstTs,
44
+ MAX(ts) AS lastTs,
45
+ COUNT(DISTINCT date(${tsModifierSql(timeZone)})) AS activeDays
46
+ FROM messages
47
+ WHERE chat_jid = @jid
48
+ `).get({ jid });
49
+ const mediaBreakdown = db.prepare(`
50
+ SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
51
+ FROM messages
52
+ WHERE chat_jid = @jid AND media_type IS NOT NULL AND media_type <> ''
53
+ GROUP BY media_type
54
+ ORDER BY count DESC, mediaType ASC
55
+ `).all({ jid }).map((row) => ({
56
+ mediaType: row.mediaType,
57
+ count: row.count,
58
+ totalBytes: row.totalBytes ?? 0,
59
+ }));
60
+ const messages = db.prepare(`
61
+ SELECT ts, from_me, text, media_type
62
+ FROM (
63
+ SELECT ts, from_me, text, media_type, rowid
64
+ FROM messages
65
+ WHERE chat_jid = @jid
66
+ ORDER BY ts DESC, rowid DESC
67
+ LIMIT ${CONTACT_PROFILE_MAX_ROWS}
68
+ )
69
+ ORDER BY ts ASC, rowid ASC
70
+ `).all({ jid });
71
+ const monthly = new Map();
72
+ const hourCounts = new Array(24).fill(0);
73
+ const wordCounts = new Map();
74
+ const emojiCounts = new Map();
75
+ const mineGaps = [];
76
+ const theirGaps = [];
77
+ const initiation = { byMe: 0, byThem: 0 };
78
+ const lastWord = { mine: 0, theirs: 0 };
79
+ let longestSilence = null;
80
+ for (let i = 0; i < messages.length; i += 1) {
81
+ const message = messages[i];
82
+ const previous = i > 0 ? messages[i - 1] : null;
83
+ const month = monthInTimezone(message.ts, timeZone);
84
+ let bucket = monthly.get(month);
85
+ if (!bucket) {
86
+ bucket = { sent: 0, received: 0 };
87
+ monthly.set(month, bucket);
88
+ }
89
+ if (message.from_me)
90
+ bucket.sent += 1;
91
+ else
92
+ bucket.received += 1;
93
+ hourCounts[hourInTimezone(message.ts, timeZone)] += 1;
94
+ for (const term of (message.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
95
+ if (isNoiseToken(term))
96
+ continue;
97
+ wordCounts.set(term, (wordCounts.get(term) ?? 0) + 1);
98
+ }
99
+ for (const emoji of (message.text ?? '').match(EMOJI_REGEX) ?? []) {
100
+ emojiCounts.set(emoji, (emojiCounts.get(emoji) ?? 0) + 1);
101
+ }
102
+ if (previous === null || message.ts - previous.ts >= SESSION_GAP_SECONDS) {
103
+ if (message.from_me)
104
+ initiation.byMe += 1;
105
+ else
106
+ initiation.byThem += 1;
107
+ if (previous !== null) {
108
+ if (previous.from_me)
109
+ lastWord.mine += 1;
110
+ else
111
+ lastWord.theirs += 1;
112
+ const gapDays = Math.floor((message.ts - previous.ts) / DAY_SECONDS);
113
+ if (longestSilence === null || gapDays > longestSilence.days) {
114
+ longestSilence = {
115
+ days: gapDays,
116
+ startedAt: unixSecondsToIso(previous.ts),
117
+ endedAt: unixSecondsToIso(message.ts),
118
+ };
119
+ }
120
+ }
121
+ }
122
+ else if (previous.from_me !== message.from_me) {
123
+ (message.from_me ? mineGaps : theirGaps).push(message.ts - previous.ts);
124
+ }
125
+ }
126
+ if (messages.length > 0) {
127
+ const final = messages[messages.length - 1];
128
+ if (final.from_me)
129
+ lastWord.mine += 1;
130
+ else
131
+ lastWord.theirs += 1;
132
+ }
133
+ const side = (gaps) => {
134
+ const { median, p90 } = summarizeGaps(gaps);
135
+ return { count: gaps.length, medianSeconds: median, p90Seconds: p90 };
136
+ };
137
+ return {
138
+ jid: identity.jid,
139
+ name: identity.name ?? identity.jid,
140
+ kind: identity.kind,
141
+ phone: identity.phone?.trim() ? identity.phone.trim() : null,
142
+ totals: {
143
+ messages: totalsRow.messages,
144
+ sentByMe: totalsRow.sentByMe ?? 0,
145
+ sentByThem: totalsRow.messages - (totalsRow.sentByMe ?? 0),
146
+ media: totalsRow.media ?? 0,
147
+ firstMessageAt: unixSecondsToIso(totalsRow.firstTs),
148
+ lastMessageAt: unixSecondsToIso(totalsRow.lastTs),
149
+ daysSinceLastMessage: totalsRow.lastTs == null ? null : Math.floor((now - totalsRow.lastTs) / DAY_SECONDS),
150
+ activeDays: totalsRow.activeDays,
151
+ },
152
+ monthlyVolume: [...monthly.entries()]
153
+ .sort((a, b) => a[0].localeCompare(b[0]))
154
+ .map(([month, counts]) => ({ month, ...counts })),
155
+ hourOfDay: hourCounts
156
+ .map((count, hour) => ({ hour, count }))
157
+ .filter((point) => point.count > 0),
158
+ latency: { mine: side(mineGaps), theirs: side(theirGaps) },
159
+ initiation,
160
+ lastWord,
161
+ longestSilence,
162
+ mediaBreakdown,
163
+ topWords: topCounted(wordCounts, limit, (text, value) => ({ text, value })),
164
+ topEmojis: topCounted(emojiCounts, limit, (emoji, count) => ({ emoji, count })),
165
+ scan: scanCoverage(messages.length, CONTACT_PROFILE_MAX_ROWS),
166
+ };
167
+ }
168
+ /** Messages of one group pulled in for the per-message analysis. */
169
+ const GROUP_PROFILE_MAX_ROWS = 100_000;
170
+ /** Roster rows are cheap, but a 900-member group should not ship 900 objects. */
171
+ const GROUP_QUIET_MEMBER_LIMIT = 50;
172
+ function isNamed(member) {
173
+ return member.name !== null && member.name !== member.jid;
174
+ }
175
+ function mergeRoster(rows) {
176
+ const merged = new Map();
177
+ for (const row of rows) {
178
+ const existing = merged.get(row.jid);
179
+ if (!existing) {
180
+ merged.set(row.jid, {
181
+ jid: row.jid,
182
+ rawJids: [row.rawJid],
183
+ name: row.name,
184
+ isAdmin: row.isAdmin === 1,
185
+ isActive: row.isActive === 1,
186
+ });
187
+ continue;
188
+ }
189
+ existing.rawJids.push(row.rawJid);
190
+ existing.name ??= row.name;
191
+ existing.isAdmin ||= row.isAdmin === 1;
192
+ existing.isActive ||= row.isActive === 1;
193
+ }
194
+ return merged;
195
+ }
196
+ /**
197
+ * Everything one group looks like, in a single response.
198
+ */
199
+ export function getGroupProfile(params, db = getDb()) {
200
+ const jid = firstQueryString(params.jid);
201
+ if (!jid)
202
+ throw new ChatNotFoundError('');
203
+ const limit = parseLimit(params.limit, 15, 100);
204
+ const timeZone = resolveStatsTimeZone(params.timeZone);
205
+ const now = Math.floor(Date.now() / 1000);
206
+ const hasRoster = tableExists(db, 'group_participants');
207
+ const hasGroupsTable = tableExists(db, 'groups');
208
+ const chat = db.prepare(`
209
+ SELECT
210
+ chats.jid AS jid,
211
+ chats.kind AS kind,
212
+ COALESCE(${cleanDisplayNameSql('chats.name')}, ${cleanDisplayNameSql('chats.jid')}, 'Unknown') AS name
213
+ FROM chats
214
+ WHERE chats.jid = @jid
215
+ `).get({ jid });
216
+ if (!chat)
217
+ throw new ChatNotFoundError(jid);
218
+ if (chat.kind !== 'group')
219
+ throw new NotAGroupError(jid);
220
+ const groupRow = hasGroupsTable
221
+ ? db.prepare(`
222
+ SELECT jid, ${cleanDisplayNameSql('name')} AS name, owner_jid AS ownerJid, created_at AS createdAt
223
+ FROM groups
224
+ WHERE jid = @jid
225
+ `).get({ jid })
226
+ : undefined;
227
+ const totalsRow = db.prepare(`
228
+ SELECT
229
+ COUNT(*) AS messages,
230
+ SUM(CASE WHEN from_me = 1 THEN 1 ELSE 0 END) AS sentByMe,
231
+ SUM(CASE WHEN media_type IS NOT NULL AND media_type <> '' THEN 1 ELSE 0 END) AS media,
232
+ MIN(ts) AS firstTs,
233
+ MAX(ts) AS lastTs,
234
+ COUNT(DISTINCT date(${tsModifierSql(timeZone)})) AS activeDays
235
+ FROM messages
236
+ WHERE chat_jid = @jid
237
+ `).get({ jid });
238
+ const mediaBreakdown = db.prepare(`
239
+ SELECT media_type AS mediaType, COUNT(*) AS count, COALESCE(SUM(media_size), 0) AS totalBytes
240
+ FROM messages
241
+ WHERE chat_jid = @jid AND media_type IS NOT NULL AND media_type <> ''
242
+ GROUP BY media_type
243
+ ORDER BY count DESC, mediaType ASC
244
+ `).all({ jid }).map((row) => ({
245
+ mediaType: row.mediaType,
246
+ count: row.count,
247
+ totalBytes: row.totalBytes ?? 0,
248
+ }));
249
+ const speakers = db.prepare(`
250
+ SELECT
251
+ ${canonicalJidSql('sender_contacts', 'messages.sender_jid')} AS jid,
252
+ ${contactDisplayNameSql('sender_contacts')} AS contactName,
253
+ ${cleanDisplayNameSql('MAX(messages.sender_name)')} AS pushName,
254
+ COUNT(*) AS messageCount,
255
+ MIN(messages.ts) AS firstTs,
256
+ MAX(messages.ts) AS lastTs
257
+ FROM messages
258
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
259
+ WHERE messages.chat_jid = @jid
260
+ AND messages.from_me = 0
261
+ AND messages.sender_jid IS NOT NULL
262
+ AND messages.sender_jid <> messages.chat_jid
263
+ GROUP BY 1
264
+ ORDER BY messageCount DESC
265
+ `).all({ jid });
266
+ const roster = hasRoster
267
+ ? db.prepare(`
268
+ SELECT
269
+ ${canonicalJidSql('member_contacts', 'group_participants.user_jid')} AS jid,
270
+ group_participants.user_jid AS rawJid,
271
+ COALESCE(
272
+ ${cleanDisplayNameSql('group_participants.contact_name')},
273
+ ${cleanDisplayNameSql('group_participants.first_name')},
274
+ ${contactDisplayNameSql('member_contacts')},
275
+ ${cleanDisplayNameSql('group_participants.user_jid')}
276
+ ) AS name,
277
+ group_participants.is_admin AS isAdmin,
278
+ group_participants.is_active AS isActive
279
+ FROM group_participants
280
+ ${contactLeftJoins('member_contacts', 'group_participants.user_jid')}
281
+ WHERE group_participants.group_jid = @jid
282
+ `).all({ jid })
283
+ : [];
284
+ const selfJids = hasRoster ? resolveSelfJids(db) : new Set();
285
+ const rosterByJid = mergeRoster(roster);
286
+ const members = [...rosterByJid.values()];
287
+ const spokeJids = new Set(speakers.map((speaker) => speaker.jid).filter((value) => value !== null));
288
+ const activeMembers = members.filter((member) => member.isActive);
289
+ const activeOthers = activeMembers.filter((member) => !member.rawJids.some((raw) => selfJids.has(raw)) && !selfJids.has(member.jid));
290
+ const receivedTotal = speakers.reduce((sum, speaker) => sum + speaker.messageCount, 0);
291
+ const share = (count) => (receivedTotal === 0 ? 0 : count / receivedTotal);
292
+ const topParticipants = speakers.slice(0, limit).map((speaker) => {
293
+ const member = speaker.jid ? rosterByJid.get(speaker.jid) : undefined;
294
+ return {
295
+ jid: speaker.jid ?? '',
296
+ name: speaker.contactName ?? member?.name ?? speaker.pushName ?? speaker.jid ?? 'Unknown',
297
+ isAdmin: member?.isAdmin ?? false,
298
+ isActive: member?.isActive ?? false,
299
+ messageCount: speaker.messageCount,
300
+ share: share(speaker.messageCount),
301
+ firstMessageAt: unixSecondsToIso(speaker.firstTs),
302
+ lastMessageAt: unixSecondsToIso(speaker.lastTs),
303
+ };
304
+ });
305
+ const quiet = activeOthers.filter((member) => !spokeJids.has(member.jid));
306
+ const quietMembers = quiet
307
+ .slice()
308
+ .sort((a, b) => Number(b.isAdmin) - Number(a.isAdmin)
309
+ || Number(isNamed(b)) - Number(isNamed(a))
310
+ || (a.name ?? '').localeCompare(b.name ?? ''))
311
+ .slice(0, GROUP_QUIET_MEMBER_LIMIT)
312
+ .map((member) => ({ jid: member.jid, name: member.name ?? member.jid, isAdmin: member.isAdmin }));
313
+ const messages = db.prepare(`
314
+ SELECT ts, from_me, text, media_type
315
+ FROM (
316
+ SELECT ts, from_me, text, media_type, rowid
317
+ FROM messages
318
+ WHERE chat_jid = @jid
319
+ ORDER BY ts DESC, rowid DESC
320
+ LIMIT ${GROUP_PROFILE_MAX_ROWS}
321
+ )
322
+ ORDER BY ts ASC, rowid ASC
323
+ `).all({ jid });
324
+ const monthly = new Map();
325
+ const hourCounts = new Array(24).fill(0);
326
+ const wordCounts = new Map();
327
+ const emojiCounts = new Map();
328
+ for (const message of messages) {
329
+ const month = monthInTimezone(message.ts, timeZone);
330
+ let bucket = monthly.get(month);
331
+ if (!bucket) {
332
+ bucket = { sent: 0, received: 0 };
333
+ monthly.set(month, bucket);
334
+ }
335
+ if (message.from_me)
336
+ bucket.sent += 1;
337
+ else
338
+ bucket.received += 1;
339
+ hourCounts[hourInTimezone(message.ts, timeZone)] += 1;
340
+ for (const term of (message.text ?? '').toLowerCase().match(WORD_TOKEN_RE) ?? []) {
341
+ if (isNoiseToken(term))
342
+ continue;
343
+ wordCounts.set(term, (wordCounts.get(term) ?? 0) + 1);
344
+ }
345
+ for (const emoji of (message.text ?? '').match(EMOJI_REGEX) ?? []) {
346
+ emojiCounts.set(emoji, (emojiCounts.get(emoji) ?? 0) + 1);
347
+ }
348
+ }
349
+ const ownerJid = groupRow?.ownerJid?.trim() ? groupRow.ownerJid.trim() : null;
350
+ return {
351
+ jid: chat.jid,
352
+ name: groupRow?.name ?? chat.name,
353
+ ownerJid,
354
+ ownerName: ownerJid ? rosterByJid.get(ownerJid)?.name ?? null : null,
355
+ createdAt: unixSecondsToIso(groupRow?.createdAt ?? null),
356
+ hasRoster,
357
+ totals: {
358
+ messages: totalsRow.messages,
359
+ sentByMe: totalsRow.sentByMe ?? 0,
360
+ media: totalsRow.media ?? 0,
361
+ firstMessageAt: unixSecondsToIso(totalsRow.firstTs),
362
+ lastMessageAt: unixSecondsToIso(totalsRow.lastTs),
363
+ daysSinceLastMessage: totalsRow.lastTs == null ? null : Math.floor((now - totalsRow.lastTs) / DAY_SECONDS),
364
+ activeDays: totalsRow.activeDays,
365
+ },
366
+ roster: {
367
+ total: members.length,
368
+ active: activeMembers.length,
369
+ departed: members.length - activeMembers.length,
370
+ others: activeOthers.length,
371
+ admins: activeOthers.filter((member) => member.isAdmin).length,
372
+ speakers: activeOthers.length - quiet.length,
373
+ lurkers: quiet.length,
374
+ },
375
+ topParticipants,
376
+ quietMembers,
377
+ concentration: {
378
+ topOneShare: share(speakers[0]?.messageCount ?? 0),
379
+ topFiveShare: share(speakers.slice(0, 5).reduce((sum, speaker) => sum + speaker.messageCount, 0)),
380
+ },
381
+ monthlyVolume: [...monthly.entries()]
382
+ .sort((a, b) => a[0].localeCompare(b[0]))
383
+ .map(([month, counts]) => ({ month, ...counts })),
384
+ hourOfDay: hourCounts.map((count, hour) => ({ hour, count })).filter((point) => point.count > 0),
385
+ mediaBreakdown,
386
+ topWords: topCounted(wordCounts, limit, (text, value) => ({ text, value })),
387
+ topEmojis: topCounted(emojiCounts, limit, (emoji, count) => ({ emoji, count })),
388
+ scan: scanCoverage(messages.length, GROUP_PROFILE_MAX_ROWS),
389
+ };
390
+ }
391
+ /**
392
+ * Every group, with the roster size the chat list cannot show.
393
+ */
394
+ export function getGroupDirectory(params, db = getDb()) {
395
+ const limit = parseLimit(params.limit, 50, 500);
396
+ const like = nameLikePattern(params.q);
397
+ const sort = params.sort === 'members' || params.sort === 'messages' ? params.sort : 'recent';
398
+ const hasRoster = tableExists(db, 'group_participants');
399
+ const hasGroupsTable = tableExists(db, 'groups');
400
+ const orderBy = sort === 'members'
401
+ ? 'memberCount DESC NULLS LAST, messageCount DESC'
402
+ : sort === 'messages'
403
+ ? 'messageCount DESC'
404
+ : 'COALESCE(lastTs, 0) DESC';
405
+ const nameSql = `
406
+ COALESCE(
407
+ ${hasGroupsTable ? `${cleanDisplayNameSql('groups.name')},` : ''}
408
+ ${cleanDisplayNameSql('chats.name')},
409
+ ${cleanDisplayNameSql('MAX(messages.chat_name)')},
410
+ ${cleanDisplayNameSql('chats.jid')},
411
+ 'Unknown'
412
+ )
413
+ `;
414
+ const rows = db.prepare(`
415
+ SELECT
416
+ chats.jid AS jid,
417
+ ${nameSql} AS name,
418
+ ${hasRoster
419
+ ? `(
420
+ SELECT COUNT(DISTINCT ${canonicalJidSql('roster_contacts', 'group_participants.user_jid')})
421
+ FROM group_participants
422
+ ${contactLeftJoins('roster_contacts', 'group_participants.user_jid')}
423
+ WHERE group_participants.group_jid = chats.jid AND group_participants.is_active = 1
424
+ )`
425
+ : 'NULL'} AS memberCount,
426
+ COUNT(messages.rowid) AS messageCount,
427
+ COALESCE(SUM(messages.from_me), 0) AS sentByMe,
428
+ MAX(messages.ts) AS lastTs,
429
+ ${hasGroupsTable ? 'groups.created_at' : 'NULL'} AS createdAt
430
+ FROM chats
431
+ ${hasGroupsTable ? 'LEFT JOIN groups ON groups.jid = chats.jid' : ''}
432
+ LEFT JOIN messages ON messages.chat_jid = chats.jid
433
+ WHERE chats.kind = 'group'
434
+ GROUP BY chats.jid
435
+ ${like ? `HAVING ${nameSql} LIKE @like ESCAPE '\\'` : ''}
436
+ ORDER BY ${orderBy}
437
+ LIMIT @limit
438
+ `).all({ limit, like });
439
+ return rows.map((row) => ({
440
+ jid: row.jid,
441
+ name: row.name ?? row.jid,
442
+ memberCount: row.memberCount,
443
+ messageCount: row.messageCount,
444
+ sentByMe: row.sentByMe,
445
+ lastMessageAt: unixSecondsToIso(row.lastTs),
446
+ createdAt: unixSecondsToIso(row.createdAt),
447
+ }));
448
+ }
449
+ /**
450
+ * How groups age, and how many of them have quietly died.
451
+ */
452
+ export function getGroupLifecycle(params, db = getDb()) {
453
+ const dormantDays = parseLimit(params.dormantDays, 180, 3650);
454
+ const limit = parseLimit(params.limit, 8, 50);
455
+ const timeZone = resolveStatsTimeZone(params.timeZone);
456
+ const hasGroupsTable = tableExists(db, 'groups');
457
+ const rows = db.prepare(`
458
+ SELECT
459
+ chats.jid AS jid,
460
+ COALESCE(
461
+ ${hasGroupsTable ? `${cleanDisplayNameSql('groups.name')},` : ''}
462
+ ${cleanDisplayNameSql('chats.name')},
463
+ ${cleanDisplayNameSql('MAX(messages.chat_name)')},
464
+ ${cleanDisplayNameSql('chats.jid')},
465
+ 'Unknown'
466
+ ) AS name,
467
+ COUNT(messages.rowid) AS messageCount,
468
+ NULLIF(MAX(COALESCE(MAX(messages.ts), 0), COALESCE(chats.last_message_at, 0)), 0) AS lastTs,
469
+ ${hasGroupsTable ? 'NULLIF(groups.created_at, 0)' : 'NULL'} AS createdAt
470
+ FROM chats
471
+ ${hasGroupsTable ? 'LEFT JOIN groups ON groups.jid = chats.jid' : ''}
472
+ LEFT JOIN messages ON messages.chat_jid = chats.jid
473
+ WHERE chats.kind = 'group'
474
+ GROUP BY chats.jid
475
+ `).all();
476
+ const now = Math.floor(Date.now() / 1000);
477
+ const cutoff = now - dormantDays * DAY_SECONDS;
478
+ const toEntry = (row) => ({
479
+ jid: row.jid,
480
+ name: row.name ?? row.jid,
481
+ messageCount: row.messageCount,
482
+ createdAt: unixSecondsToIso(row.createdAt),
483
+ lastMessageAt: unixSecondsToIso(row.lastTs),
484
+ daysSilent: row.lastTs == null ? null : Math.floor((now - row.lastTs) / DAY_SECONDS),
485
+ });
486
+ const isDead = (row) => row.lastTs == null || row.lastTs < cutoff;
487
+ const dead = rows.filter(isDead);
488
+ const alive = rows.filter((row) => !isDead(row));
489
+ const ages = rows
490
+ .filter((row) => row.createdAt != null && row.createdAt <= now)
491
+ .map((row) => (now - row.createdAt) / DAY_SECONDS)
492
+ .sort((a, b) => a - b);
493
+ const oldestAliveRow = alive
494
+ .filter((row) => row.createdAt != null)
495
+ .sort((a, b) => a.createdAt - b.createdAt)[0];
496
+ const byYear = new Map();
497
+ for (const row of rows) {
498
+ if (row.createdAt == null)
499
+ continue;
500
+ const year = Number(dateInTimezone(row.createdAt, timeZone).slice(0, 4));
501
+ const bucket = byYear.get(year) ?? { year, alive: 0, dead: 0 };
502
+ if (isDead(row))
503
+ bucket.dead += 1;
504
+ else
505
+ bucket.alive += 1;
506
+ byYear.set(year, bucket);
507
+ }
508
+ return {
509
+ dormantDays,
510
+ totalGroups: rows.length,
511
+ deadGroups: dead.length,
512
+ neverActiveGroups: dead.filter((row) => row.messageCount === 0).length,
513
+ medianAgeDays: ages.length ? Math.round(percentile(ages, 50)) : null,
514
+ oldestAlive: oldestAliveRow ? toEntry(oldestAliveRow) : null,
515
+ foundedByYear: [...byYear.values()].sort((a, b) => a.year - b.year),
516
+ graveyard: dead
517
+ .filter((row) => row.messageCount > 0)
518
+ .sort((a, b) => b.messageCount - a.messageCount || (b.lastTs ?? 0) - (a.lastTs ?? 0))
519
+ .slice(0, limit)
520
+ .map(toEntry),
521
+ };
522
+ }