first commit

This commit is contained in:
2025-10-14 13:13:17 +02:00
commit 3cc24138bb
20 changed files with 16483 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Editors/IDE
.vscode/
.idea/
*.swp
*.swo
# OS junk
.DS_Store
Thumbs.db
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
*.log
# Node
node_modules/
*.tsbuildinfo
# Build artifacts
dist/
build/
.cache/
.tmp/
.temp/
coverage/
.eslintcache
# Env
.env
.env.*
!.env.example
*.local.*
# Patches/backups
*.bak
*.orig
*.rej
# Extension packaging
web-ext-artifacts/
*.crx
*.zip

File diff suppressed because it is too large Load Diff

22
LICENSE Normal file
View File

@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2024 Mark Rai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
<p align="center">
<img src="logo.png" alt="What's That!?" width="200" />
</p>
# What's That!? v0.7 (chrome-extension)
A brutally honest WhatsApp Web analyzer, because group chats have politics too. It quietly watches reactions and replies, then surfaces the social gravity you feel but cant quite prove: favoritism, marginalization, and influence.
## Why
- Uncover who gets boosted, who gets ignored, and who orbits whom.
- Spot toxic loops early. Celebrate healthy dynamics loudly.
- Which members form secret reaction cartels.
- Whos the real influencer vs. who just thinks they are.
- And yes, who loves you significantly less than you thought...
## What it shows
- Relationships: who reacts/replies to whom (and how much it skews).
- Influence: per-person scores from volume and consistency.
- Selectivity: who someone concentrates reactions on (focus, lift, bias).
- Temporal patterns: when the room actually listens.
## How to use
1. Load the extension (chrome://extensions → Load unpacked).
2. Open WhatsApp Web and scroll through chats you care about.
3. Open the dashboard. Read the room.
## Notes
- Data lives locally. Nothing leaves your machine.
- WhatsApp Web loads history in chunks; It persist what youve seen and can backfill.
- Export/Import available for audits or offline analysis.
- This is a work in progress ⚠️
- I am looking for folks to contribute to this project 🙂
## Disclaimer
I am not responsible for ruined friendships, exposed cliques, or awkward brunches.
Use responsibly. Or dont - honestly, were all curious who the groups real favorite is. But seriously...This is a mirror, not a gavel. Use insights to improve conversations - not weaponize them 😘
## License
MIT © 2024 Mark Rai

1018
background.js Normal file

File diff suppressed because it is too large Load Diff

BIN
background.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

2100
content.js Normal file

File diff suppressed because it is too large Load Diff

147
dashboard.css Normal file
View File

@@ -0,0 +1,147 @@
/* Enhanced Dashboard Styles */
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; color: #333; background: #f5f6f7; }
.header { position: sticky; top: 0; background: linear-gradient(135deg, #128c7e, #25d366); color: #fff; padding: 14px 18px; z-index: 10; }
.header-inner { display: flex; align-items: center; justify-content: space-between; max-width: 1200px; margin: 0 auto; }
.header h1 { margin: 0; font-size: 18px; font-weight: 700; }
.status-indicator { display: flex; gap: 8px; align-items: center; font-size: 12px; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; background: #ffc107; }
.status-dot.success { background: #28a745; }
.status-dot.warning { background: #ffc107; }
.status-dot.error { background: #dc3545; }
.main { max-width: 1200px; margin: 0 auto; padding: 16px; }
.controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.chat-selector { display: flex; gap: 8px; align-items: center; }
.chat-selector label { font-size: 12px; font-weight: 600; }
.chat-selector select { min-width: 260px; padding: 8px 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; font-size: 12px; }
.actions { display: flex; gap: 8px; }
.btn { padding: 8px 12px; border-radius: 6px; border: 1px solid #128c7e; background: #25d366; color: #fff; font-weight: 600; cursor: pointer; }
.btn.secondary { background: #fff; color: #128c7e; }
.btn.danger { background: #dc3545; border-color: #dc3545; }
/* Data Quality Section */
.data-quality { margin-bottom: 20px; }
.quality-card { background: #fff; border: 1px solid #e9ecef; border-radius: 10px; padding: 16px; }
.quality-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.quality-header h3 { margin: 0; font-size: 16px; font-weight: 600; }
.quality-score { font-size: 24px; font-weight: 700; color: #25d366; }
.quality-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.quality-metrics .metric { text-align: center; }
.quality-metrics .metric-label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
.quality-metrics .metric-value { font-size: 16px; font-weight: 600; color: #333; }
/* Tabs Section */
.tabs-section { margin-top: 20px; }
.tabs { display: flex; background: #fff; border-radius: 8px 8px 0 0; border: 1px solid #e9ecef; border-bottom: none; }
.tab-button { flex: 1; padding: 12px 16px; border: none; background: transparent; color: #666; font-size: 13px; font-weight: 500; cursor: pointer; transition: all 0.2s ease; }
.tab-button:hover { background: #f8f9fa; color: #333; }
.tab-button.active { background: #25d366; color: #fff; }
.tab-panel { display: none; background: #fff; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px; padding: 20px; }
.tab-panel.active { display: block; }
/* Overview Tab */
.overview-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
.overview-card { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; }
.overview-card h4 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #333; }
.metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; }
.metric-item { text-align: center; }
.metric-number { font-size: 20px; font-weight: 700; color: #25d366; }
.metric-label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: 0.5px; }
/* Temporal Analysis */
.temporal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; }
.temporal-card { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; }
.temporal-card h4 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #333; }
.chart-container { height: 200px; background: #fff; border-radius: 6px; padding: 12px; display: flex; align-items: center; justify-content: center; color: #666; }
/* Engagement Analysis */
.engagement-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
.engagement-card { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; }
.engagement-card h4 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #333; }
.participants-list, .influence-list { max-height: 200px; overflow-y: auto; }
.participant-item, .influence-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #e9ecef; }
.participant-item:last-child, .influence-item:last-child { border-bottom: none; }
/* Content Analysis */
.content-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; }
.content-card { background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; padding: 16px; }
.content-card h4 { margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #333; }
.reaction-types, .emoji-usage, .message-lengths { max-height: 200px; overflow-y: auto; }
.reaction-item, .emoji-item, .length-item { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid #e9ecef; }
.reaction-item:last-child, .emoji-item:last-child, .length-item:last-child { border-bottom: none; }
/* Relationships */
.results-container { max-height: 60vh; overflow-y: auto; background: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; }
.rel-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 6px 0 10px; font-size: 12px; color: #555; }
.rel-control { display: inline-flex; align-items: center; gap: 6px; }
.rel-summary { margin-left: auto; color: #667085; }
.simple-card { background: #fff; border: 1px solid #e9ecef; border-radius: 8px; padding: 12px; margin-bottom: 10px; }
.simple-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.person-name { font-weight: 700; color: #128c7e; }
.mini { font-size: 11px; color: #666; }
/* Relationship Cards */
#relationshipsList { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 12px; }
.relationship-card { background: #fff; border: 1px solid #e9ecef; border-radius: 10px; padding: 12px; box-shadow: 0 1px 2px rgba(0,0,0,0.03); transition: box-shadow 0.2s ease, transform 0.2s ease; }
.relationship-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); transform: translateY(-1px); }
.relationship-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.relationship-pair { display: inline-flex; align-items: center; gap: 8px; font-weight: 600; }
.reactor-name-rel { color: #128c7e; }
.sender-name-rel { color: #25d366; }
.relationship-pair .arrow { display: inline-block; color: #98a2b3; font-size: 16px; line-height: 1; }
.relationship-pair .arrow::before { content: "→"; }
.strength-badge { background: #e6f8ef; color: #128c7e; border: 1px solid #c8efdf; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; }
.relationship-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-top: 6px; }
.relationship-metrics .metric { background: #f8f9fa; border: 1px solid #eef1f4; border-radius: 8px; text-align: center; padding: 8px 6px; }
.relationship-metrics .metric-label { font-size: 10px; color: #667085; text-transform: uppercase; letter-spacing: 0.4px; }
.relationship-metrics .metric-value { font-size: 16px; font-weight: 700; color: #21725c; }
.relationship-metrics .metric-subtext { font-size: 10px; color: #98a2b3; }
.progress-bar { height: 6px; background: #eef1f4; border-radius: 999px; overflow: hidden; margin-top: 10px; }
.progress-bar .progress-fill { height: 100%; background: linear-gradient(90deg, #25d366, #128c7e); border-radius: 999px; transition: width 0.4s ease; }
@media (max-width: 640px) {
#relationshipsList { grid-template-columns: 1fr; }
}
/* Loading and Error States */
.loading, .no-data, .error { text-align: center; padding: 20px; color: #666; }
.error { color: #dc3545; }
.footer { text-align: center; padding: 12px; color: #777; font-size: 12px; }
/* Chart Placeholders */
.chart-placeholder { text-align: center; }
.chart-placeholder h5 { margin: 0 0 8px 0; font-size: 14px; font-weight: 600; color: #333; }
.chart-placeholder p { margin: 0 0 12px 0; font-size: 12px; color: #666; }
.mini-chart { display: flex; align-items: end; justify-content: space-between; height: 120px; gap: 2px; }
.mini-chart .bar { flex: 1; background: linear-gradient(to top, #25d366, #128c7e); border-radius: 2px 2px 0 0; min-height: 4px; transition: all 0.2s ease; }
.mini-chart .bar:hover { background: linear-gradient(to top, #128c7e, #25d366); }
.mini-chart-labels { display: flex; justify-content: space-between; gap: 2px; margin-top: 6px; font-size: 10px; color: #667085; }
.mini-chart-labels .label { flex: 1; text-align: center; white-space: nowrap; }
/* Status Badges */
.status-badge { padding: 2px 8px; border-radius: 12px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
.status-badge.active { background: #d4edda; color: #155724; }
.status-badge.lurker { background: #f8d7da; color: #721c24; }
/* Peak Activity */
.peak-hours, .peak-days { margin-bottom: 12px; }
.peak-hours strong, .peak-days strong { color: #333; font-size: 12px; }
/* Influence Scores */
.influence-score { font-weight: 600; color: #25d366; }
/* Responsive Design */
@media (max-width: 768px) {
.overview-grid, .temporal-grid, .engagement-grid, .content-grid { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: 1fr; }
.quality-metrics { grid-template-columns: 1fr; }
.tabs { flex-direction: column; }
.tab-button { border-radius: 0; }
.tab-button:first-child { border-radius: 8px 8px 0 0; }
.tab-button:last-child { border-radius: 0 0 8px 8px; }
.mini-chart { height: 80px; }
}

209
dashboard.html Normal file
View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Open Graph -->
<meta property="og:title" content="What's That!? — Dashboard" />
<meta property="og:description" content="Analyze WhatsApp Web dynamics: favoritism, marginalization, influence, and more." />
<meta property="og:type" content="website" />
<meta property="og:image" content="logo.png" />
<meta property="og:image:alt" content="What's That!?" />
<meta property="og:site_name" content="What's That!?" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="What's That!? — Dashboard" />
<meta name="twitter:description" content="Analyze WhatsApp Web dynamics: favoritism, marginalization, influence, and more." />
<meta name="twitter:image" content="logo.png" />
<title>What's That!? — Dashboard</title>
<link rel="stylesheet" href="dashboard.css" />
</head>
<body>
<header class="header">
<div class="header-inner">
<div style="display:flex;align-items:center;gap:8px;">
<img src="logo.png" alt="What's That!?" style="height:24px;width:24px;" />
<h1>What's That!?</h1>
</div>
<div class="status-indicator">
<span class="status-dot" id="statusDot"></span>
<span class="status-text" id="statusText">Ready</span>
</div>
</div>
</header>
<main class="main">
<section class="controls">
<div class="chat-selector">
<label for="chatSelect">Chat/Group</label>
<select id="chatSelect">
</select>
<button id="refreshChatsBtn" class="btn secondary">Refresh Chats</button>
<button id="backfillBtn" class="btn secondary">Load Older Messages</button>
</div>
<div class="actions">
<button id="refreshBtn" class="btn">Refresh Data</button>
<button id="exportBtn" class="btn secondary">Export Data</button>
<button id="clearBtn" class="btn danger">Clear Data</button>
<button id="exportCorpusBtn" class="btn secondary">Export Corpus</button>
<button id="importCorpusBtn" class="btn secondary">Import Corpus</button>
<input type="file" id="importCorpusFile" accept="application/json" style="display:none" />
</div>
</section>
<!-- Data Quality Indicator -->
<section class="data-quality" id="dataQualitySection">
<div class="quality-card">
<div class="quality-header">
<h3>Data Quality</h3>
<div class="quality-score" id="qualityScore">0%</div>
</div>
<div class="quality-metrics">
<div class="metric">
<span class="metric-label">Extraction Rate</span>
<span class="metric-value" id="extractionRate">0%</span>
</div>
<div class="metric">
<span class="metric-label">Confidence</span>
<span class="metric-value" id="confidenceLevel">0%</span>
</div>
<div class="metric">
<span class="metric-label">Sample Size</span>
<span class="metric-value" id="sampleSize">0</span>
</div>
</div>
</div>
</section>
<!-- Enhanced Analytics Tabs -->
<section class="tabs-section">
<div class="tabs">
<button class="tab-button active" data-tab="overview">Overview</button>
<button class="tab-button" data-tab="relationships">Relationships</button>
<button class="tab-button" data-tab="temporal">Activity Patterns</button>
<button class="tab-button" data-tab="engagement">Engagement</button>
<button class="tab-button" data-tab="content">Content Analysis</button>
</div>
<!-- Overview Tab -->
<div class="tab-panel active" id="overview">
<div class="overview-grid">
<div class="overview-card">
<h4>Key Metrics</h4>
<div class="metric-grid">
<div class="metric-item">
<div class="metric-number" id="totalMessagesOverview">0</div>
<div class="metric-label">Messages</div>
</div>
<div class="metric-item">
<div class="metric-number" id="totalReactionsOverview">0</div>
<div class="metric-label">Reactions</div>
</div>
<div class="metric-item">
<div class="metric-number" id="activeParticipantsOverview">0</div>
<div class="metric-label">Active Users</div>
</div>
<div class="metric-item">
<div class="metric-number" id="networkDensityOverview">0%</div>
<div class="metric-label">Network Density</div>
</div>
</div>
</div>
<div class="overview-card">
<h4>Top Influencers</h4>
<div class="influencers-list" id="topInfluencers"></div>
</div>
<div class="overview-card">
<h4>Peak Activity</h4>
<div class="peak-activity" id="peakActivity"></div>
</div>
</div>
</div>
<!-- Relationships Tab -->
<div class="tab-panel" id="relationships">
<h3>Relationship Analysis</h3>
<p class="hint">Who interacts with whom most frequently</p>
<div class="rel-controls">
<label class="rel-control">
<input type="checkbox" id="includeUnknown"> Include 'Unknown'
</label>
<label class="rel-control">
<input type="checkbox" id="showAllRelationships"> Show all
</label>
<span class="rel-summary" id="relationshipsSummary"></span>
</div>
<div class="results-container" id="relationshipsList"></div>
</div>
<!-- Temporal Analysis Tab -->
<div class="tab-panel" id="temporal">
<h3>Activity Patterns</h3>
<div class="temporal-grid">
<div class="temporal-card">
<h4>Hourly Activity</h4>
<div class="chart-container" id="hourlyChart"></div>
</div>
<div class="temporal-card">
<h4>Weekly Patterns</h4>
<div class="chart-container" id="weeklyChart"></div>
</div>
<div class="temporal-card">
<h4>Activity Trends</h4>
<div class="chart-container" id="trendsChart"></div>
</div>
</div>
</div>
<!-- Engagement Tab -->
<div class="tab-panel" id="engagement">
<h3>Engagement Analysis</h3>
<div class="engagement-grid">
<div class="engagement-card">
<h4>Active Participants</h4>
<div class="participants-list" id="activeParticipants"></div>
</div>
<div class="engagement-card">
<h4>Lurkers</h4>
<div class="participants-list" id="lurkers"></div>
</div>
<div class="engagement-card">
<h4>Influence Scores</h4>
<div class="influence-list" id="influenceScores"></div>
</div>
</div>
</div>
<!-- Content Analysis Tab -->
<div class="tab-panel" id="content">
<h3>Content Analysis</h3>
<div class="content-grid">
<div class="content-card">
<h4>Reaction Types</h4>
<div class="reaction-types" id="reactionTypes"></div>
</div>
<div class="content-card">
<h4>Top Emojis</h4>
<div class="emoji-usage" id="emojiUsage"></div>
</div>
<div class="content-card">
<h4>Message Lengths</h4>
<div class="message-lengths" id="messageLengths"></div>
</div>
</div>
</div>
</section>
</main>
<footer class="footer">
<span>Tip: Open WhatsApp Web and scroll the chat to capture more messages and reactions.</span>
</footer>
<script src="dashboard.js"></script>
</body>
</html>

994
dashboard.js Normal file
View File

@@ -0,0 +1,994 @@
class DashboardController {
constructor() {
this.stats = null;
this.availableChats = [];
this.selectedChat = '';
this.currentTab = 'overview';
this.init();
}
init() {
this.bindUI();
this.refreshChats();
this.loadStats();
this.startAutoRefresh();
this.updateStatus('Open WhatsApp Web for best results', 'warning');
}
bindUI() {
const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) refreshBtn.addEventListener('click', () => this.loadStats());
const exportBtn = document.getElementById('exportBtn');
if (exportBtn) exportBtn.addEventListener('click', () => this.exportData());
const clearBtn = document.getElementById('clearBtn');
if (clearBtn) clearBtn.addEventListener('click', () => this.clearData());
const refreshChatsBtn = document.getElementById('refreshChatsBtn');
if (refreshChatsBtn) refreshChatsBtn.addEventListener('click', () => this.refreshChats());
const chatSelect = document.getElementById('chatSelect');
if (chatSelect) chatSelect.addEventListener('change', (e) => this.selectChat(e.target.value));
// Optional: manual backfill button if present
const backfillBtn = document.getElementById('backfillBtn');
if (backfillBtn) backfillBtn.addEventListener('click', () => this.triggerBackfill());
// Export corpus
const exportCorpusBtn = document.getElementById('exportCorpusBtn');
if (exportCorpusBtn) exportCorpusBtn.addEventListener('click', () => this.exportCorpus());
// Import corpus
const importCorpusBtn = document.getElementById('importCorpusBtn');
const importCorpusFile = document.getElementById('importCorpusFile');
if (importCorpusBtn && importCorpusFile) {
importCorpusBtn.addEventListener('click', () => importCorpusFile.click());
importCorpusFile.addEventListener('change', (e) => this.importCorpusFromFile(e.target.files && e.target.files[0]));
}
// Tab switching
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Relationships controls
const includeUnknown = document.getElementById('includeUnknown');
if (includeUnknown) includeUnknown.addEventListener('change', () => this.renderRelationships());
const showAll = document.getElementById('showAllRelationships');
if (showAll) showAll.addEventListener('change', () => this.renderRelationships());
}
switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// Update tab panels
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.remove('active');
});
document.getElementById(tabName).classList.add('active');
this.currentTab = tabName;
this.renderCurrentTab();
}
renderCurrentTab() {
if (!this.stats) {
// Clear all tab content when no data
this.clearAllTabContent();
return;
}
switch (this.currentTab) {
case 'overview':
this.renderOverview();
break;
case 'relationships':
this.renderRelationships();
break;
case 'temporal':
this.renderTemporalAnalysis();
break;
case 'engagement':
this.renderEngagementAnalysis();
break;
case 'content':
this.renderContentAnalysis();
break;
}
}
clearAllTabContent() {
// Clear all tab content containers
const containers = [
'topInfluencers', 'peakActivity', 'simpleList', 'replyPairs',
'hourlyChart', 'weeklyChart', 'trendsChart',
'activeParticipants', 'lurkers', 'influenceScores',
'reactionTypes', 'emojiUsage', 'messageLengths'
];
containers.forEach(id => {
const element = document.getElementById(id);
if (element) {
element.innerHTML = '<div class="no-data">No data available</div>';
}
});
}
startAutoRefresh() {
// Clear any existing interval
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
// Auto-refresh every 5 seconds to show new data
this.refreshInterval = setInterval(async () => {
try {
// Only refresh if the dashboard is visible
if (document.visibilityState === 'visible') {
await this.loadStats();
}
} catch (error) {
console.error('Auto-refresh error:', error);
}
}, 5000); // Refresh every 5 seconds
console.log('Dashboard: Auto-refresh started (every 5 seconds)');
this.updateStatus('Auto-refresh active (every 5s)', 'info');
}
stopAutoRefresh() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
console.log('Dashboard: Auto-refresh stopped');
}
}
// Single-view dashboard; no tab switching
async refreshChats() {
try {
const res = await chrome.runtime.sendMessage({ type: 'GET_AVAILABLE_CHATS' });
if (res && res.chats) {
this.availableChats = res.chats;
} else {
// Fallback to stored chats in background
const alt = await chrome.runtime.sendMessage({ type: 'GET_STORED_CHATS' });
this.availableChats = (alt && alt.chats) || [];
}
this.populateChatSelector();
} catch (e) {
console.warn('refreshChats error', e);
const alt = await chrome.runtime.sendMessage({ type: 'GET_STORED_CHATS' });
this.availableChats = (alt && alt.chats) || [];
this.populateChatSelector();
}
}
populateChatSelector() {
const sel = document.getElementById('chatSelect');
if (!sel) return; // Add null check
const current = this.selectedChat;
sel.innerHTML = '';
this.availableChats.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.name;
if (c.id === current) opt.selected = true;
sel.appendChild(opt);
});
}
async selectChat(chatId) {
this.selectedChat = chatId || '';
await this.loadStats();
}
async loadStats() {
try {
this.showLoading();
let res;
if (this.selectedChat) {
res = await chrome.runtime.sendMessage({ type: 'GET_STATS_FOR_CHAT', chatId: this.selectedChat });
} else {
res = await chrome.runtime.sendMessage({ type: 'GET_STATS' });
}
if (res && res.stats) {
this.stats = res.stats;
this.updateOverview();
this.render();
this.updateStatus('Data loaded', 'success');
} else {
this.stats = null;
this.updateOverview();
this.showNoData();
this.updateStatus('No data available', 'warning');
}
} catch (e) {
console.error('loadStats error', e);
this.updateStatus('Error loading data', 'error');
this.showError();
}
}
updateOverview() {
const tm = document.getElementById('totalMessages');
const tr = document.getElementById('totalReactions');
if (tm) tm.textContent = this.stats?.totalMessages || 0;
if (tr) tr.textContent = this.stats?.totalReactions || 0;
}
render() {
if (!this.stats) return;
this.updateDataQuality();
this.renderCurrentTab();
}
updateDataQuality() {
if (!this.stats.dataQuality) return;
const qualityScore = document.getElementById('qualityScore');
const extractionRate = document.getElementById('extractionRate');
const confidenceLevel = document.getElementById('confidenceLevel');
const sampleSize = document.getElementById('sampleSize');
const coverageStart = this.stats.dataQuality.coverageStartTs;
const coverageEnd = this.stats.dataQuality.coverageEndTs;
if (qualityScore) qualityScore.textContent = `${Math.round(this.stats.dataQuality.completenessScore)}%`;
if (extractionRate) extractionRate.textContent = `${Math.round(this.stats.dataQuality.extractionRate)}%`;
if (confidenceLevel) confidenceLevel.textContent = `${Math.round(this.stats.dataQuality.confidenceLevel)}%`;
if (sampleSize) sampleSize.textContent = this.stats.dataQuality.sampleSize.toLocaleString();
// Add title tooltip to indicate coverage window if available
try {
const dq = document.getElementById('dataQualitySection');
if (dq && coverageStart && coverageEnd) {
const start = new Date(coverageStart).toLocaleString();
const end = new Date(coverageEnd).toLocaleString();
dq.title = `Coverage window: ${start}${end}`;
}
} catch {}
}
async triggerBackfill() {
try {
this.updateStatus('Backfill starting...', 'info');
const res = await chrome.runtime.sendMessage({ type: 'START_BACKFILL', options: { steps: 40, stepDelayMs: 900 } });
if (res && !res.error) {
this.updateStatus(`Backfill performed ${res.performed || 0} steps`, 'success');
await this.loadStats();
} else {
this.updateStatus(`Backfill error: ${res && res.error ? res.error : 'unknown'}`, 'error');
}
} catch (e) {
this.updateStatus(`Backfill failed: ${e && e.message || e}`, 'error');
}
}
async exportCorpus() {
try {
this.updateStatus('Exporting corpus...', 'info');
const res = await chrome.runtime.sendMessage({ type: 'DUMP_CORPUS' });
if (!res || res.error) throw new Error(res && res.error || 'Unknown error');
const blob = new Blob([JSON.stringify(res.corpus, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `whatsapp-corpus-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.updateStatus('Corpus exported', 'success');
} catch (e) {
this.updateStatus(`Export failed: ${e && e.message || e}`, 'error');
}
}
async importCorpusFromFile(file) {
if (!file) return;
try {
this.updateStatus('Importing corpus...', 'info');
const text = await file.text();
const corpus = JSON.parse(text);
const res = await chrome.runtime.sendMessage({ type: 'IMPORT_CORPUS', corpus });
if (!res || res.error) throw new Error(res && res.error || 'Unknown error');
this.updateStatus(`Imported ${res.total || 0} messages`, 'success');
await this.loadStats();
} catch (e) {
this.updateStatus(`Import failed: ${e && e.message || e}`, 'error');
} finally {
try {
const input = document.getElementById('importCorpusFile');
if (input) input.value = '';
} catch {}
}
}
renderOverview() {
// Update key metrics
const totalMessages = document.getElementById('totalMessagesOverview');
const totalReactions = document.getElementById('totalReactionsOverview');
const activeParticipants = document.getElementById('activeParticipantsOverview');
const networkDensity = document.getElementById('networkDensityOverview');
if (totalMessages) totalMessages.textContent = this.stats.totalMessages || 0;
if (totalReactions) totalReactions.textContent = this.stats.totalReactions || 0;
// Handle both Set objects and arrays for activeParticipants count
const activeCount = this.stats.engagementMetrics?.activeParticipants;
const participantCount = activeCount instanceof Set ? activeCount.size : (activeCount?.length || 0);
if (activeParticipants) activeParticipants.textContent = participantCount;
if (networkDensity) networkDensity.textContent = `${Math.round((this.stats.engagementMetrics?.networkDensity || 0) * 100)}%`;
// Render top influencers
this.renderTopInfluencers();
// Render peak activity
this.renderPeakActivity();
}
renderTopInfluencers() {
const container = document.getElementById('topInfluencers');
if (!container || !this.stats.engagementMetrics?.influencers) return;
// Normalize to array of {name, score, totalReactions, perMessage, totalMessages}
const raw = this.stats.engagementMetrics.influencers instanceof Map
? Array.from(this.stats.engagementMetrics.influencers.entries())
: Object.entries(this.stats.engagementMetrics.influencers);
const items = raw.map(([name, val]) => {
if (typeof val === 'number') {
return { name, score: Math.round(val * 100), totalReactions: null, perMessage: val, totalMessages: null };
}
return { name, score: val.score ?? 0, totalReactions: val.totalReactions ?? 0, perMessage: val.perMessage ?? 0, totalMessages: val.totalMessages ?? 0 };
});
// If legacy numeric values are present, derive totals from overall stats
const msgCount = this.stats.messageCount || {};
const bySender = this.stats.bySender || {};
items.forEach(it => {
const name = it.name;
const derivedMsgs = typeof msgCount[name] === 'number' ? msgCount[name] : 0;
const senderMap = bySender[name] || {};
const derivedReacts = Object.values(senderMap).reduce((s, n) => s + (typeof n === 'number' ? n : 0), 0);
if (it.totalMessages == null) it.totalMessages = derivedMsgs;
if (it.totalReactions == null) it.totalReactions = derivedReacts;
// Recompute per-message from derived data if legacy value was used
if (derivedMsgs > 0 && (typeof it.perMessage !== 'number' || it.perMessage === 0)) {
it.perMessage = derivedReacts / derivedMsgs;
}
});
const sorted = items.sort((a, b) => b.score - a.score).slice(0, 5);
// Use existing styles (.influence-item, .person-name, .influence-score)
const header = `
<div class="influence-item" title="Top influencers in this selection">
<span class="person-name mini">Person</span>
<span class="influence-score mini">Score</span>
</div>
`;
const rows = sorted.map(it => `
<div class="influence-item">
<span class="person-name">${this.escape(it.name)}</span>
<span class="influence-score" title="Influence score (0100)">${it.score}</span>
</div>
<div class="mini">Reactions: ${it.totalReactions ?? 0} | Reacts/Msg: ${(isFinite(it.perMessage) ? it.perMessage : 0).toFixed(2)}${it.totalMessages ? ` | Msgs: ${it.totalMessages}` : ''}</div>
`).join('');
container.innerHTML = (sorted.length ? (header + rows) : '<div class="no-data">No influence data available</div>');
}
renderPeakActivity() {
const container = document.getElementById('peakActivity');
if (!container || !this.stats.temporalAnalysis?.peakHours) return;
const peakHours = this.stats.temporalAnalysis.peakHours.slice(0, 3);
const peakDays = this.stats.temporalAnalysis.peakDays.slice(0, 3);
let html = '<div class="peak-hours"><strong>Peak Hours:</strong><br>';
peakHours.forEach(peak => {
html += `${peak.hour}:00 - ${peak.messages} msgs<br>`;
});
html += '</div><div class="peak-days"><strong>Peak Days:</strong><br>';
peakDays.forEach(peak => {
html += `${peak.dayName} - ${peak.messages} msgs<br>`;
});
html += '</div>';
container.innerHTML = html;
}
renderTemporalAnalysis() {
this.renderHourlyChart();
this.renderWeeklyChart();
this.renderTrendsChart();
}
renderHourlyChart() {
const container = document.getElementById('hourlyChart');
if (!container || !this.stats.temporalAnalysis?.hourlyActivity) return;
const hourlyData = this.stats.temporalAnalysis.hourlyActivity;
const hours = Array.from({length: 24}, (_, i) => i);
// Handle both Map objects and plain objects
const messages = hours.map(hour => {
const data = hourlyData instanceof Map ? hourlyData.get(hour) : hourlyData[hour];
return data?.messages || 0;
});
const reactions = hours.map(hour => {
const data = hourlyData instanceof Map ? hourlyData.get(hour) : hourlyData[hour];
return data?.reactions || 0;
});
const maxMsgs = Math.max(1, ...messages);
const labelHours = hours.map(h => (h % 3 === 0 ? String(h).padStart(2, '0') : ''));
container.innerHTML = `
<div class="chart-placeholder">
<h5>Hourly Activity</h5>
<p>Peak: ${hours[messages.indexOf(Math.max(...messages))]}:00 (${Math.max(...messages)} msgs)</p>
<div class="mini-chart">
${hours.map((hour, i) => `
<div class="bar" style="height: ${(messages[i] / maxMsgs) * 100}%;" title="${hour}:00 - ${messages[i]} msgs"></div>
`).join('')}
</div>
<div class="mini-chart-labels">
${labelHours.map(l => `<span class="label">${l}</span>`).join('')}
</div>
</div>
`;
}
renderWeeklyChart() {
const container = document.getElementById('weeklyChart');
if (!container || !this.stats.temporalAnalysis?.weeklyPatterns) return;
const weeklyData = this.stats.temporalAnalysis.weeklyPatterns;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
// Handle both Map objects and plain objects
const messages = days.map((_, i) => {
const data = weeklyData instanceof Map ? weeklyData.get(i) : weeklyData[i];
return data?.messages || 0;
});
const maxW = Math.max(1, ...messages);
container.innerHTML = `
<div class="chart-placeholder">
<h5>Weekly Patterns</h5>
<p>Most active: ${days[messages.indexOf(Math.max(...messages))]} (${Math.max(...messages)} msgs)</p>
<div class="mini-chart">
${days.map((day, i) => `
<div class="bar" style="height: ${(messages[i] / maxW) * 100}%;" title="${day} - ${messages[i]} msgs"></div>
`).join('')}
</div>
<div class="mini-chart-labels">
${days.map(d => `<span class="label">${d}</span>`).join('')}
</div>
</div>
`;
}
renderTrendsChart() {
const container = document.getElementById('trendsChart');
if (!container || !this.stats.temporalAnalysis?.activityTrends) return;
const trends = this.stats.temporalAnalysis.activityTrends.slice(-7); // Last 7 days
const dates = trends.map(t => new Date(t.date).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }));
const messages = trends.map(t => t.messages);
const reactions = trends.map(t => t.reactions || 0);
const participants = trends.map(t => t.participants || 0);
const totalMsgs = messages.reduce((s, n) => s + n, 0);
const totalReacts = reactions.reduce((s, n) => s + n, 0);
const maxT = Math.max(1, ...messages);
container.innerHTML = `
<div class="chart-placeholder">
<h5>Recent Trends</h5>
<p>Last 7 days: ${totalMsgs} msgs, ${totalReacts} reactions</p>
<div class="mini-chart">
${dates.map((date, i) => `
<div class="bar" style="height: ${(messages[i] / maxT) * 100}%;" title="${date} - ${messages[i]} msgs, ${reactions[i]} reactions, ${participants[i]} participants"></div>
`).join('')}
</div>
<div class="mini-chart-labels">
${dates.map(d => `<span class="label">${d}</span>`).join('')}
</div>
</div>
`;
}
renderEngagementAnalysis() {
this.renderActiveParticipants();
this.renderLurkers();
this.renderInfluenceScores();
}
renderActiveParticipants() {
const container = document.getElementById('activeParticipants');
if (!container || !this.stats.engagementMetrics?.activeParticipants) return;
// Handle both Set objects and arrays
const active = this.stats.engagementMetrics.activeParticipants instanceof Set
? Array.from(this.stats.engagementMetrics.activeParticipants)
: this.stats.engagementMetrics.activeParticipants;
const html = active.map(name => `
<div class="participant-item">
<span class="person-name">${this.escape(name)}</span>
<span class="status-badge active">Active</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No active participants</div>';
}
renderLurkers() {
const container = document.getElementById('lurkers');
if (!container || !this.stats.engagementMetrics?.lurkers) return;
// Handle both Set objects and arrays
const lurkers = this.stats.engagementMetrics.lurkers instanceof Set
? Array.from(this.stats.engagementMetrics.lurkers)
: this.stats.engagementMetrics.lurkers;
const html = lurkers.map(name => `
<div class="participant-item">
<span class="person-name">${this.escape(name)}</span>
<span class="status-badge lurker">Lurker</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No lurkers detected</div>';
}
renderInfluenceScores() {
const container = document.getElementById('influenceScores');
if (!container || !this.stats.engagementMetrics?.influencers) return;
// Normalize to array of {name, score, totalReactions, perMessage, totalMessages}
const raw = this.stats.engagementMetrics.influencers instanceof Map
? Array.from(this.stats.engagementMetrics.influencers.entries())
: Object.entries(this.stats.engagementMetrics.influencers);
const items = raw.map(([name, val]) => {
if (typeof val === 'number') {
return { name, score: Math.round(val * 100), totalReactions: null, perMessage: val, totalMessages: null };
}
return { name, score: val.score ?? 0, totalReactions: val.totalReactions ?? 0, perMessage: val.perMessage ?? 0, totalMessages: val.totalMessages ?? 0 };
});
// Derive totals when legacy numeric values are present
const msgCount2 = this.stats.messageCount || {};
const bySender2 = this.stats.bySender || {};
items.forEach(it => {
const name = it.name;
const derivedMsgs = typeof msgCount2[name] === 'number' ? msgCount2[name] : 0;
const senderMap = bySender2[name] || {};
const derivedReacts = Object.values(senderMap).reduce((s, n) => s + (typeof n === 'number' ? n : 0), 0);
if (it.totalMessages == null) it.totalMessages = derivedMsgs;
if (it.totalReactions == null) it.totalReactions = derivedReacts;
if (derivedMsgs > 0 && (typeof it.perMessage !== 'number' || it.perMessage === 0)) {
it.perMessage = derivedReacts / derivedMsgs;
}
});
const sorted = items.sort((a, b) => b.score - a.score);
const html = sorted.map(it => `
<div class="influence-item">
<span class="person-name">${this.escape(it.name)}</span>
<span class="influence-score">${it.score}</span>
<span class="mini">${it.totalReactions ?? 0} reactions (${it.perMessage.toFixed(2)}/msg${it.totalMessages ? `, ${it.totalMessages} msgs` : ''})</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No influence data</div>';
}
renderContentAnalysis() {
this.renderReactionTypes();
this.renderEmojiUsage();
this.renderMessageLengths();
}
renderReactionTypes() {
const container = document.getElementById('reactionTypes');
if (!container || !this.stats.contentAnalysis?.reactionTypes) return;
// Handle both Map objects and plain objects
const types = this.stats.contentAnalysis.reactionTypes instanceof Map
? Array.from(this.stats.contentAnalysis.reactionTypes.entries())
: Object.entries(this.stats.contentAnalysis.reactionTypes);
const sortedTypes = types.sort((a, b) => b[1] - a[1]);
const html = sortedTypes.map(([type, count]) => `
<div class="reaction-item">
<span class="reaction-type">${this.escape(type)}</span>
<span class="reaction-count">${count}</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No reaction data</div>';
}
renderEmojiUsage() {
const container = document.getElementById('emojiUsage');
if (!container || !this.stats.contentAnalysis?.emojiUsage) return;
// Handle both Map objects and plain objects
const emojis = this.stats.contentAnalysis.emojiUsage instanceof Map
? Array.from(this.stats.contentAnalysis.emojiUsage.entries())
: Object.entries(this.stats.contentAnalysis.emojiUsage);
const sortedEmojis = emojis
.sort((a, b) => b[1] - a[1])
.slice(0, 10);
const html = sortedEmojis.map(([emoji, count]) => `
<div class="emoji-item">
<span class="emoji">${emoji}</span>
<span class="emoji-count">${count}</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No emoji data</div>';
}
renderMessageLengths() {
const container = document.getElementById('messageLengths');
if (!container || !this.stats.contentAnalysis?.messageLengths) return;
// Handle both Map objects and plain objects
const lengths = this.stats.contentAnalysis.messageLengths instanceof Map
? Array.from(this.stats.contentAnalysis.messageLengths.entries())
: Object.entries(this.stats.contentAnalysis.messageLengths);
const sortedLengths = lengths.sort((a, b) => b[1] - a[1]);
const html = sortedLengths.map(([name, length]) => `
<div class="length-item">
<span class="person-name">${this.escape(name)}</span>
<span class="avg-length">${Math.round(length)} chars</span>
</div>
`).join('');
container.innerHTML = html || '<div class="no-data">No length data</div>';
}
renderSimpleRelationships() {
const el = document.getElementById('simpleList');
if (!el) return;
const rows = this.stats.simpleRelationships || [];
if (!rows.length) { el.innerHTML = '<div class="no-data">No relationships yet. Scroll chats to load data.</div>'; return; }
const html = rows.map(r => {
const person = this.escape(r.person);
const outSentence = r.mostOutgoing
? `${person} responds the most to ${this.escape(r.mostOutgoing.target)} (${r.mostOutgoing.count}).`
: `${person} has not responded to anyone yet.`;
const inSentence = r.mostIncoming
? `${person} receives the most responses from ${this.escape(r.mostIncoming.from)} (${r.mostIncoming.count}).`
: `${person} has not received responses yet.`;
return `
<div class="simple-card">
<div class="simple-header">
<span class="person-name">${person}</span>
<span class="mini">out: ${r.totals.outgoing} | in: ${r.totals.incoming}</span>
</div>
<div>${outSentence}</div>
<div>${inSentence}</div>
</div>`;
}).join('');
el.innerHTML = html;
}
renderRelationships() {
// Match dashboard.html container
const c = document.getElementById('relationshipsList');
if (!c) return; // Container not found
const rels = this.stats.relationships || [];
if (!rels.length) {
c.innerHTML = '<div class="no-data">No relationship data yet. Try reacting to messages.</div>';
return;
}
// Filters and limits
const includeUnknown = !!document.getElementById('includeUnknown')?.checked;
const showAll = !!document.getElementById('showAllRelationships')?.checked;
const relsFiltered = includeUnknown ? rels : rels.filter(r => r.from !== 'Unknown' && r.to !== 'Unknown');
const limit = showAll ? relsFiltered.length : Math.min(100, relsFiltered.length);
// Summary
const summary = document.getElementById('relationshipsSummary');
if (summary) summary.textContent = `${relsFiltered.length} pairs${showAll ? '' : ` (showing ${limit})`}`;
let html = '';
relsFiltered.slice(0, limit).forEach(rel => {
const strengthPercent = (rel.strength * 100).toFixed(1);
const likelihoodPercent = (rel.likelihood * 100).toFixed(1);
const focusPercent = (rel.focus * 100).toFixed(1);
html += `
<div class="relationship-card">
<div class="relationship-header">
<div class="relationship-pair">
<span class="reactor-name-rel">${this.escape(rel.from)}</span>
<span class="arrow"></span>
<span class="sender-name-rel">${this.escape(rel.to)}</span>
</div>
<div class="strength-badge">${strengthPercent}% strength</div>
</div>
<div class="relationship-metrics">
<div class="metric">
<div class="metric-label">Reactions</div>
<div class="metric-value">${rel.reactions}</div>
<div class="metric-subtext">total</div>
</div>
<div class="metric">
<div class="metric-label">Likelihood</div>
<div class="metric-value">${likelihoodPercent}%</div>
<div class="metric-subtext">${rel.reactions}/${rel.totalMessagesBy} msgs</div>
</div>
<div class="metric">
<div class="metric-label">Focus</div>
<div class="metric-value">${focusPercent}%</div>
<div class="metric-subtext">of their reactions</div>
</div>
</div>
<div class="progress-bar"><div class="progress-fill" style="width:${strengthPercent}%"></div></div>
</div>`;
});
c.innerHTML = html;
}
renderBySender() {
const el = document.getElementById('bySenderResults');
if (!el) return; // Add null check
const bySender = this.stats.bySender || {};
const senders = Object.entries(bySender);
if (!senders.length) { el.innerHTML = '<div class="no-data">No sender data</div>'; return; }
const html = senders
.sort((a,b) => (Object.values(b[1]).reduce((s,n)=>s+n,0) - Object.values(a[1]).reduce((s,n)=>s+n,0)))
.map(([sender, reactors]) => {
const total = Object.values(reactors).reduce((s,n)=>s+n,0);
const top = Object.entries(reactors).sort((a,b)=>b[1]-a[1]).slice(0,5)
.map(([r,c])=>`<div class="reactor-item"><span>${this.escape(r)}</span><span>${c}</span></div>`).join('');
return `<div class="sender-card">
<div class="sender-header"><span class="sender-name-rel">${this.escape(sender)}</span><span class="reaction-count">${total} reactions</span></div>
<div class="reactors-list">${top}</div>
</div>`;
}).join('');
el.innerHTML = html;
}
renderByReactor() {
const el = document.getElementById('byReactorResults');
if (!el) return; // Add null check
const byReactor = this.stats.byReactor || {};
const reactors = Object.entries(byReactor);
if (!reactors.length) { el.innerHTML = '<div class="no-data">No reactor data</div>'; return; }
const html = reactors
.sort((a,b) => (Object.values(b[1]).reduce((s,n)=>s+n,0) - Object.values(a[1]).reduce((s,n)=>s+n,0)))
.map(([reactor, senders]) => {
const total = Object.values(senders).reduce((s,n)=>s+n,0);
const top = Object.entries(senders).sort((a,b)=>b[1]-a[1]).slice(0,5)
.map(([s,c])=>`<div class="sender-item"><span>${this.escape(s)}</span><span>${c}</span></div>`).join('');
return `<div class="reactor-card">
<div class="reactor-header"><span class="reactor-name-rel">${this.escape(reactor)}</span><span class="reaction-count">${total} reactions given</span></div>
<div class="senders-list">${top}</div>
</div>`;
}).join('');
el.innerHTML = html;
}
renderTopReactions() {
const el = document.getElementById('topReactionsResults');
if (!el) return; // Add null check
const tops = this.stats.topReactions || {};
const entries = Object.entries(tops);
if (!entries.length) { el.innerHTML = '<div class="no-data">No top reactions data</div>'; return; }
const html = entries.map(([sender, reactors]) => {
const items = Object.entries(reactors).map(([r,c])=>`<div class="top-reactor-item"><span>${this.escape(r)}</span><span>${c}</span></div>`).join('');
return `<div class="top-reactions-card"><div class="sender-name-rel">${this.escape(sender)}</div><div class="top-reactors">${items}</div></div>`;
}).join('');
el.innerHTML = html;
}
renderSelectivity() {
const reactorsEl = document.getElementById('biasedReactorsResults');
const pairsEl = document.getElementById('biasedPairsResults');
if (!reactorsEl || !pairsEl) return; // Add null checks
const biasedReactors = this.stats.biasedReactors || [];
const selectivity = this.stats.selectivity || [];
// Reactors
if (!biasedReactors.length) {
reactorsEl.innerHTML = '<div class="no-data">No selective patterns detected yet</div>';
} else {
const html = biasedReactors.slice(0, 15).map(br => {
const top = (br.topTargets || []).slice(0, 3).map(t => `
<div class="bias-item">
<span>${this.escape(t.target)}</span>
<span>${t.reactions} • focus ${(t.focus*100).toFixed(0)}% • lift ${t.lift.toFixed(2)}</span>
</div>
`).join('');
return `
<div class="bias-card">
<div class="bias-header">
<span class="bias-name">${this.escape(br.reactor)}</span>
<span class="bias-badge">Selectivity ${(br.biasIndex*100).toFixed(0)}%</span>
</div>
<div class="bias-subtle">Total reactions: ${br.totalReactions}</div>
<div class="reactors-list">${top}</div>
</div>
`;
}).join('');
reactorsEl.innerHTML = html;
}
// Pairs
if (!selectivity.length) {
pairsEl.innerHTML = '<div class="no-data">No biased pairs detected yet</div>';
} else {
const html = selectivity.slice(0, 20).map(p => `
<div class="bias-card">
<div class="bias-header">
<span class="bias-name">${this.escape(p.reactor)}${this.escape(p.target)}</span>
<span class="bias-badge">Score ${(p.selectivity*100).toFixed(0)}%</span>
</div>
<div class="bias-metrics">
<div class="metric"><div class="metric-label">Reactions</div><div class="metric-value">${p.reactions}</div></div>
<div class="metric"><div class="metric-label">Focus</div><div class="metric-value">${(p.focus*100).toFixed(0)}%</div></div>
<div class="metric"><div class="metric-label">Lift</div><div class="metric-value">${p.lift.toFixed(2)}</div></div>
</div>
<div class="bias-subtle">Baseline msgs by ${this.escape(p.target)}: ${(p.targetMessageShare*100).toFixed(1)}%</div>
</div>
`).join('');
pairsEl.innerHTML = html;
}
}
renderResponses() {
const respondersEl = document.getElementById('biasedRespondersResults');
const replyPairsEl = document.getElementById('replyPairsResults');
if (!respondersEl || !replyPairsEl) return; // Add null checks
const biasedResponders = this.stats.biasedResponders || [];
const respondSelectivity = this.stats.respondSelectivity || [];
// Responders
if (!biasedResponders.length) {
respondersEl.innerHTML = '<div class="no-data">No selective responders detected yet</div>';
} else {
const html = biasedResponders.slice(0, 15).map(br => {
const top = (br.topTargets || []).slice(0, 3).map(t => `
<div class="bias-item">
<span>${this.escape(t.target)}</span>
<span>${t.replies} • focus ${(t.focus*100).toFixed(0)}% • lift ${t.lift.toFixed(2)}</span>
</div>
`).join('');
return `
<div class="bias-card">
<div class="bias-header">
<span class="bias-name">${this.escape(br.replier)}</span>
<span class="bias-badge">Selectivity ${(br.biasIndex*100).toFixed(0)}%</span>
</div>
<div class="bias-subtle">Total replies: ${br.totalReplies}</div>
<div class="reactors-list">${top}</div>
</div>
`;
}).join('');
respondersEl.innerHTML = html;
}
// Pair list
if (!respondSelectivity.length) {
replyPairsEl.innerHTML = '<div class="no-data">No reply pairs detected yet</div>';
} else {
const html = respondSelectivity.slice(0, 20).map(p => `
<div class="bias-card">
<div class="bias-header">
<span class="bias-name">${this.escape(p.replier)}${this.escape(p.target)}</span>
<span class="bias-badge">Score ${(p.selectivity*100).toFixed(0)}%</span>
</div>
<div class="bias-metrics">
<div class="metric"><div class="metric-label">Replies</div><div class="metric-value">${p.replies}</div></div>
<div class="metric"><div class="metric-label">Focus</div><div class="metric-value">${(p.focus*100).toFixed(0)}%</div></div>
<div class="metric"><div class="metric-label">Lift</div><div class="metric-value">${p.lift.toFixed(2)}</div></div>
</div>
<div class="bias-subtle">Baseline msgs by ${this.escape(p.target)}: ${(p.targetMessageShare*100).toFixed(1)}%</div>
</div>
`).join('');
replyPairsEl.innerHTML = html;
}
}
async clearData() {
if (!confirm('Clear all reaction data?')) return;
await chrome.runtime.sendMessage({ type: 'CLEAR_DATA' });
this.stats = null;
this.updateOverview();
this.showNoData();
this.renderCurrentTab(); // Refresh current tab content
this.updateStatus('Data cleared', 'success');
}
exportData() {
if (!this.stats) {
alert('No data to export');
return;
}
// Handle both Set objects and arrays for participants count
const activeCount = this.stats.engagementMetrics?.activeParticipants;
const participantCount = activeCount instanceof Set ? activeCount.size : (activeCount?.length || 0);
const exportData = {
exportDate: new Date().toISOString(),
chatId: this.selectedChat,
chatName: this.getChatName(this.selectedChat),
stats: this.stats,
summary: {
totalMessages: this.stats.totalMessages,
totalReactions: this.stats.totalReactions,
participants: participantCount,
dataQuality: this.stats.dataQuality?.completenessScore || 0
}
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `whatsapp-analysis-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.updateStatus('Data exported successfully', 'success');
}
getChatName(chatId) {
const chat = this.availableChats.find(c => c.id === chatId);
return chat ? chat.name : 'All Chats';
}
updateStatus(message, type) {
const statusText = document.getElementById('statusText');
const dot = document.getElementById('statusDot');
if (statusText) statusText.textContent = message;
if (dot) dot.className = `status-dot ${type}`;
}
showLoading() {
document.querySelectorAll('.results-container').forEach(c => {
if (c) c.innerHTML = '<div class="loading">Loading...</div>';
});
}
showNoData() {
const simple = document.getElementById('simpleList');
if (simple) simple.innerHTML = '<div class="no-data">No data available</div>';
}
showError() {
document.querySelectorAll('.results-container').forEach(c => {
if (c) c.innerHTML = '<div class="error">Error loading data</div>';
});
}
escape(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
}
document.addEventListener('DOMContentLoaded', () => new DashboardController());

327
index.html Normal file
View File

@@ -0,0 +1,327 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<!-- Open Graph -->
<meta property="og:title" content="What's That!?" />
<meta property="og:description" content="A brutally honest WhatsApp Web analyzer: favoritism, marginalization, influence." />
<meta property="og:type" content="website" />
<meta property="og:image" content="logo.png" />
<meta property="og:image:alt" content="What's That!?" />
<meta property="og:site_name" content="What's That!?" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="What's That!?" />
<meta name="twitter:description" content="A brutally honest WhatsApp Web analyzer: favoritism, marginalization, influence." />
<meta name="twitter:image" content="logo.png" />
<title>What's That!?</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
<style>
html,body{font-family:Inter,ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto}
.dropzone{border:2px dashed rgba(0,0,0,.15)}
header,main{position:relative;z-index:1}
</style>
</head>
<body class="bg-slate-50 text-slate-900">
<header class="sticky top-0 z-10 bg-white/80 backdrop-blur border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<img src="logo.png" alt="What's That!?" class="h-7 w-7" />
<h1 class="text-2xl font-extrabold tracking-tight">What's That!?</h1>
</div>
<div class="flex items-center gap-2">
<label class="inline-flex items-center px-3 py-2 rounded-xl border border-slate-300 bg-white hover:bg-slate-50 cursor-pointer text-sm font-semibold">
<input id="fileInput" type="file" accept=".txt" class="hidden">
<span>Select .txt</span>
</label>
<button id="demoBtn" class="px-3 py-2 rounded-xl border border-slate-300 bg-white hover:bg-slate-50 text-sm font-semibold">Try Demo</button>
</div>
</div>
</header>
<main class="max-w-7xl mx-auto px-4 py-6">
<section id="dropArea" class="dropzone rounded-2xl bg-white p-10 text-center text-slate-600">
<div class="mx-auto max-w-xl">
<div class="text-xl font-semibold">Drop your exported WhatsApp chat .txt here</div>
<div class="mt-2 text-sm">Or use the Select button above. Exports with or without media are fine.</div>
</div>
</section>
<section id="summary" class="mt-8 hidden">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200">
<div class="text-sm text-slate-500">Total messages</div>
<div id="totalMessages" class="mt-1 text-2xl font-bold"></div>
</div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200">
<div class="text-sm text-slate-500">Participants</div>
<div id="participants" class="mt-1 text-2xl font-bold"></div>
</div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200">
<div class="text-sm text-slate-500">Date range</div>
<div id="dateRange" class="mt-1 text-sm font-semibold"></div>
</div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200">
<div class="text-sm text-slate-500">Media & Links</div>
<div class="mt-1 text-sm"><span id="mediaCount" class="font-semibold">0</span> media • <span id="linkCount" class="font-semibold">0</span> links</div>
</div>
</div>
</section>
<section id="charts" class="mt-8 grid grid-cols-1 lg:grid-cols-2 gap-6 hidden">
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Messages by participant</h3><canvas id="byAuthor"></canvas></div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Messages per day</h3><canvas id="perDay"></canvas></div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Hourly distribution</h3><canvas id="byHour"></canvas></div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Weekday distribution</h3><canvas id="byWeekday"></canvas></div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Top words</h3><canvas id="topWords"></canvas></div>
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200"><h3 class="mb-3 font-semibold">Top emojis</h3><canvas id="topEmojis"></canvas></div>
</section>
<section id="tables" class="mt-8 hidden">
<div class="p-5 bg-white rounded-2xl shadow-sm border border-slate-200">
<h3 class="mb-3 font-semibold">Participants</h3>
<div class="overflow-x-auto">
<table id="participantsTable" class="min-w-full text-sm">
<thead class="text-left text-slate-500">
<tr>
<th class="py-2 pr-4">Name</th>
<th class="py-2 pr-4">Messages</th>
<th class="py-2 pr-4">Avg length</th>
<th class="py-2 pr-4">Links</th>
<th class="py-2 pr-4">Media</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100"></tbody>
</table>
</div>
</div>
</section>
</main>
<script>
const fileInput = document.getElementById('fileInput')
const dropArea = document.getElementById('dropArea')
const demoBtn = document.getElementById('demoBtn')
const stopwords = new Set("faire,2,médias,omis,c'est,oui,bonne,photos,a,abord,absolument,afin,ah,ai,aie,aient,aies,ailleurs,ainsi,ait,allaient,allo,allons,allô,alors,anterieur,anterieure,anterieures,apres,après,as,assez,attendu,au,aucun,aucune,aucuns,aujourd,aujourd'hui,aupres,auquel,aura,aurai,auraient,aurais,aurait,auras,aurez,auriez,aurions,aurons,auront,aussi,autant,autre,autrefois,autrement,autres,autrui,aux,auxquelles,auxquels,avaient,avais,avait,avant,avec,avez,aviez,avions,avoir,avons,ayant,ayez,ayons,b,bah,bas,basee,bat,beau,beaucoup,bien,bigre,bon,boum,bravo,brrr,c,car,ce,ceci,cela,celle,celle-ci,celle-là,celles,celles-ci,celles-là,celui,celui-ci,celui-là,celà,cent,cependant,certain,certaine,certaines,certains,certes,ces,cet,cette,ceux,ceux-ci,ceux-là,chacun,chacune,chaque,cher,chers,chez,chiche,chut,chère,chères,ci,cinq,cinquantaine,cinquante,cinquantième,cinquième,clac,clic,combien,comme,comment,comparable,comparables,compris,concernant,contre,couic,crac,d,da,dans,de,debout,dedans,dehors,deja,delà,depuis,dernier,derniere,derriere,derrière,des,desormais,desquelles,desquels,dessous,dessus,deux,deuxième,deuxièmement,devant,devers,devra,devrait,different,differentes,differents,différent,différente,différentes,différents,dire,directe,directement,dit,dite,dites,dits,divers,diverse,diverses,dix,dix-huit,dix-neuf,dix-sept,dixième,doit,doivent,donc,dont,dos,douze,douzième,dring,droite,du,duquel,durant,dès,début,désormais,e,effet,egale,egalement,egales,eh,elle,elle-même,elles,elles-mêmes,en,encore,enfin,entre,envers,environ,es,essai,est,et,etant,etc,etre,eu,eue,eues,euh,eurent,eus,eusse,eussent,eusses,eussiez,eussions,eut,eux,eux-mêmes,exactement,excepté,extenso,exterieur,eûmes,eût,eûtes,f,fais,faisaient,faisant,fait,faites,façon,feront,fi,flac,floc,fois,font,force,furent,fus,fusse,fussent,fusses,fussiez,fussions,fut,fûmes,fût,fûtes,g,gens,h,ha,haut,hein,hem,hep,hi,ho,holà,hop,hormis,hors,hou,houp,hue,hui,huit,huitième,hum,hurrah,hé,hélas,i,ici,il,ils,importe,j,je,jusqu,jusque,juste,k,l,la,laisser,laquelle,las,le,lequel,les,lesquelles,lesquels,leur,leurs,longtemps,lors,lorsque,lui,lui-meme,lui-même,là,lès,m,ma,maint,maintenant,mais,malgre,malgré,maximale,me,meme,memes,merci,mes,mien,mienne,miennes,miens,mille,mince,mine,minimale,moi,moi-meme,moi-même,moindres,moins,mon,mot,moyennant,multiple,multiples,même,mêmes,n,na,naturel,naturelle,naturelles,ne,neanmoins,necessaire,necessairement,neuf,neuvième,ni,nombreuses,nombreux,nommés,non,nos,notamment,notre,nous,nous-mêmes,nouveau,nouveaux,nul,néanmoins,nôtre,nôtres,o,oh,ohé,ollé,olé,on,ont,onze,onzième,ore,ou,ouf,ouias,oust,ouste,outre,ouvert,ouverte,ouverts,o|,où,p,paf,pan,par,parce,parfois,parle,parlent,parler,parmi,parole,parseme,partant,particulier,particulière,particulièrement,pas,passé,pendant,pense,permet,personne,personnes,peu,peut,peuvent,peux,pff,pfft,pfut,pif,pire,pièce,plein,plouf,plupart,plus,plusieurs,plutôt,possessif,possessifs,possible,possibles,pouah,pour,pourquoi,pourrais,pourrait,pouvait,prealable,precisement,premier,première,premièrement,pres,probable,probante,procedant,proche,près,psitt,pu,puis,puisque,pur,pure,q,qu,quand,quant,quant-à-soi,quanta,quarante,quatorze,quatre,quatre-vingt,quatrième,quatrièmement,que,quel,quelconque,quelle,quelles,quelqu'un,quelque,quelques,quels,qui,quiconque,quinze,quoi,quoique,r,rare,rarement,rares,relative,relativement,remarquable,rend,rendre,restant,reste,restent,restrictif,retour,revoici,revoilà,rien,s,sa,sacrebleu,sait,sans,sapristi,sauf,se,sein,seize,selon,semblable,semblaient,semble,semblent,sent,sept,septième,sera,serai,seraient,serais,serait,seras,serez,seriez,serions,serons,seront,ses,seul,seule,seulement,si,sien,sienne,siennes,siens,sinon,six,sixième,soi,soi-même,soient,sois,soit,soixante,sommes,son,sont,sous,souvent,soyez,soyons,specifique,specifiques,speculatif,stop,strictement,subtiles,suffisant,suffisante,suffit,suis,suit,suivant,suivante,suivantes,suivants,suivre,sujet,superpose,sur,surtout,t,ta,tac,tandis,tant,tardive,te,tel,telle,tellement,telles,tels,tenant,tend,tenir,tente,tes,tic,tien,tienne,tiennes,tiens,toc,toi,toi-même,ton,touchant,toujours,tous,tout,toute,toutefois,toutes,treize,trente,tres,trois,troisième,troisièmement,trop,très,tsoin,tsouin,tu,té,u,un,une,unes,uniformement,unique,uniques,uns,v,va,vais,valeur,vas,vers,via,vif,vifs,vingt,vivat,vive,vives,vlan,voici,voie,voient,voilà,voire,vont,vos,votre,vous,vous-mêmes,vu,vé,vôtre,vôtres,w,x,y,z,zut,à,â,ça,ès,étaient,étais,était,étant,état,étiez,étions,été,étée,étées,étés,êtes,être,ô".split(','))
function readFile(f){
const r = new FileReader()
r.onload = () => analyze(String(r.result))
r.readAsText(f)
}
fileInput.addEventListener('change', e => { if(e.target.files[0]) readFile(e.target.files[0]) })
;['dragenter','dragover'].forEach(ev=>dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.add('ring-2','ring-blue-500','bg-blue-50')}))
;['dragleave','drop'].forEach(ev=>dropArea.addEventListener(ev,e=>{e.preventDefault();dropArea.classList.remove('ring-2','ring-blue-500','bg-blue-50')}))
dropArea.addEventListener('drop', e=>{const f=e.dataTransfer.files?.[0];if(f&&/\.txt$/i.test(f.name)) readFile(f)})
demoBtn.addEventListener('click', ()=>{
const sample = `12/30/2023, 7:45 PM - Alice: Anyone up for pizza?\n12/30/2023, 7:46 PM - Bob: Yes! 🍕\n12/30/2023, 7:47 PM - Charlie: Link: https://menu.example.com\n12/31/2023, 08:10 - Alice: Happy New Year! 🎉\n[01/01/2024, 09:15] Bob: Good morning\n01/02/2024, 21:34 - Alice: <Media omitted>\n01/02/2024, 21:36 - Bob: Photo\n01/03/2024, 10:02 - Charlie: Meeting at 14:00?\n01/03/2024, 14:05 - Alice: Yes\n01/04/2024, 18:00 - Bob: 👍👍\n01/05/2024, 23:55 - Charlie: Late night message\n01/06/2024, 00:05 - Alice: After midnight\n`;
analyze(sample)
})
function isNewMsg(line){
const dash = '[\u2013\u2014\-]'
const r1 = new RegExp('^(\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}),\\s(\\d{1,2}:\\d{2})(?:\\s?(AM|PM|am|pm))?\\s'+dash+'\\s(.+)$')
const r2 = new RegExp('^\\[(\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}),\\s(\\d{1,2}:\\d{2})\\]\\s(.+)$')
return r1.test(line) || r2.test(line)
}
function parseHeader(line){
const dash = '[\u2013\u2014\-]'
const r1 = new RegExp('^(\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}),\\s(\\d{1,2}:\\d{2})(?:\\s?(AM|PM|am|pm))?\\s'+dash+'\\s(.+)$')
const r2 = new RegExp('^\\[(\\d{1,2}\\/\\d{1,2}\\/\\d{2,4}),\\s(\\d{1,2}:\\d{2})\\]\\s(.+)$')
let m = line.match(r1)
if(m) return {date:m[1], time:m[2], ampm:m[3]||'', rest:m[4]}
m = line.match(r2)
if(m) return {date:m[1], time:m[2], ampm:'', rest:m[3]}
return null
}
function detectDMY(samples){
let dmyVotes = 0, mdyVotes = 0
for(const s of samples){
const h = parseHeader(s)
if(!h) continue
const [a,b] = h.date.split('/').map(Number)
if(a>12) dmyVotes++
else if(b>12) mdyVotes++
}
return dmyVotes>mdyVotes
}
function parseDate(dateStr,timeStr,ampm,isDMY){
const [p1,p2,p3] = dateStr.split('/').map(Number)
const day = isDMY ? p1 : p2
const month = isDMY ? p2-1 : p1-1
const year = p3<100 ? (p3+2000) : p3
let [hh,mm] = timeStr.split(':').map(Number)
const am = ampm.toLowerCase()==='am'
const pm = ampm.toLowerCase()==='pm'
if(pm && hh<12) hh += 12
if(am && hh===12) hh = 0
return new Date(year,month,day,hh,mm)
}
function parseChat(txt){
const lines = txt.replace(/\r\n?/g,'\n').split('\n').filter(Boolean)
const sample = []
for(let i=0;i<Math.min(lines.length,200);i++){ if(isNewMsg(lines[i])) sample.push(lines[i]) }
const isDMY = detectDMY(sample)
const msgs = []
let cur = null
for(const line of lines){
if(isNewMsg(line)){
if(cur) msgs.push(cur)
const h = parseHeader(line)
if(!h) continue
let name = '', text = ''
const colonIdx = h.rest.indexOf(':')
if(colonIdx>-1){ name = h.rest.slice(0,colonIdx).trim(); text = h.rest.slice(colonIdx+1).trim() }
else { name = 'System'; text = h.rest.trim() }
const ts = parseDate(h.date,h.time,h.ampm,isDMY)
cur = {date:h.date,time:h.time,ampm:h.ampm,name,text,ts}
} else if(cur) {
cur.text += '\n' + line.trim()
}
}
if(cur) msgs.push(cur)
return msgs.filter(m=>m.name && m.name.toLowerCase()!=='system')
}
function analyze(txt){
const msgs = parseChat(txt)
if(!msgs.length){ alert('Could not parse messages. Make sure this is a raw WhatsApp .txt export.'); return }
document.getElementById('summary').classList.remove('hidden')
document.getElementById('charts').classList.remove('hidden')
document.getElementById('tables').classList.remove('hidden')
const byAuthor = new Map()
const byDay = new Map()
const byHour = new Array(24).fill(0)
const byWeekday = new Array(7).fill(0)
const wordFreq = new Map()
const emojiFreq = new Map()
const mediaByAuthor = new Map()
const linkByAuthor = new Map()
let mediaCount = 0, linkCount = 0
for(const m of msgs){
const d = new Date(m.ts.getFullYear(),m.ts.getMonth(),m.ts.getDate())
const dayKey = d.toISOString().slice(0,10)
byDay.set(dayKey,(byDay.get(dayKey)||0)+1)
byHour[m.ts.getHours()]++
byWeekday[m.ts.getDay()]++
byAuthor.set(m.name,(byAuthor.get(m.name)||0)+1)
const media = /<Media omitted>|image omitted|video omitted|sticker omitted|GIF omitted/i.test(m.text)
const links = (m.text.match(/https?:\/\//gi)||[]).length
if(media){ mediaCount++; mediaByAuthor.set(m.name,(mediaByAuthor.get(m.name)||0)+1) }
if(links){ linkCount+=links; linkByAuthor.set(m.name,(linkByAuthor.get(m.name)||0)+links) }
const words = m.text.toLowerCase().replace(/https?:[^\s]+/g,'').replace(/[^\p{L}\p{N}\s']/gu,' ').split(/\s+/).filter(w=>w && !stopwords.has(w) && !/^'+$/.test(w))
for(const w of words) wordFreq.set(w,(wordFreq.get(w)||0)+1)
const emojis = m.text.match(/\p{Extended_Pictographic}/gu)||[]
for(const e of emojis) emojiFreq.set(e,(emojiFreq.get(e)||0)+1)
}
const authors = [...byAuthor.keys()].sort((a,b)=>byAuthor.get(b)-byAuthor.get(a))
const first = msgs[0].ts, last = msgs[msgs.length-1].ts
document.getElementById('totalMessages').textContent = msgs.length.toLocaleString()
document.getElementById('participants').textContent = authors.length.toString()
document.getElementById('dateRange').textContent = `${first.toLocaleDateString()}${last.toLocaleDateString()}`
document.getElementById('mediaCount').textContent = mediaCount.toLocaleString()
document.getElementById('linkCount').textContent = linkCount.toLocaleString()
const tbody = document.querySelector('#participantsTable tbody')
tbody.innerHTML = ''
for(const a of authors){
const rowsMsgs = msgs.filter(m=>m.name===a)
const avgLen = Math.round(rowsMsgs.reduce((s,m)=>s+m.text.length,0)/rowsMsgs.length)
const tr = document.createElement('tr')
tr.innerHTML = `<td class="py-2 pr-4 font-medium">${escapeHtml(a)}</td><td class="py-2 pr-4">${byAuthor.get(a).toLocaleString()}</td><td class="py-2 pr-4">${avgLen}</td><td class="py-2 pr-4">${(linkByAuthor.get(a)||0).toLocaleString()}</td><td class="py-2 pr-4">${(mediaByAuthor.get(a)||0).toLocaleString()}</td>`
tbody.appendChild(tr)
}
drawBar('byAuthor', authors, authors.map(a=>byAuthor.get(a)), 'Messages')
const dayLabels = [...byDay.keys()].sort()
const dayValues = dayLabels.map(k=>byDay.get(k))
drawLine('perDay', dayLabels, dayValues, 'Messages per day')
drawBar('byHour', Array.from({length:24},(_,i)=>i), byHour, 'Messages')
const wk = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']
drawBar('byWeekday', wk, byWeekday, 'Messages')
const topWords = [...wordFreq.entries()].sort((a,b)=>b[1]-a[1]).slice(0,15)
drawHorizontalBar('topWords', topWords.map(x=>x[0]), topWords.map(x=>x[1]), 'Frequency')
const topEmojis = [...emojiFreq.entries()].sort((a,b)=>b[1]-a[1]).slice(0,10)
drawHorizontalBar('topEmojis', topEmojis.map(x=>x[0]), topEmojis.map(x=>x[1]), 'Frequency')
window.scrollTo({top: document.getElementById('summary').offsetTop - 12, behavior: 'smooth'})
}
function escapeHtml(s){
return s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;')
}
const chartRefs = {}
function destroyChart(id){ if(chartRefs[id]){ chartRefs[id].destroy(); delete chartRefs[id] } }
function drawBar(id, labels, data, label){
destroyChart(id)
const ctx = document.getElementById(id).getContext('2d')
chartRefs[id] = new Chart(ctx,{type:'bar',data:{labels,datasets:[{label,data}]},options:{responsive:true,plugins:{legend:{display:false}},scales:{x:{grid:{display:false}},y:{beginAtZero:true}}}})
}
function drawHorizontalBar(id, labels, data, label){
destroyChart(id)
const ctx = document.getElementById(id).getContext('2d')
chartRefs[id] = new Chart(ctx,{type:'bar',data:{labels,datasets:[{label,data}]},options:{indexAxis:'y',responsive:true,plugins:{legend:{display:false}},scales:{x:{beginAtZero:true}}}})
}
function drawLine(id, labels, data, label){
destroyChart(id)
const ctx = document.getElementById(id).getContext('2d')
chartRefs[id] = new Chart(ctx,{
type: 'line',
data: {
labels: labels,
datasets: [{
label: label,
data: data,
fill: false,
tension: 0.25
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
x: {
ticks: {
autoSkip: true,
maxRotation: 0,
minRotation: 0
}
}
}
}
})
}
</script>
</body>
</html>

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

27
manifest.json Normal file
View File

@@ -0,0 +1,27 @@
{
"manifest_version": 3,
"name": "What's That!?",
"version": "2.6",
"description": "Analyzes WhatsApp Web reactions to reveal who reacts to whose messages the most",
"permissions": [
"activeTab",
"storage"
],
"host_permissions": [
"https://web.whatsapp.com/*"
],
"content_scripts": [
{
"matches": ["*://web.whatsapp.com/*"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
],
"background": {
"service_worker": "background.js"
},
"action": {
"default_title": "What's That!?"
}
}

BIN
media1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
media2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

BIN
media3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

BIN
media4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

502
popup.css Normal file
View File

@@ -0,0 +1,502 @@
/* What's That!? - Popup Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #333;
line-height: 1.4;
}
.container {
width: 800px;
min-height: 600px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.header {
background: linear-gradient(135deg, #128c7e, #25d366);
color: white;
padding: 16px;
text-align: center;
}
.header h1 {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.status-indicator {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ffc107;
}
.status-dot.success {
background: #28a745;
}
.status-dot.warning {
background: #ffc107;
}
.status-dot.error {
background: #dc3545;
}
.stats-overview {
display: flex;
padding: 16px;
gap: 12px;
background: #f8f9fa;
}
.stat-card {
flex: 1;
text-align: center;
padding: 12px;
background: white;
border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #25d366;
margin-bottom: 4px;
}
.stat-label {
font-size: 11px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chat-selector {
padding: 16px;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
gap: 8px;
}
.chat-selector-label {
font-size: 12px;
font-weight: 600;
color: #333;
white-space: nowrap;
}
.chat-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 12px;
color: #333;
cursor: pointer;
}
.chat-select:focus {
outline: none;
border-color: #25d366;
box-shadow: 0 0 0 2px rgba(37, 211, 102, 0.1);
}
.refresh-btn {
padding: 8px 12px;
background: #25d366;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}
.refresh-btn:hover {
background: #128c7e;
}
.tabs {
display: flex;
background: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.tab-button {
flex: 1;
padding: 12px 8px;
border: none;
background: transparent;
color: #666;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.tab-button:hover {
background: #e9ecef;
color: #333;
}
.tab-button.active {
background: white;
color: #25d366;
border-bottom: 2px solid #25d366;
}
.tab-content {
padding: 16px;
max-height: 300px;
overflow-y: auto;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.tab-panel h3 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: #333;
}
.results-container {
max-height: 250px;
overflow-y: auto;
}
.loading, .no-data, .error {
text-align: center;
padding: 20px;
color: #666;
font-size: 13px;
}
.error {
color: #dc3545;
}
.sender-card, .reactor-card, .top-reactions-card {
background: white;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
margin-bottom: 8px;
transition: box-shadow 0.2s ease;
}
.sender-card:hover, .reactor-card:hover, .top-reactions-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.sender-header, .reactor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sender-name, .reactor-name {
font-weight: 600;
color: #333;
font-size: 13px;
}
.reaction-count {
font-size: 11px;
color: #666;
background: #f8f9fa;
padding: 2px 6px;
border-radius: 10px;
}
.reactors-list, .senders-list {
margin-bottom: 8px;
}
.reactor-item, .sender-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 12px;
}
.reactor-item:not(:last-child), .sender-item:not(:last-child) {
border-bottom: 1px solid #f8f9fa;
}
.reactor-count, .sender-count {
font-weight: 600;
color: #25d366;
background: #e8f5e8;
padding: 2px 6px;
border-radius: 10px;
font-size: 11px;
}
.top-reactor, .top-sender {
font-size: 11px;
color: #666;
padding-top: 8px;
border-top: 1px solid #f8f9fa;
}
.top-reactions-card .sender-name {
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.top-reactors {
display: flex;
flex-direction: column;
gap: 4px;
}
.top-reactor-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 12px;
}
.actions {
padding: 16px;
display: flex;
gap: 8px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.action-button {
flex: 1;
padding: 8px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.action-button.secondary {
background: #6c757d;
color: white;
}
.action-button.secondary:hover {
background: #5a6268;
}
.action-button.danger {
background: #dc3545;
color: white;
}
.action-button.danger:hover {
background: #c82333;
}
.instructions {
padding: 16px;
background: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.instructions h4 {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
color: #333;
}
.instructions ol {
font-size: 11px;
color: #666;
padding-left: 16px;
}
.instructions li {
margin-bottom: 4px;
}
/* Scrollbar styling */
.results-container::-webkit-scrollbar {
width: 6px;
}
.results-container::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.results-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.results-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* Relationships panel */
.relationship-card {
background: white;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s ease;
}
.relationship-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.relationship-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.relationship-pair {
font-size: 14px;
font-weight: 600;
color: #333;
}
.reactor-name-rel {
color: #25d366;
}
.arrow {
margin: 0 8px;
color: transparent; /* Hide any corrupted inner text */
position: relative;
}
.arrow::before {
content: '→';
color: #999;
}
.sender-name-rel {
color: #128c7e;
}
.strength-badge {
background: linear-gradient(135deg, #25d366, #128c7e);
color: white;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
}
.relationship-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-top: 12px;
}
.metric {
text-align: center;
padding: 8px;
background: #f8f9fa;
border-radius: 6px;
}
.metric-label {
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.metric-value {
font-size: 16px;
font-weight: 700;
color: #25d366;
}
.metric-subtext {
font-size: 9px;
color: #999;
margin-top: 2px;
}
.progress-bar {
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #25d366, #128c7e);
transition: width 0.3s ease;
}
/* Results container with better scrolling */
.results-container {
max-height: 450px;
overflow-y: auto;
padding-right: 4px;
}
/* Responsive adjustments */
@media (max-width: 400px) {
.container {
width: 100%;
}
.stats-overview {
flex-direction: column;
}
.actions {
flex-direction: column;
}
}
/* Override arrow content to avoid encoding issues */
.arrow::before {
content: '\2192';
}

103
popup.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>What's That!?</title>
<link rel="stylesheet" href="popup.css">
<style>
/* Full-screen mode */
html, body {
width: 800px;
min-height: 600px;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div class="container">
<header class="header">
<div style="display:flex;align-items:center;gap:6px;">
<img src="logo.png" alt="What's That!?" style="height:20px;width:20px;" />
<h1>What's That!?</h1>
</div>
<div class="status-indicator" id="statusIndicator">
<span class="status-dot"></span>
<span class="status-text">Analyzing...</span>
</div>
</header>
<div class="stats-overview" id="statsOverview">
<div class="stat-card">
<div class="stat-number" id="totalMessages">0</div>
<div class="stat-label">Messages Tracked</div>
</div>
<div class="stat-card">
<div class="stat-number" id="totalReactions">0</div>
<div class="stat-label">Total Reactions</div>
</div>
</div>
<div class="chat-selector">
<label for="chatSelect" class="chat-selector-label">Select Chat/Group:</label>
<select id="chatSelect" class="chat-select">
<option value="">Loading chats...</option>
</select>
<button id="refreshChatsBtn" class="refresh-btn">Refresh</button>
</div>
<div class="tabs">
<button class="tab-button active" data-tab="relationships">Relationships</button>
<button class="tab-button" data-tab="by-sender">By Sender</button>
<button class="tab-button" data-tab="by-reactor">By Reactor</button>
<button class="tab-button" data-tab="top-reactions">Top Reactions</button>
</div>
<div class="tab-content">
<div class="tab-panel active" id="relationships">
<h3>Relationship Strength Analysis</h3>
<div class="relationships-content"></div>
</div>
<div class="tab-panel" id="by-sender">
<h3>Who reacts to whom the most</h3>
<div class="results-container" id="bySenderResults">
<div class="loading">Loading data...</div>
</div>
</div>
<div class="tab-panel" id="by-reactor">
<h3>Reaction patterns by reactor</h3>
<div class="results-container" id="byReactorResults">
<div class="loading">Loading data...</div>
</div>
</div>
<div class="tab-panel" id="top-reactions">
<h3>Top reactors for each sender</h3>
<div class="results-container" id="topReactionsResults">
<div class="loading">Loading data...</div>
</div>
</div>
</div>
<div class="actions">
<button class="action-button secondary" id="refreshBtn">Refresh Data</button>
<button class="action-button danger" id="clearBtn">Clear Data</button>
</div>
<div class="instructions">
<h4>How to use:</h4>
<ol>
<li>Open WhatsApp Web</li>
<li>Navigate to any chat</li>
<li>This extension will automatically track reactions</li>
<li>View statistics in this popup</li>
</ol>
</div>
</div>
<script src="popup.js"></script>
</body>
</html>

438
popup.js Normal file
View File

@@ -0,0 +1,438 @@
// What's That!? - Popup Script
class PopupController {
constructor() {
this.currentTab = 'relationships';
this.stats = null;
this.availableChats = [];
this.selectedChat = null;
this.init();
}
init() {
this.setupEventListeners();
this.loadAvailableChats();
this.loadStats();
this.checkWhatsAppStatus();
}
setupEventListeners() {
// Tab switching
document.querySelectorAll('.tab-button').forEach(button => {
button.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Action buttons
document.getElementById('refreshBtn').addEventListener('click', () => {
this.loadStats();
});
document.getElementById('clearBtn').addEventListener('click', () => {
this.clearData();
});
// Chat selector
document.getElementById('chatSelect').addEventListener('change', (e) => {
this.selectChat(e.target.value);
});
document.getElementById('refreshChatsBtn').addEventListener('click', () => {
this.loadAvailableChats();
});
}
switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-tab="${tabName}"]`).classList.add('active');
// Update tab panels
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.remove('active');
});
document.getElementById(tabName).classList.add('active');
this.currentTab = tabName;
this.renderCurrentTab();
}
async loadStats() {
try {
this.showLoading();
// Request stats from background script
const response = await chrome.runtime.sendMessage({ type: 'GET_STATS' });
if (response && response.stats) {
this.stats = response.stats;
this.updateOverview();
this.renderCurrentTab();
this.updateStatus('Data loaded successfully', 'success');
} else {
this.updateStatus('No data available', 'warning');
this.showNoData();
}
} catch (error) {
console.error('Error loading stats:', error);
this.updateStatus('Error loading data', 'error');
this.showError();
}
}
updateOverview() {
if (!this.stats) return;
document.getElementById('totalMessages').textContent = this.stats.totalMessages || 0;
document.getElementById('totalReactions').textContent = this.stats.totalReactions || 0;
}
renderCurrentTab() {
if (!this.stats) return;
switch (this.currentTab) {
case 'relationships':
this.renderRelationships();
break;
case 'by-sender':
this.renderBySender();
break;
case 'by-reactor':
this.renderByReactor();
break;
case 'top-reactions':
this.renderTopReactions();
break;
}
}
renderRelationships() {
const container = document.querySelector('.relationships-content');
if (!this.stats.relationships || this.stats.relationships.length === 0) {
container.innerHTML = '<div class="no-data">No relationship data available</div>';
return;
}
let html = '';
// Show top 20 relationships
this.stats.relationships.slice(0, 20).forEach((rel, index) => {
const strengthPercent = (rel.strength * 100).toFixed(1);
const likelihoodPercent = (rel.likelihood * 100).toFixed(1);
const focusPercent = (rel.focus * 100).toFixed(1);
html += `
<div class="relationship-card">
<div class="relationship-header">
<div class="relationship-pair">
<span class="reactor-name-rel">${this.escapeHtml(rel.from)}</span>
<span class="arrow">→</span>
<span class="sender-name-rel">${this.escapeHtml(rel.to)}</span>
</div>
<div class="strength-badge">${strengthPercent}% strength</div>
</div>
<div class="relationship-metrics">
<div class="metric">
<div class="metric-label">Reactions</div>
<div class="metric-value">${rel.reactions}</div>
<div class="metric-subtext">total</div>
</div>
<div class="metric">
<div class="metric-label">Likelihood</div>
<div class="metric-value">${likelihoodPercent}%</div>
<div class="metric-subtext">${rel.reactions}/${rel.totalMessagesBy} msgs</div>
</div>
<div class="metric">
<div class="metric-label">Focus</div>
<div class="metric-value">${focusPercent}%</div>
<div class="metric-subtext">of their reactions</div>
</div>
</div>
<div class="progress-bar">
<div class="progress-fill" style="width: ${strengthPercent}%"></div>
</div>
</div>
`;
});
container.innerHTML = html;
}
renderBySender() {
const container = document.getElementById('bySenderResults');
if (!this.stats.bySender || Object.keys(this.stats.bySender).length === 0) {
container.innerHTML = '<div class="no-data">No sender data available</div>';
return;
}
const html = Object.entries(this.stats.bySender)
.sort((a, b) => {
const aTotal = Object.values(a[1]).reduce((sum, count) => sum + count, 0);
const bTotal = Object.values(b[1]).reduce((sum, count) => sum + count, 0);
return bTotal - aTotal;
})
.map(([sender, reactors]) => {
const totalReactions = Object.values(reactors).reduce((sum, count) => sum + count, 0);
const topReactor = Object.entries(reactors)
.sort((a, b) => b[1] - a[1])[0];
return `
<div class="sender-card">
<div class="sender-header">
<span class="sender-name">${this.escapeHtml(sender)}</span>
<span class="reaction-count">${totalReactions} reactions</span>
</div>
<div class="reactors-list">
${Object.entries(reactors)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([reactor, count]) => `
<div class="reactor-item">
<span class="reactor-name">${this.escapeHtml(reactor)}</span>
<span class="reactor-count">${count}</span>
</div>
`).join('')}
</div>
${topReactor ? `
<div class="top-reactor">
Most reactions from: <strong>${this.escapeHtml(topReactor[0])}</strong> (${topReactor[1]})
</div>
` : ''}
</div>
`;
}).join('');
container.innerHTML = html;
}
renderByReactor() {
const container = document.getElementById('byReactorResults');
if (!this.stats.byReactor || Object.keys(this.stats.byReactor).length === 0) {
container.innerHTML = '<div class="no-data">No reactor data available</div>';
return;
}
const html = Object.entries(this.stats.byReactor)
.sort((a, b) => {
const aTotal = Object.values(a[1]).reduce((sum, count) => sum + count, 0);
const bTotal = Object.values(b[1]).reduce((sum, count) => sum + count, 0);
return bTotal - aTotal;
})
.map(([reactor, senders]) => {
const totalReactions = Object.values(senders).reduce((sum, count) => sum + count, 0);
const topSender = Object.entries(senders)
.sort((a, b) => b[1] - a[1])[0];
return `
<div class="reactor-card">
<div class="reactor-header">
<span class="reactor-name">${this.escapeHtml(reactor)}</span>
<span class="reaction-count">${totalReactions} reactions given</span>
</div>
<div class="senders-list">
${Object.entries(senders)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([sender, count]) => `
<div class="sender-item">
<span class="sender-name">${this.escapeHtml(sender)}</span>
<span class="sender-count">${count}</span>
</div>
`).join('')}
</div>
${topSender ? `
<div class="top-sender">
Reacts most to: <strong>${this.escapeHtml(topSender[0])}</strong> (${topSender[1]})
</div>
` : ''}
</div>
`;
}).join('');
container.innerHTML = html;
}
renderTopReactions() {
const container = document.getElementById('topReactionsResults');
if (!this.stats.topReactions || Object.keys(this.stats.topReactions).length === 0) {
container.innerHTML = '<div class="no-data">No top reactions data available</div>';
return;
}
const html = Object.entries(this.stats.topReactions)
.map(([sender, reactors]) => `
<div class="top-reactions-card">
<div class="sender-name">${this.escapeHtml(sender)}</div>
<div class="top-reactors">
${Object.entries(reactors)
.map(([reactor, count]) => `
<div class="top-reactor-item">
<span class="reactor-name">${this.escapeHtml(reactor)}</span>
<span class="reaction-count">${count}</span>
</div>
`).join('')}
</div>
</div>
`).join('');
container.innerHTML = html;
}
async clearData() {
if (confirm('Are you sure you want to clear all reaction data?')) {
try {
await chrome.runtime.sendMessage({ type: 'CLEAR_DATA' });
this.stats = null;
this.updateOverview();
this.showNoData();
this.updateStatus('Data cleared', 'success');
} catch (error) {
console.error('Error clearing data:', error);
this.updateStatus('Error clearing data', 'error');
}
}
}
checkWhatsAppStatus() {
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const currentTab = tabs[0];
if (currentTab && currentTab.url.includes('web.whatsapp.com')) {
this.updateStatus('WhatsApp Web detected', 'success');
} else {
this.updateStatus('Open WhatsApp Web to start tracking', 'warning');
}
});
}
updateStatus(message, type) {
const statusText = document.querySelector('.status-text');
const statusDot = document.querySelector('.status-dot');
statusText.textContent = message;
statusDot.className = `status-dot ${type}`;
}
showLoading() {
document.querySelectorAll('.results-container').forEach(container => {
container.innerHTML = '<div class="loading">Loading data...</div>';
});
}
showNoData() {
document.querySelectorAll('.results-container').forEach(container => {
container.innerHTML = '<div class="no-data">No reaction data available yet. Start chatting on WhatsApp Web!</div>';
});
}
showError() {
document.querySelectorAll('.results-container').forEach(container => {
container.innerHTML = '<div class="error">Error loading data. Please try refreshing.</div>';
});
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async loadAvailableChats() {
try {
// Request available chats from content script
const response = await chrome.runtime.sendMessage({ type: 'GET_AVAILABLE_CHATS' });
if (response && response.chats) {
this.availableChats = response.chats;
this.populateChatSelector();
} else {
this.showChatSelectorError('No chats found');
}
} catch (error) {
console.error('Error loading chats:', error);
this.showChatSelectorError('Error loading chats');
}
}
populateChatSelector() {
const chatSelect = document.getElementById('chatSelect');
chatSelect.innerHTML = '';
if (this.availableChats.length === 0) {
chatSelect.innerHTML = '<option value="">No chats available</option>';
return;
}
// Add default option
const defaultOption = document.createElement('option');
defaultOption.value = '';
defaultOption.textContent = 'Select a chat/group...';
chatSelect.appendChild(defaultOption);
// Add chat options
this.availableChats.forEach(chat => {
const option = document.createElement('option');
option.value = chat.id;
option.textContent = chat.name;
chatSelect.appendChild(option);
});
}
showChatSelectorError(message) {
const chatSelect = document.getElementById('chatSelect');
chatSelect.innerHTML = `<option value="">${message}</option>`;
}
async selectChat(chatId) {
if (!chatId) {
this.selectedChat = null;
this.stats = null;
this.updateOverview();
this.showNoData();
return;
}
this.selectedChat = chatId;
try {
// Request stats for the selected chat
const response = await chrome.runtime.sendMessage({
type: 'GET_STATS_FOR_CHAT',
chatId: chatId
});
if (response && response.stats) {
this.stats = response.stats;
this.updateOverview();
this.renderCurrentTab();
this.updateStatus(`Data loaded for ${this.getChatName(chatId)}`, 'success');
} else {
this.updateStatus('No data available for this chat', 'warning');
this.showNoData();
}
} catch (error) {
console.error('Error loading chat stats:', error);
this.updateStatus('Error loading chat data', 'error');
this.showError();
}
}
getChatName(chatId) {
const chat = this.availableChats.find(c => c.id === chatId);
return chat ? chat.name : 'Unknown Chat';
}
}
// Initialize popup when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new PopupController();
});