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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # WaCrawl Dashboard
2
2
 
3
- Local WhatsApp analytics dashboard for the SQLite archive generated by `wacrawl`. A small monorepo runs a **read-only Express API** (`apps/api`) and a **React + Vite** UI (`apps/web`) that talks to it over CORS on localhost only.
3
+ Local WhatsApp analytics dashboard for the SQLite archive generated by [wacrawl](https://github.com/steipete/wacrawl). A small monorepo runs a **read-only Express API** (`apps/api`) and a **React + Vite** UI (`apps/web`) that talks to it over CORS on localhost only.
4
4
 
5
5
  ## One-line install
6
6
 
@@ -24,7 +24,15 @@ WACRAWL_DB=/path/to/wacrawl.db npx wacrawl-dashboard@latest
24
24
  - **Overview cards** — total messages, chats, contacts, media files, and archive date span; each card can jump to Search, Chats, People, or Media as a shortcut.
25
25
  - **Time window** — period filter in the top bar (**Day / Week / Month / Year / All**) drives chart stats (default **Year**). The **activity heatmap** uses the current calendar year.
26
26
  - **Charts and lists** — message volume over time, top contacts (bar chart; click a contact to open **Chats** filtered to that JID), hour-of-day and day-of-week activity, monthly sent vs received vs ratio, media type breakdown (count and bytes), top media senders, response-time averages, busiest groups, messaging streaks (current and longest).
27
- - **Word cloud** — top terms with an **All words** vs **Useful words** (stopword-filtered) toggle.
27
+ - **Word cloud** — top terms with an **All words** vs **Useful words** (stopword-filtered) toggle; click any term to search your archive for it.
28
+ - **Emoji analytics** — total and unique emoji counts, top emojis by **All / Sent / Received**, and a leaderboard of top emoji users with their favourite emoji.
29
+ - **Conversation dynamics** — a dedicated section with relationship-level insights:
30
+ - **Who starts conversations** — initiation ratio per contact with a visual split bar (sessions separated by 4h+ gaps).
31
+ - **Conversation depth** — average messages per session, showing which chats go deep.
32
+ - **Ghost score** — percentage of your messages that went unanswered within 24 hours, colour-coded by severity.
33
+ - **Late-night texters** — who you talk to between midnight and 5 am, with hour-bucket breakdowns.
34
+ - **Relationship trajectory** — monthly sparkline per contact showing whether message frequency is *growing*, *fading*, or *stable*.
35
+ - **Loading overlay** — an animated full-screen splash with rotating quotes while the dashboard fetches data.
28
36
 
29
37
  ### People
30
38
 
@@ -36,10 +44,17 @@ WACRAWL_DB=/path/to/wacrawl.db npx wacrawl-dashboard@latest
36
44
  - **Read-only inspection** — message bubbles, timestamps, optional **copy message** (including media-aware clipboard text), and inline **images / videos / audio** when paths resolve under your configured media root.
37
45
  - **Deep links** — `?contact=<jid>` (for example from the dashboard top-contacts chart) selects the matching chat when present.
38
46
 
39
- ### Media
47
+ ### Media & Links
40
48
 
41
- - **Paged grid** of indexed media with **infinite scroll** (intersection-based loading).
42
- - Thumbnails for **images** and **videos**, affordances for **audio**, and a full-screen **lightbox** for preview and playback where the type supports it.
49
+ - **Two-tab layout** — switch between **Media Grid** and **Link Intelligence** from the page header.
50
+ - **Media Grid** — paged grid of indexed media with **infinite scroll** (intersection-based loading). Thumbnails for **images** and **videos**, affordances for **audio**, and a full-screen **lightbox** for preview and playback where the type supports it.
51
+ - **Link Intelligence** (new) — a rich analytics panel for URLs shared in your conversations:
52
+ - **Overview pills** — total links shared and unique domains at a glance.
53
+ - **Domain leaderboard** — horizontal bar chart of the most-shared domains, colour-coded by category.
54
+ - **Content categories** — pie chart breaking links into *Video, Social, News, Shopping, Music, Developer, Reference,* and *Other*, with the top domain per category.
55
+ - **Media timeline density** — stacked area chart showing daily counts of images, videos, audio files, and links over time.
56
+ - **Sharing asymmetry** — per-contact breakdown of media and link volume in each direction (sent vs received).
57
+ - **First shared** — a timeline of the earliest message and earliest media item for each conversation.
43
58
 
44
59
  ### Search
45
60
 
@@ -62,6 +77,7 @@ WACRAWL_DB=/path/to/wacrawl.db npx wacrawl-dashboard@latest
62
77
 
63
78
  - Listens on **`127.0.0.1`** with host and CORS restricted to local dev/preview origins.
64
79
  - Opens the WaCrawl SQLite database in **readonly** mode where applicable; `/api/health` reports DB readability and resolved paths.
80
+ - **Stats endpoints** — `/api/stats/emoji-analytics`, `/api/stats/conversation-dynamics`, and `/api/stats/link-intelligence` power the new dashboard and media analytics panels.
65
81
 
66
82
  ## Requirements
67
83
 
@@ -11,7 +11,7 @@ export function cleanDisplayNameSql(expression) {
11
11
  END
12
12
  `;
13
13
  }
14
- export function contactDisplayNameSql(alias) {
14
+ export function contactDisplayNameSingleSql(alias) {
15
15
  return `
16
16
  COALESCE(
17
17
  ${cleanDisplayNameSql(`${alias}.full_name`)},
@@ -23,6 +23,20 @@ export function contactDisplayNameSql(alias) {
23
23
  )
24
24
  `;
25
25
  }
26
- export function contactMatchSql(alias, jidExpression) {
27
- return `(${alias}.jid = ${jidExpression} OR ${alias}.lid = ${jidExpression})`;
26
+ export function contactDisplayNameSql(alias, isSplit = true) {
27
+ if (!isSplit) {
28
+ return contactDisplayNameSingleSql(alias);
29
+ }
30
+ return `
31
+ COALESCE(
32
+ ${contactDisplayNameSingleSql(`${alias}_jid`)},
33
+ ${contactDisplayNameSingleSql(`${alias}_lid`)}
34
+ )
35
+ `;
36
+ }
37
+ export function contactLeftJoins(alias, jidExpression) {
38
+ return `
39
+ LEFT JOIN contacts AS ${alias}_jid ON ${alias}_jid.jid = ${jidExpression}
40
+ LEFT JOIN contacts AS ${alias}_lid ON ${alias}_lid.lid = ${jidExpression}
41
+ `;
28
42
  }
@@ -1,4 +1,4 @@
1
- import { cleanDisplayNameSql, contactDisplayNameSql, contactMatchSql } from './displayName.js';
1
+ import { cleanDisplayNameSql, contactDisplayNameSql, contactLeftJoins } from './displayName.js';
2
2
  export const MESSAGE_SEARCH_FTS_TABLE = 'messages_fts';
3
3
  export function hasMessageSearchIndex(db) {
4
4
  const row = db.prepare(`
@@ -51,7 +51,7 @@ export function createSearchIndexes(db) {
51
51
  COALESCE(${contactDisplayNameSql('sender_contacts')}, '')
52
52
  FROM messages
53
53
  LEFT JOIN chats ON chats.jid = messages.chat_jid
54
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
55
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')};
54
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
55
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')};
56
56
  `);
57
57
  }
@@ -24,13 +24,26 @@ export function resolveStatsTimeZone(raw) {
24
24
  return 'UTC';
25
25
  }
26
26
  }
27
+ const formattersCache = new Map();
28
+ function getFormatter(timeZone) {
29
+ let formatter = formattersCache.get(timeZone);
30
+ if (!formatter) {
31
+ formatter = new Intl.DateTimeFormat('en-GB', {
32
+ timeZone,
33
+ hour: 'numeric',
34
+ hourCycle: 'h23',
35
+ });
36
+ formattersCache.set(timeZone, formatter);
37
+ }
38
+ return formatter;
39
+ }
27
40
  /** Calendar hour 0–23 for Unix epoch seconds in the given IANA time zone. */
28
41
  export function hourInTimezone(epochSeconds, timeZone) {
29
- const parts = new Intl.DateTimeFormat('en-GB', {
30
- timeZone,
31
- hour: 'numeric',
32
- hourCycle: 'h23',
33
- }).formatToParts(new Date(epochSeconds * 1000));
42
+ if (timeZone === 'UTC') {
43
+ return new Date(epochSeconds * 1000).getUTCHours();
44
+ }
45
+ const formatter = getFormatter(timeZone);
46
+ const parts = formatter.formatToParts(new Date(epochSeconds * 1000));
34
47
  const value = Number(parts.find((p) => p.type === 'hour')?.value);
35
48
  if (!Number.isFinite(value))
36
49
  return 0;
@@ -0,0 +1,111 @@
1
+ const URL_RE = /https?:\/\/[^\s<>"')\]]+/gi;
2
+ const TRAILING_PUNCT_RE = /[.,;:!?)}\]]+$/;
3
+ const DOMAIN_CATEGORIES = {
4
+ 'youtube.com': 'video',
5
+ 'youtu.be': 'video',
6
+ 'vimeo.com': 'video',
7
+ 'dailymotion.com': 'video',
8
+ 'twitch.tv': 'video',
9
+ 'tiktok.com': 'video',
10
+ 'vm.tiktok.com': 'video',
11
+ 'twitter.com': 'social',
12
+ 'x.com': 'social',
13
+ 'facebook.com': 'social',
14
+ 'fb.com': 'social',
15
+ 'instagram.com': 'social',
16
+ 'linkedin.com': 'social',
17
+ 'reddit.com': 'social',
18
+ 'threads.net': 'social',
19
+ 'mastodon.social': 'social',
20
+ 'bsky.app': 'social',
21
+ 'pinterest.com': 'social',
22
+ 'snapchat.com': 'social',
23
+ 'tumblr.com': 'social',
24
+ 'wa.me': 'social',
25
+ 'cnn.com': 'news',
26
+ 'bbc.com': 'news',
27
+ 'bbc.co.uk': 'news',
28
+ 'nytimes.com': 'news',
29
+ 'theguardian.com': 'news',
30
+ 'reuters.com': 'news',
31
+ 'apnews.com': 'news',
32
+ 'washingtonpost.com': 'news',
33
+ 'aljazeera.com': 'news',
34
+ 'news.google.com': 'news',
35
+ 'news.yahoo.com': 'news',
36
+ 'techcrunch.com': 'news',
37
+ 'theverge.com': 'news',
38
+ 'arstechnica.com': 'news',
39
+ 'wired.com': 'news',
40
+ 'medium.com': 'news',
41
+ 'substack.com': 'news',
42
+ 'bloomberg.com': 'news',
43
+ 'cnbc.com': 'news',
44
+ 'foxnews.com': 'news',
45
+ 'nbcnews.com': 'news',
46
+ 'ynet.co.il': 'news',
47
+ 'haaretz.com': 'news',
48
+ 'amazon.com': 'shopping',
49
+ 'amazon.co.uk': 'shopping',
50
+ 'amazon.de': 'shopping',
51
+ 'ebay.com': 'shopping',
52
+ 'etsy.com': 'shopping',
53
+ 'aliexpress.com': 'shopping',
54
+ 'walmart.com': 'shopping',
55
+ 'target.com': 'shopping',
56
+ 'shopify.com': 'shopping',
57
+ 'wish.com': 'shopping',
58
+ 'spotify.com': 'music',
59
+ 'open.spotify.com': 'music',
60
+ 'soundcloud.com': 'music',
61
+ 'music.apple.com': 'music',
62
+ 'music.youtube.com': 'music',
63
+ 'deezer.com': 'music',
64
+ 'bandcamp.com': 'music',
65
+ 'github.com': 'dev',
66
+ 'gitlab.com': 'dev',
67
+ 'stackoverflow.com': 'dev',
68
+ 'npmjs.com': 'dev',
69
+ 'pypi.org': 'dev',
70
+ 'dev.to': 'dev',
71
+ 'codepen.io': 'dev',
72
+ 'jsfiddle.net': 'dev',
73
+ 'codesandbox.io': 'dev',
74
+ 'hackernews.com': 'dev',
75
+ 'news.ycombinator.com': 'dev',
76
+ 'wikipedia.org': 'reference',
77
+ 'en.wikipedia.org': 'reference',
78
+ 'docs.google.com': 'reference',
79
+ 'drive.google.com': 'reference',
80
+ 'maps.google.com': 'reference',
81
+ 'translate.google.com': 'reference',
82
+ 'google.com': 'reference',
83
+ 'bing.com': 'reference',
84
+ 'duckduckgo.com': 'reference',
85
+ };
86
+ export function extractUrls(text) {
87
+ if (!text)
88
+ return [];
89
+ const matches = text.match(URL_RE);
90
+ if (!matches)
91
+ return [];
92
+ return matches.map((url) => url.replace(TRAILING_PUNCT_RE, ''));
93
+ }
94
+ export function parseDomain(url) {
95
+ try {
96
+ const hostname = new URL(url).hostname.toLowerCase();
97
+ return hostname.replace(/^www\./, '');
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ export function categorizeDomain(domain) {
104
+ if (DOMAIN_CATEGORIES[domain])
105
+ return DOMAIN_CATEGORIES[domain];
106
+ for (const [known, category] of Object.entries(DOMAIN_CATEGORIES)) {
107
+ if (domain.endsWith(`.${known}`))
108
+ return category;
109
+ }
110
+ return 'other';
111
+ }
@@ -1,6 +1,6 @@
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 { resolveArchiveMediaPath } from '../lib/mediaFsPath.js';
5
5
  import { parseLimit, parseOffset, unixSecondsToIso } from '../lib/query.js';
6
6
  import { hasMessageSearchIndex, MESSAGE_SEARCH_FTS_TABLE } from '../lib/searchIndex.js';
@@ -85,10 +85,10 @@ export function getPeople(params, db = getDb()) {
85
85
  media_type
86
86
  FROM messages
87
87
  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
88
- 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')}
88
+ ${contactLeftJoins('contacts', 'CASE WHEN messages.from_me = 1 THEN messages.chat_jid ELSE COALESCE(messages.sender_jid, messages.chat_jid) END')}
89
89
  ),
90
90
  all_people AS (
91
- SELECT jid, ${contactDisplayNameSql('contacts')} AS contact_name, NULL AS message_name, NULL AS direct_chat_name, NULL AS from_me, NULL AS ts, NULL AS media_type
91
+ SELECT jid, ${contactDisplayNameSql('contacts', false)} AS contact_name, NULL AS message_name, NULL AS direct_chat_name, NULL AS from_me, NULL AS ts, NULL AS media_type
92
92
  FROM contacts
93
93
  UNION ALL
94
94
  SELECT jid, contact_name, message_name, direct_chat_name, from_me, ts, media_type FROM normalized_messages
@@ -121,8 +121,12 @@ export function getChats(params, db = getDb()) {
121
121
  const limit = parseLimit(params.limit, 50, 200);
122
122
  const offset = parseOffset(params.offset);
123
123
  const kind = params.kind === 'direct' || params.kind === 'group' ? params.kind : undefined;
124
- const where = kind ? 'WHERE chats.kind = @kind' : 'WHERE chats.kind IN (\'direct\', \'group\')';
125
- const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({ kind }).count;
124
+ const targetJid = typeof params.jid === 'string' && params.jid.trim() ? params.jid.trim() : undefined;
125
+ let where = kind ? 'WHERE chats.kind = @kind' : 'WHERE chats.kind IN (\'direct\', \'group\')';
126
+ if (targetJid) {
127
+ where = 'WHERE chats.jid = @targetJid';
128
+ }
129
+ const total = db.prepare(`SELECT COUNT(*) AS count FROM chats ${where}`).get({ kind, targetJid }).count;
126
130
  const rows = db.prepare(`
127
131
  WITH chat_page AS (
128
132
  SELECT
@@ -172,9 +176,9 @@ export function getChats(params, db = getDb()) {
172
176
  FROM chat_page
173
177
  LEFT JOIN message_stats ON message_stats.chat_jid = chat_page.jid
174
178
  LEFT JOIN latest ON latest.chat_jid = chat_page.jid
175
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'chat_page.jid')}
179
+ ${contactLeftJoins('chat_contacts', 'chat_page.jid')}
176
180
  ORDER BY COALESCE(message_stats.lastMessageTs, chat_page.last_message_at, 0) DESC, name COLLATE NOCASE ASC
177
- `).all({ kind, limit, offset });
181
+ `).all({ kind, limit, offset, targetJid });
178
182
  return asPagination(rows.map((row) => ({
179
183
  jid: row.jid,
180
184
  kind: row.kind,
@@ -207,8 +211,8 @@ export function getChatMessages(chatJid, params, db = getDb()) {
207
211
  media_path AS mediaPath
208
212
  FROM messages
209
213
  LEFT JOIN chats ON chats.jid = messages.chat_jid
210
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
211
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
214
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
215
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
212
216
  WHERE chat_jid = @chatJid
213
217
  ORDER BY ts DESC, messages.rowid DESC
214
218
  LIMIT @limit OFFSET @offset
@@ -240,8 +244,8 @@ export function getMediaItems(params, db = getDb()) {
240
244
  media_size AS mediaSize
241
245
  FROM messages
242
246
  LEFT JOIN chats ON chats.jid = messages.chat_jid
243
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
244
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
247
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
248
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
245
249
  WHERE ${where}
246
250
  ORDER BY ts DESC, messages.rowid DESC
247
251
  LIMIT @limit OFFSET @offset
@@ -301,8 +305,8 @@ export function searchMessages(params, db = getDb()) {
301
305
  FROM matched
302
306
  JOIN messages ON messages.rowid = matched.rowid
303
307
  LEFT JOIN chats ON chats.jid = messages.chat_jid
304
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
305
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
308
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
309
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
306
310
  ORDER BY ts DESC, messages.rowid DESC
307
311
  LIMIT @limit OFFSET @offset
308
312
  `).all({ ftsQuery, limit, offset });
@@ -325,8 +329,8 @@ export function searchMessages(params, db = getDb()) {
325
329
  SELECT COUNT(*) AS count
326
330
  FROM messages
327
331
  LEFT JOIN chats ON chats.jid = messages.chat_jid
328
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
329
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
332
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
333
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
330
334
  WHERE ${where}
331
335
  `).get({ like }).count;
332
336
  const rows = db.prepare(`
@@ -345,8 +349,8 @@ export function searchMessages(params, db = getDb()) {
345
349
  media_path AS mediaPath
346
350
  FROM messages
347
351
  LEFT JOIN chats ON chats.jid = messages.chat_jid
348
- LEFT JOIN contacts AS chat_contacts ON ${contactMatchSql('chat_contacts', 'messages.chat_jid')}
349
- LEFT JOIN contacts AS sender_contacts ON ${contactMatchSql('sender_contacts', 'messages.sender_jid')}
352
+ ${contactLeftJoins('chat_contacts', 'messages.chat_jid')}
353
+ ${contactLeftJoins('sender_contacts', 'messages.sender_jid')}
350
354
  WHERE ${where}
351
355
  ORDER BY ts DESC, messages.rowid DESC
352
356
  LIMIT @limit OFFSET @offset
@@ -415,3 +419,25 @@ dataRouter.get('/media/file', (req, res, next) => {
415
419
  dataRouter.get('/search', (req, res) => {
416
420
  res.json(searchMessages(req.query));
417
421
  });
422
+ dataRouter.get('/messages/:id/offset', (req, res) => {
423
+ const id = Number(req.params.id);
424
+ if (isNaN(id)) {
425
+ res.status(400).json({ error: 'Invalid message ID' });
426
+ return;
427
+ }
428
+ const db = getDb();
429
+ const msg = db.prepare('SELECT chat_jid, ts FROM messages WHERE rowid = ?').get(id);
430
+ if (!msg) {
431
+ res.status(404).json({ error: 'Message not found' });
432
+ return;
433
+ }
434
+ const countRow = db.prepare(`
435
+ SELECT COUNT(*) AS count
436
+ FROM messages
437
+ WHERE chat_jid = ? AND (ts > ? OR (ts = ? AND rowid > ?))
438
+ `).get(msg.chat_jid, msg.ts, msg.ts, id);
439
+ res.json({
440
+ chatJid: msg.chat_jid,
441
+ offset: countRow.count
442
+ });
443
+ });