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.
@@ -1,9 +1,10 @@
1
1
  import { Router } from 'express';
2
2
  import { getDb } from '../db.js';
3
- import { cleanDisplayNameSql, contactDisplayNameSql, contactMatchSql } from '../lib/displayName.js';
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
- contacts.jid,
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
- contacts.phone AS contact_phone,
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
- LEFT JOIN contacts ON ${contactMatchSql('contacts', `CASE
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
- 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')}
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
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'ordered.chat_jid')}
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
+ });