first commit
This commit is contained in:
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
|
||||
|
||||
10510
Discussion WhatsApp avec Couraud voyage.txt
Normal file
10510
Discussion WhatsApp avec Couraud voyage.txt
Normal file
File diff suppressed because it is too large
Load Diff
22
LICENSE
Normal file
22
LICENSE
Normal 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
40
README.md
Normal 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 can’t 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.
|
||||
- Who’s 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 you’ve 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 don’t - honestly, we’re all curious who the group’s 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
1018
background.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
background.png
Normal file
BIN
background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 195 KiB |
2100
content.js
Normal file
2100
content.js
Normal file
File diff suppressed because it is too large
Load Diff
147
dashboard.css
Normal file
147
dashboard.css
Normal 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
209
dashboard.html
Normal 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
994
dashboard.js
Normal 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 (0–100)">${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
327
index.html
Normal 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('&','&').replaceAll('<','<').replaceAll('>','>')
|
||||
}
|
||||
|
||||
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>
|
||||
27
manifest.json
Normal file
27
manifest.json
Normal 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
BIN
media1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
media2.png
Normal file
BIN
media2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
BIN
media3.png
Normal file
BIN
media3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 155 KiB |
BIN
media4.png
Normal file
BIN
media4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
502
popup.css
Normal file
502
popup.css
Normal 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
103
popup.html
Normal 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
438
popup.js
Normal 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();
|
||||
});
|
||||
Reference in New Issue
Block a user