wacrawl-dashboard 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -5
- package/apps/api/dist/lib/displayName.js +17 -3
- package/apps/api/dist/lib/searchIndex.js +3 -3
- package/apps/api/dist/lib/timezone.js +18 -5
- package/apps/api/dist/lib/urlExtract.js +111 -0
- package/apps/api/dist/routes/data.js +43 -17
- package/apps/api/dist/routes/stats.js +445 -6
- package/apps/web/dist/assets/index-CFh_Wz98.js +43 -0
- package/apps/web/dist/assets/index-DJiLhgJZ.css +1 -0
- package/apps/web/dist/index.html +2 -2
- package/package.json +7 -2
- package/apps/web/dist/assets/index-1fGu93mF.css +0 -1
- package/apps/web/dist/assets/index-Dfsoi6vG.js +0 -43
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Router } from 'express';
|
|
2
2
|
import { getDb } from '../db.js';
|
|
3
|
-
import { cleanDisplayNameSql, contactDisplayNameSql,
|
|
3
|
+
import { cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from '../lib/displayName.js';
|
|
4
4
|
import { bucketDateSql, normalizeBucketDate, parseGranularity, parseLimit, parsePeriod, parseYear, sinceTimestamp, unixSecondsToIso, yearBounds, } from '../lib/query.js';
|
|
5
5
|
import { hourInTimezone, resolveStatsTimeZone } from '../lib/timezone.js';
|
|
6
6
|
import { USEFUL_WORD_STOP_SET } from '../lib/wordCloudUsefulStopWords.js';
|
|
7
|
+
import { categorizeDomain, extractUrls, parseDomain } from '../lib/urlExtract.js';
|
|
7
8
|
export function getOverviewStats(db = getDb()) {
|
|
8
9
|
const messageRow = db.prepare(`
|
|
9
10
|
SELECT
|
|
@@ -32,14 +33,15 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
32
33
|
WITH normalized_messages AS (
|
|
33
34
|
SELECT
|
|
34
35
|
COALESCE(
|
|
35
|
-
|
|
36
|
+
contacts_jid.jid,
|
|
37
|
+
contacts_lid.jid,
|
|
36
38
|
CASE
|
|
37
39
|
WHEN from_me = 1 THEN chat_jid
|
|
38
40
|
ELSE COALESCE(sender_jid, chat_jid)
|
|
39
41
|
END
|
|
40
42
|
) AS jid,
|
|
41
43
|
${contactDisplayNameSql('contacts')} AS contact_name,
|
|
42
|
-
|
|
44
|
+
COALESCE(contacts_jid.phone, contacts_lid.phone) AS contact_phone,
|
|
43
45
|
${cleanDisplayNameSql(`
|
|
44
46
|
CASE
|
|
45
47
|
WHEN from_me = 1 THEN messages.chat_name
|
|
@@ -64,7 +66,7 @@ export function getTopContacts(params, db = getDb()) {
|
|
|
64
66
|
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
65
67
|
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
66
68
|
END
|
|
67
|
-
|
|
69
|
+
${contactLeftJoins('contacts', `CASE
|
|
68
70
|
WHEN messages.from_me = 1 THEN messages.chat_jid
|
|
69
71
|
ELSE COALESCE(messages.sender_jid, messages.chat_jid)
|
|
70
72
|
END`)}
|
|
@@ -193,7 +195,7 @@ export function getMediaSenders(params, db = getDb()) {
|
|
|
193
195
|
media_size
|
|
194
196
|
FROM messages
|
|
195
197
|
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
|
-
|
|
198
|
+
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
197
199
|
WHERE ts >= @since AND media_type IS NOT NULL AND media_type <> ''
|
|
198
200
|
)
|
|
199
201
|
SELECT
|
|
@@ -255,7 +257,7 @@ export function getResponseTimes(params, db = getDb()) {
|
|
|
255
257
|
ROUND(AVG(ts - previous_ts)) AS averageSeconds
|
|
256
258
|
FROM ordered
|
|
257
259
|
LEFT JOIN chats ON chats.jid = ordered.chat_jid
|
|
258
|
-
|
|
260
|
+
${contactLeftJoins('chat_contacts', 'ordered.chat_jid')}
|
|
259
261
|
WHERE previous_ts IS NOT NULL
|
|
260
262
|
AND previous_from_me IS NOT NULL
|
|
261
263
|
AND previous_from_me <> from_me
|
|
@@ -328,6 +330,8 @@ export function getWordCloud(params, db = getDb()) {
|
|
|
328
330
|
SELECT text
|
|
329
331
|
FROM messages
|
|
330
332
|
WHERE ts >= @since AND text IS NOT NULL AND TRIM(text) <> ''
|
|
333
|
+
ORDER BY ts DESC
|
|
334
|
+
LIMIT 30000
|
|
331
335
|
`).all({ since });
|
|
332
336
|
const counts = new Map();
|
|
333
337
|
for (const row of rows) {
|
|
@@ -346,6 +350,432 @@ export function getWordCloud(params, db = getDb()) {
|
|
|
346
350
|
.sort((a, b) => b.value - a.value || a.text.localeCompare(b.text))
|
|
347
351
|
.slice(0, limit);
|
|
348
352
|
}
|
|
353
|
+
const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
|
|
354
|
+
export function getEmojiAnalytics(params, db = getDb()) {
|
|
355
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
356
|
+
const limit = parseLimit(params.limit, 15, 50);
|
|
357
|
+
const rows = db.prepare(`
|
|
358
|
+
SELECT
|
|
359
|
+
messages.text,
|
|
360
|
+
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,
|
|
362
|
+
${cleanDisplayNameSql(`CASE WHEN messages.from_me = 1 THEN COALESCE(chats.name, messages.chat_name) ELSE messages.sender_name END`)} AS name,
|
|
363
|
+
${contactDisplayNameSql('contacts')} AS contact_name
|
|
364
|
+
FROM messages
|
|
365
|
+
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
|
|
366
|
+
${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
|
|
367
|
+
WHERE messages.ts >= @since AND messages.text IS NOT NULL AND messages.text <> ''
|
|
368
|
+
ORDER BY messages.ts DESC
|
|
369
|
+
LIMIT 50000
|
|
370
|
+
`).all({ since });
|
|
371
|
+
const allCounts = new Map();
|
|
372
|
+
const sentCounts = new Map();
|
|
373
|
+
const receivedCounts = new Map();
|
|
374
|
+
const userCounts = new Map();
|
|
375
|
+
let totalEmojiCount = 0;
|
|
376
|
+
for (const row of rows) {
|
|
377
|
+
const emojis = (row.text ?? '').match(EMOJI_REGEX);
|
|
378
|
+
if (!emojis || emojis.length === 0)
|
|
379
|
+
continue;
|
|
380
|
+
totalEmojiCount += emojis.length;
|
|
381
|
+
const jid = row.jid ?? 'unknown';
|
|
382
|
+
const displayName = row.name ?? row.contact_name ?? jid;
|
|
383
|
+
if (!userCounts.has(jid)) {
|
|
384
|
+
userCounts.set(jid, { name: displayName, count: 0, emojis: new Map() });
|
|
385
|
+
}
|
|
386
|
+
const userEntry = userCounts.get(jid);
|
|
387
|
+
for (const emoji of emojis) {
|
|
388
|
+
allCounts.set(emoji, (allCounts.get(emoji) ?? 0) + 1);
|
|
389
|
+
if (row.from_me) {
|
|
390
|
+
sentCounts.set(emoji, (sentCounts.get(emoji) ?? 0) + 1);
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
receivedCounts.set(emoji, (receivedCounts.get(emoji) ?? 0) + 1);
|
|
394
|
+
}
|
|
395
|
+
userEntry.count += 1;
|
|
396
|
+
userEntry.emojis.set(emoji, (userEntry.emojis.get(emoji) ?? 0) + 1);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const sortMap = (map, n) => [...map.entries()]
|
|
400
|
+
.sort((a, b) => b[1] - a[1])
|
|
401
|
+
.slice(0, n)
|
|
402
|
+
.map(([emoji, count]) => ({ emoji, count }));
|
|
403
|
+
const topEmojiUsers = [...userCounts.entries()]
|
|
404
|
+
.sort((a, b) => b[1].count - a[1].count)
|
|
405
|
+
.slice(0, limit)
|
|
406
|
+
.map(([jid, entry]) => {
|
|
407
|
+
const topEmoji = [...entry.emojis.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
|
|
408
|
+
return { jid, name: entry.name, count: entry.count, topEmoji };
|
|
409
|
+
});
|
|
410
|
+
return {
|
|
411
|
+
topEmojis: sortMap(allCounts, limit),
|
|
412
|
+
topSentEmojis: sortMap(sentCounts, limit),
|
|
413
|
+
topReceivedEmojis: sortMap(receivedCounts, limit),
|
|
414
|
+
totalEmojiCount,
|
|
415
|
+
uniqueEmojiCount: allCounts.size,
|
|
416
|
+
topEmojiUsers,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
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
|
+
}
|
|
444
|
+
function computeTrajectoryDirection(trend) {
|
|
445
|
+
if (trend.length < 4)
|
|
446
|
+
return 'stable';
|
|
447
|
+
const half = Math.floor(trend.length / 2);
|
|
448
|
+
const firstHalfAvg = trend.slice(0, half).reduce((s, p) => s + p.count, 0) / half;
|
|
449
|
+
const secondHalfAvg = trend.slice(half).reduce((s, p) => s + p.count, 0) / (trend.length - half);
|
|
450
|
+
const ratio = secondHalfAvg / (firstHalfAvg || 1);
|
|
451
|
+
if (ratio > 1.25)
|
|
452
|
+
return 'growing';
|
|
453
|
+
if (ratio < 0.75)
|
|
454
|
+
return 'fading';
|
|
455
|
+
return 'stable';
|
|
456
|
+
}
|
|
457
|
+
export function getConversationDynamics(params, db = getDb()) {
|
|
458
|
+
const since = sinceTimestamp(parsePeriod(params.period));
|
|
459
|
+
const limit = parseLimit(params.limit, 10, 50);
|
|
460
|
+
const timeZone = resolveStatsTimeZone(params.timeZone);
|
|
461
|
+
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
|
|
466
|
+
`).all({ since });
|
|
467
|
+
const chatGroups = new Map();
|
|
468
|
+
for (const row of rows) {
|
|
469
|
+
let group = chatGroups.get(row.chat_jid);
|
|
470
|
+
if (!group) {
|
|
471
|
+
group = [];
|
|
472
|
+
chatGroups.set(row.chat_jid, group);
|
|
473
|
+
}
|
|
474
|
+
group.push(row);
|
|
475
|
+
}
|
|
476
|
+
const rawInitiation = [];
|
|
477
|
+
const rawDepth = [];
|
|
478
|
+
const rawGhost = [];
|
|
479
|
+
const rawLateNight = [];
|
|
480
|
+
const rawTrajectory = [];
|
|
481
|
+
for (const [jid, messages] of chatGroups) {
|
|
482
|
+
if (messages.length < 2)
|
|
483
|
+
continue;
|
|
484
|
+
let byMe = 0;
|
|
485
|
+
let byThem = 0;
|
|
486
|
+
let sessionStart = 0;
|
|
487
|
+
const sessionSizes = [];
|
|
488
|
+
if (messages[0].from_me)
|
|
489
|
+
byMe++;
|
|
490
|
+
else
|
|
491
|
+
byThem++;
|
|
492
|
+
for (let i = 1; i < messages.length; i++) {
|
|
493
|
+
if (messages[i].ts - messages[i - 1].ts >= SESSION_GAP_SECONDS) {
|
|
494
|
+
sessionSizes.push(i - sessionStart);
|
|
495
|
+
sessionStart = i;
|
|
496
|
+
if (messages[i].from_me)
|
|
497
|
+
byMe++;
|
|
498
|
+
else
|
|
499
|
+
byThem++;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
sessionSizes.push(messages.length - sessionStart);
|
|
503
|
+
const totalSessions = sessionSizes.length;
|
|
504
|
+
rawInitiation.push({ jid, byMe, byThem, total: byMe + byThem });
|
|
505
|
+
rawDepth.push({ jid, avgDepth: Math.round((messages.length / totalSessions) * 10) / 10, sessions: totalSessions, total: messages.length });
|
|
506
|
+
let ghosted = 0;
|
|
507
|
+
let totalSent = 0;
|
|
508
|
+
for (let i = 0; i < messages.length; i++) {
|
|
509
|
+
if (!messages[i].from_me)
|
|
510
|
+
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
|
+
}
|
|
519
|
+
if (!replied)
|
|
520
|
+
ghosted++;
|
|
521
|
+
}
|
|
522
|
+
if (totalSent > 0) {
|
|
523
|
+
rawGhost.push({ jid, ghosted, totalSent });
|
|
524
|
+
}
|
|
525
|
+
let lateNight = 0;
|
|
526
|
+
let workHrs = 0;
|
|
527
|
+
let other = 0;
|
|
528
|
+
for (const msg of messages) {
|
|
529
|
+
const hour = hourInTimezone(msg.ts, timeZone);
|
|
530
|
+
if (hour < 5)
|
|
531
|
+
lateNight++;
|
|
532
|
+
else if (hour >= 9 && hour < 17)
|
|
533
|
+
workHrs++;
|
|
534
|
+
else
|
|
535
|
+
other++;
|
|
536
|
+
}
|
|
537
|
+
rawLateNight.push({ jid, lateNight, workHours: workHrs, other, total: messages.length });
|
|
538
|
+
const monthCounts = new Map();
|
|
539
|
+
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')}`;
|
|
542
|
+
monthCounts.set(month, (monthCounts.get(month) ?? 0) + 1);
|
|
543
|
+
}
|
|
544
|
+
rawTrajectory.push({ jid, monthCounts, total: messages.length });
|
|
545
|
+
}
|
|
546
|
+
const topInitiation = rawInitiation.filter((r) => r.total >= 3).sort((a, b) => b.total - a.total).slice(0, limit);
|
|
547
|
+
const topDepth = rawDepth.filter((r) => r.sessions >= 3).sort((a, b) => b.avgDepth - a.avgDepth).slice(0, limit);
|
|
548
|
+
const topGhost = rawGhost.filter((r) => r.totalSent >= 3).sort((a, b) => (b.ghosted / b.totalSent) - (a.ghosted / a.totalSent)).slice(0, limit);
|
|
549
|
+
const topLateNight = rawLateNight.filter((r) => r.lateNight > 0).sort((a, b) => b.lateNight - a.lateNight).slice(0, limit);
|
|
550
|
+
const topTrajectory = rawTrajectory.filter((r) => r.monthCounts.size >= 2).sort((a, b) => b.total - a.total).slice(0, limit);
|
|
551
|
+
const allJids = new Set();
|
|
552
|
+
for (const r of topInitiation)
|
|
553
|
+
allJids.add(r.jid);
|
|
554
|
+
for (const r of topDepth)
|
|
555
|
+
allJids.add(r.jid);
|
|
556
|
+
for (const r of topGhost)
|
|
557
|
+
allJids.add(r.jid);
|
|
558
|
+
for (const r of topLateNight)
|
|
559
|
+
allJids.add(r.jid);
|
|
560
|
+
for (const r of topTrajectory)
|
|
561
|
+
allJids.add(r.jid);
|
|
562
|
+
const names = resolveChatNames([...allJids], db);
|
|
563
|
+
const name = (jid) => names.get(jid) ?? jid;
|
|
564
|
+
const initiationRatio = topInitiation.map((r) => ({
|
|
565
|
+
jid: r.jid,
|
|
566
|
+
name: name(r.jid),
|
|
567
|
+
initiatedByMe: r.byMe,
|
|
568
|
+
initiatedByThem: r.byThem,
|
|
569
|
+
ratio: Math.round((r.byMe / r.total) * 100) / 100,
|
|
570
|
+
}));
|
|
571
|
+
const conversationDepth = topDepth.map((r) => ({
|
|
572
|
+
jid: r.jid,
|
|
573
|
+
name: name(r.jid),
|
|
574
|
+
avgMessagesPerSession: r.avgDepth,
|
|
575
|
+
totalSessions: r.sessions,
|
|
576
|
+
}));
|
|
577
|
+
const ghostScore = topGhost.map((r) => ({
|
|
578
|
+
jid: r.jid,
|
|
579
|
+
name: name(r.jid),
|
|
580
|
+
ghostedCount: r.ghosted,
|
|
581
|
+
totalSent: r.totalSent,
|
|
582
|
+
ghostRate: Math.round((r.ghosted / r.totalSent) * 100) / 100,
|
|
583
|
+
}));
|
|
584
|
+
const lateNightTexters = topLateNight.map((r) => ({
|
|
585
|
+
jid: r.jid,
|
|
586
|
+
name: name(r.jid),
|
|
587
|
+
lateNight: r.lateNight,
|
|
588
|
+
workHours: r.workHours,
|
|
589
|
+
otherHours: r.other,
|
|
590
|
+
totalMessages: r.total,
|
|
591
|
+
lateNightPct: Math.round((r.lateNight / r.total) * 100),
|
|
592
|
+
}));
|
|
593
|
+
const relationshipTrajectory = topTrajectory.map((r) => {
|
|
594
|
+
const sorted = [...r.monthCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
595
|
+
const trend = sorted.map(([month, count]) => ({ month, count }));
|
|
596
|
+
return {
|
|
597
|
+
jid: r.jid,
|
|
598
|
+
name: name(r.jid),
|
|
599
|
+
trend,
|
|
600
|
+
direction: computeTrajectoryDirection(trend),
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
return { initiationRatio, conversationDepth, ghostScore, relationshipTrajectory, lateNightTexters };
|
|
604
|
+
}
|
|
605
|
+
export function getLinkIntelligence(params, db = getDb()) {
|
|
606
|
+
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();
|
|
678
|
+
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;
|
|
693
|
+
}
|
|
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 });
|
|
708
|
+
}
|
|
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,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
349
779
|
export const statsRouter = Router();
|
|
350
780
|
statsRouter.get('/overview', (_req, res) => {
|
|
351
781
|
res.json(getOverviewStats());
|
|
@@ -386,3 +816,12 @@ statsRouter.get('/streaks', (req, res) => {
|
|
|
386
816
|
statsRouter.get('/word-cloud', (req, res) => {
|
|
387
817
|
res.json(getWordCloud(req.query));
|
|
388
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
|
+
});
|