1791 lines
82 KiB
HTML
1791 lines
82 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>WhatsApp + Threema Aggregate Stats</title>
|
||
<style>
|
||
:root { --bg:#070b17; --panel:#131a2e; --text:#eaf0ff; --muted:#95a3c7; --good:#3ddc97; --bad:#ff6b6b; --border:#2a355d; }
|
||
* { box-sizing:border-box; }
|
||
body { margin:0; color:var(--text); font-family:Inter,system-ui,sans-serif; line-height:1.35;
|
||
background:
|
||
radial-gradient(1000px 600px at -10% -20%, rgba(110,168,254,.24), transparent 58%),
|
||
radial-gradient(900px 700px at 110% -30%, rgba(126,255,199,.16), transparent 62%),
|
||
radial-gradient(700px 700px at 50% 120%, rgba(194,123,255,.14), transparent 65%),
|
||
linear-gradient(160deg,#080d1d 0%,#060a15 55%,#050811 100%); min-height:100vh; }
|
||
.wrap { max-width:1300px; margin:0 auto; padding:24px; }
|
||
.hero { display:grid; gap:14px; margin-bottom:18px; }
|
||
h1 { margin:0; font-size:clamp(1.3rem,2vw,2rem); }
|
||
.sub,.hint,.small,#status { color:var(--muted); }
|
||
.upload,.section,.card { border:1px solid var(--border); border-radius:12px; }
|
||
.upload { display:flex; flex-wrap:wrap; gap:10px; align-items:center; background:rgba(255,255,255,.02); padding:14px; }
|
||
.toolbar { display:grid; gap:10px; grid-template-columns:1fr; margin-top:8px; }
|
||
@media (min-width:1000px){ .toolbar { grid-template-columns:1fr 1fr; } }
|
||
.toolbox { border:1px solid var(--border); border-radius:12px; padding:12px; background:rgba(255,255,255,.02); display:flex; flex-wrap:wrap; gap:8px; align-items:center; }
|
||
.toolbox input,.toolbox select,.toolbox button { background:#0f1730; color:var(--text); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||
.toolbox button { cursor:pointer; }
|
||
.toolbox label { display:flex; align-items:center; gap:6px; }
|
||
.lookup-result { margin-top:6px; color:var(--muted); font-size:.85rem; width:100%; }
|
||
.grid { display:grid; gap:12px; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); margin:16px 0 22px; }
|
||
.card { background:linear-gradient(180deg, rgba(19,26,46,.9), rgba(25,35,65,.78)); padding:12px; min-height:92px; }
|
||
.k { color:var(--muted); font-size:.82rem; margin-bottom:6px; }
|
||
.v { font-size:1.4rem; font-weight:700; }
|
||
.delta { margin-top:4px; font-size:.82rem; }
|
||
.delta.up { color:var(--good); } .delta.down { color:var(--bad); } .delta.flat { color:var(--muted); }
|
||
.row { display:grid; gap:12px; grid-template-columns:1fr; }
|
||
@media (min-width:1000px){ .row.two { grid-template-columns:1fr 1fr; } }
|
||
.yearly-charts { display:grid; gap:10px; grid-template-columns:1fr; }
|
||
@media (min-width:1000px){ .yearly-charts { grid-template-columns:1fr 1fr 1fr; } }
|
||
.section { background:linear-gradient(180deg, rgba(19,26,46,.72), rgba(17,23,42,.72)); padding:14px; margin-bottom:12px; }
|
||
.section h2 { margin:2px 0 10px; font-size:1.05rem; }
|
||
.chart-wrap { height:290px; width:100%; margin-top:8px; border:1px solid var(--border); border-radius:10px; background:linear-gradient(180deg, rgba(8,14,29,.85), rgba(9,16,32,.85)); padding:8px; }
|
||
.chart-canvas { width:100%; height:100%; display:block; }
|
||
.legend { display:flex; gap:12px; flex-wrap:wrap; margin-top:6px; color:var(--muted); font-size:.8rem; }
|
||
.legend i { display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:middle; }
|
||
.bars { display:grid; gap:8px; }
|
||
.bar-row { display:grid; gap:10px; grid-template-columns:160px 1fr 200px; align-items:center; }
|
||
.bar-label { font-size:.83rem; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.bar-track { height:10px; border-radius:999px; background:#0e152d; border:1px solid var(--border); overflow:hidden; }
|
||
.bar-fill { height:100%; background:linear-gradient(90deg,#67c1ff,#7effc7); width:0%; }
|
||
.bar-value { text-align:right; font-variant-numeric:tabular-nums; font-size:.8rem; white-space:nowrap; }
|
||
.list-nav { display:flex; gap:8px; align-items:center; margin-top:10px; }
|
||
.list-nav button { background:#0f1730; color:var(--text); border:1px solid var(--border); border-radius:8px; padding:6px 10px; cursor:pointer; }
|
||
.list-nav .small { margin-left:4px; }
|
||
.inline-controls { display:flex; gap:8px; align-items:left; flex-wrap:wrap; margin-bottom:8px; }
|
||
.inline-controls select { background:#0f1730; color:var(--text); border:1px solid var(--border); border-radius:8px; padding:6px 10px; }
|
||
table { width:100%; border-collapse:collapse; font-size:.88rem; }
|
||
th,td { text-align:left; padding:8px; border-bottom:1px solid var(--border); vertical-align:top; font-variant-numeric:tabular-nums; }
|
||
th { color:#b6c4ea; font-weight:600; }
|
||
.map-controls { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:8px 0; }
|
||
.map-controls button { background:#0f1730; color:var(--text); border:1px solid var(--border); border-radius:8px; padding:6px 10px; cursor:pointer; }
|
||
.map-controls .small { margin-left:4px; }
|
||
.map-input { width:100%; min-width:140px; background:#0f1730; color:var(--text); border:1px solid var(--border); border-radius:6px; padding:6px 8px; }
|
||
.suggestions { display:flex; gap:6px; flex-wrap:wrap; }
|
||
.suggestions button { background:#121d3f; color:var(--text); border:1px solid var(--border); border-radius:999px; padding:4px 8px; font-size:.78rem; cursor:pointer; }
|
||
.anon-btn { background:transparent; color:var(--muted); border:1px solid var(--border); border-radius:999px; font-size:.72rem; line-height:1; padding:2px 6px; margin-right:6px; cursor:pointer; }
|
||
.anon-btn:hover { color:var(--text); border-color:#4a5f95; }
|
||
.anon-pill { color:#bcd6ff; }
|
||
details.section-toggle { border:1px solid var(--border); border-radius:12px; background:linear-gradient(180deg, rgba(19,26,46,.72), rgba(17,23,42,.72)); }
|
||
details.section-toggle > summary { cursor:pointer; padding:12px 14px; color:#b6c4ea; font-weight:600; }
|
||
details.section-toggle > .section { margin:0; border:0; border-top:1px solid var(--border); border-radius:0 0 12px 12px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<section class="hero">
|
||
<h1>WhatsApp + Threema Aggregate Statistics</h1>
|
||
<p class="sub">Drop your full result folder to compute combined metrics by day and by week.</p>
|
||
<div class="upload">
|
||
<input id="folderInput" type="file" webkitdirectory directory multiple accept=".json,.csv,application/json,text/csv" />
|
||
<input id="vcfInput" type="file" multiple accept=".vcf,text/vcard,text/x-vcard" />
|
||
<span class="hint">Tip: choose the full result folder so the page includes WhatsApp chats/calls and result/threema CSVs. Optionally add VCF files to improve number→name matching.</span>
|
||
</div>
|
||
<div class="toolbar">
|
||
<div class="toolbox">
|
||
<strong>Chart mode</strong>
|
||
<select id="chartMode">
|
||
<option value="recent">Recent windows</option>
|
||
<option value="weekly">Weekly by year</option>
|
||
</select>
|
||
<label><input id="chartLogToggle" type="checkbox" checked /> Log scale charts</label>
|
||
<label>Cutoff year <input id="cutoffYearInput" type="number" min="2009" max="2100" step="1" value="2015" style="width:92px;" /></label>
|
||
<strong>Focus</strong>
|
||
<select id="chartContactSelect">
|
||
<option value="__all__">All contacts</option>
|
||
</select>
|
||
<button id="prevYearBtn" type="button">← Year</button>
|
||
<span id="yearLabel" class="small">Recent window</span>
|
||
<button id="nextYearBtn" type="button">Year →</button>
|
||
</div>
|
||
<div class="toolbox">
|
||
<strong>Lookup number</strong>
|
||
<input id="numberLookup" type="text" placeholder="e.g. 41794751819" />
|
||
<button id="lookupBtn" type="button">Search</button>
|
||
<label><input id="anonymityToggle" type="checkbox" /> Anonymity mode</label>
|
||
<div id="lookupResult" class="lookup-result">Load data, then search by number.</div>
|
||
</div>
|
||
</div>
|
||
<div id="status"></div>
|
||
</section>
|
||
|
||
<section class="grid" id="kpiGrid"></section>
|
||
|
||
<section class="row two">
|
||
<div class="section">
|
||
<h2 id="trendMessagesTitle">Trend: messages (last 30 days)</h2>
|
||
<div class="chart-wrap"><canvas id="trendMessagesChart" class="chart-canvas"></canvas></div>
|
||
<div class="legend"><span><i style="background:#6ea8fe"></i>Messages</span></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2 id="trendActivityTitle">Trend: active contacts / calls (last 30 days)</h2>
|
||
<div class="chart-wrap"><canvas id="trendActivityChart" class="chart-canvas"></canvas></div>
|
||
<div class="legend"><span><i style="background:#7effc7"></i>Active contacts (all) / calls (focused contact)</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row two">
|
||
<div class="section">
|
||
<h2 id="volumeMessagesTitle">Weekly volume: messages (last 16 weeks)</h2>
|
||
<div class="chart-wrap"><canvas id="volumeMessagesChart" class="chart-canvas"></canvas></div>
|
||
<div class="legend"><span><i style="background:#c27bff"></i>Messages</span></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2 id="volumeCallsTitle">Weekly volume: call duration (last 16 weeks)</h2>
|
||
<div class="chart-wrap"><canvas id="volumeCallsChart" class="chart-canvas"></canvas></div>
|
||
<div class="legend"><span><i style="background:#ff6b6b"></i>Call duration (minutes)</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row two">
|
||
<div class="section">
|
||
<h2>Most active contacts</h2>
|
||
<div class="inline-controls">
|
||
<label for="contactSortMode" class="small">Sort by</label>
|
||
<select id="contactSortMode">
|
||
<option value="activity">Activity (msg + calls)</option>
|
||
<option value="messages">Messages</option>
|
||
<option value="calls">Calls</option>
|
||
<option value="callDuration">Call duration</option>
|
||
<option value="consistency">Consistency</option>
|
||
</select>
|
||
</div>
|
||
<div id="contactBars" class="bars"></div>
|
||
<div class="list-nav">
|
||
<button id="contactsPrevBtn" type="button">← Prev</button>
|
||
<button id="contactsNextBtn" type="button">Next →</button>
|
||
<span id="contactsPageInfo" class="small"></span>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>Most active groups</h2>
|
||
<div id="groupBars" class="bars"></div>
|
||
<div class="list-nav">
|
||
<button id="groupsPrevBtn" type="button">← Prev</button>
|
||
<button id="groupsNextBtn" type="button">Next →</button>
|
||
<span id="groupsPageInfo" class="small"></span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row">
|
||
<div class="section">
|
||
<h2>Histogram: messages per contact</h2>
|
||
<div class="small">X axis: message-count bins · Y axis: number of contacts in each bin.</div>
|
||
<div class="toolbox" style="margin-top:8px;">
|
||
<label><input id="histLogToggle" type="checkbox" checked /> Logarithmic y-axis</label>
|
||
</div>
|
||
<div class="chart-wrap"><canvas id="messagesHistogramChart" class="chart-canvas"></canvas></div>
|
||
<div class="legend"><span><i style="background:#6ea8fe"></i>Contacts per message bin</span></div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row two">
|
||
<div class="section"><h2 id="weeklyComparisonTitle">Weekly comparison (recent 16 weeks)</h2><div class="small">Compare message volume, active contacts, average text length, call count and call duration.</div><table id="weeklyTable"></table></div>
|
||
<div class="section"><h2>Call insights</h2><table id="callTable"></table></div>
|
||
</section>
|
||
|
||
<section class="row">
|
||
<div class="section">
|
||
<h2>Top 5 contacts per year (private only)</h2>
|
||
<div class="small">Ranks are based on yearly activity (messages + calls). Move markers compare against the previous year.</div>
|
||
<table id="yearTopContactsTable"></table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row">
|
||
<div class="section">
|
||
<h2>Yearly aggregated stats</h2>
|
||
<div class="small">One row per year across all available data.</div>
|
||
<div class="yearly-charts">
|
||
<div>
|
||
<div class="small">Messages</div>
|
||
<div class="chart-wrap"><canvas id="yearlyMessagesChart" class="chart-canvas"></canvas></div>
|
||
</div>
|
||
<div>
|
||
<div class="small">Calls</div>
|
||
<div class="chart-wrap"><canvas id="yearlyCallsChart" class="chart-canvas"></canvas></div>
|
||
</div>
|
||
<div>
|
||
<div class="small">Call duration (hours)</div>
|
||
<div class="chart-wrap"><canvas id="yearlyDurationChart" class="chart-canvas"></canvas></div>
|
||
</div>
|
||
</div>
|
||
<div class="legend">
|
||
<span><i style="background:#6ea8fe"></i>Messages</span>
|
||
<span><i style="background:#ffce54"></i>Calls</span>
|
||
<span><i style="background:#ff6b6b"></i>Call duration (h)</span>
|
||
</div>
|
||
<div class="small">Raw yearly bars with separate y-axis per metric.</div>
|
||
<table id="yearlyTable"></table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="row">
|
||
<details class="section-toggle" id="identityLinksPanel">
|
||
<summary>Threema ↔ WhatsApp identity links (optional)</summary>
|
||
<div class="section">
|
||
<div class="small">Map high-activity Threema identities to WhatsApp numbers. Links are persisted in browser storage.</div>
|
||
<div class="map-controls">
|
||
<button id="exportMapBtn" type="button">Export links</button>
|
||
<button id="importMapBtn" type="button">Import links</button>
|
||
<input id="importMapInput" type="file" accept="application/json,.json" style="display:none;" />
|
||
<span id="mappingStatus" class="small">No links loaded.</span>
|
||
</div>
|
||
<table id="threemaMappingTable"></table>
|
||
</div>
|
||
</details>
|
||
</section>
|
||
</div>
|
||
|
||
<script>
|
||
const $ = (id) => document.getElementById(id);
|
||
const statusEl = $('status');
|
||
const MAPPING_STORAGE_KEY = 'chatanalyzer.threema-map.v1';
|
||
let latestStats = null;
|
||
let latestFolderFiles = [];
|
||
let extraVcfFiles = [];
|
||
let chartMode = 'recent';
|
||
let selectedYear = null;
|
||
let contactsPage = 0;
|
||
let groupsPage = 0;
|
||
let contactSortMode = 'activity';
|
||
let selectedChartContactKey = '__all__';
|
||
let cutoffYear = 2015;
|
||
let chartLogScale = true;
|
||
const PAGE_SIZE = 10;
|
||
let histogramLogScale = true;
|
||
let identityMapping = loadThreemaMapping();
|
||
let anonymityMode = false;
|
||
const revealedNameKeys = new Set();
|
||
const anonAliasCache = new Map();
|
||
const ANON_MONSTERS = ['Pikachu','Eevee','Snorlax','Bulbasaur','Charmander','Squirtle','Mew','Psyduck','Jigglypuff','Gengar','Togepi','Lapras','Raichu','Mewtwo','Clefairy','Meowth','Growlithe','Arcanine','Machamp','Geodude','Golem','Slowpoke','Magikarp','Gyarados','Ditto','Vaporeon','Jolteon','Flareon','Porygon','Snorlax','Dragonite','Articuno','Zapdos','Moltres','Caterpie','Metapod','Butterfree','Weedle','Kakuna','Beedrill','Pidgey','Pidgeot','Rattata','Ekans','Arbok','Sandshrew','Nidoran','Nidoking','Clefable','Vulpix','Ninetales','Wigglytuff','Zubat','Golbat','Oddish','Paras','Venonat','Diglett','Dugtrio','Mankey','Primeape','Poliwag','Abra','Kadabra','Alakazam','Bellsprout','Tentacruel','Ponyta','Rapidash','Farfetchd','Doduo','Seel','Dewgong','Grimer','Muk','Shellder','Cloyster','Haunter','Onix','Drowzee','Hypno','Krabby','Kingler','Voltorb','Electrode','Exeggcute','Cubone','Hitmonlee','Hitmonchan','Lickitung','Koffing','Weezing','Rhyhorn','Chansey','Tangela','Horsea','Goldeen','Staryu','Starmie','MrMime','Scyther','Jynx','Electabuzz','Magmar','Pinsir','Tauros','Dratini','Dragonair','Kabuto','Omanyte','Aerodactyl'];
|
||
|
||
$('folderInput').addEventListener('change', async (e) => {
|
||
const files = Array.from(e.target.files || []).filter((f) => /\.(json|csv)$/i.test(f.name));
|
||
latestFolderFiles = files;
|
||
if (!files.length && !extraVcfFiles.length) return setStatus('No JSON/CSV/VCF files selected.');
|
||
await recomputeFromLatestFiles();
|
||
});
|
||
|
||
$('vcfInput').addEventListener('change', async (e) => {
|
||
const files = Array.from(e.target.files || []).filter((f) => /\.vcf$/i.test(f.name));
|
||
extraVcfFiles = files;
|
||
if (!latestFolderFiles.length && !extraVcfFiles.length) return setStatus('No JSON/CSV/VCF files selected.');
|
||
await recomputeFromLatestFiles();
|
||
});
|
||
window.addEventListener('resize', () => latestStats && renderCharts(latestStats));
|
||
|
||
$('chartMode').addEventListener('change', (e) => {
|
||
chartMode = e.target.value;
|
||
if (latestStats) {
|
||
renderCharts(latestStats);
|
||
}
|
||
});
|
||
|
||
$('chartContactSelect').addEventListener('change', (e) => {
|
||
selectedChartContactKey = e.target.value || '__all__';
|
||
if (latestStats) {
|
||
renderCharts(latestStats);
|
||
}
|
||
});
|
||
|
||
$('contactSortMode').addEventListener('change', (e) => {
|
||
contactSortMode = e.target.value || 'activity';
|
||
contactsPage = 0;
|
||
if (latestStats) renderPagedBars();
|
||
});
|
||
|
||
$('cutoffYearInput').addEventListener('change', async (e) => {
|
||
const y = Number.parseInt(e.target.value, 10);
|
||
cutoffYear = Number.isFinite(y) && y >= 2009 && y <= 2100 ? y : 2015;
|
||
e.target.value = String(cutoffYear);
|
||
if (latestFolderFiles.length || extraVcfFiles.length) await recomputeFromLatestFiles();
|
||
});
|
||
|
||
$('prevYearBtn').addEventListener('click', () => {
|
||
if (!latestStats || chartMode !== 'weekly') return;
|
||
const years = getActiveYears(latestStats);
|
||
const idx = years.indexOf(selectedYear);
|
||
if (idx > 0) selectedYear = years[idx - 1];
|
||
renderCharts(latestStats);
|
||
});
|
||
|
||
$('nextYearBtn').addEventListener('click', () => {
|
||
if (!latestStats || chartMode !== 'weekly') return;
|
||
const years = getActiveYears(latestStats);
|
||
const idx = years.indexOf(selectedYear);
|
||
if (idx >= 0 && idx < years.length - 1) selectedYear = years[idx + 1];
|
||
renderCharts(latestStats);
|
||
});
|
||
|
||
$('lookupBtn').addEventListener('click', () => runLookup());
|
||
$('numberLookup').addEventListener('keydown', (e) => { if (e.key === 'Enter') runLookup(); });
|
||
$('anonymityToggle').addEventListener('change', (e) => {
|
||
anonymityMode = !!e.target.checked;
|
||
if (latestStats) renderDashboard(latestStats, { preservePagination: true });
|
||
});
|
||
$('histLogToggle').addEventListener('change', (e) => {
|
||
histogramLogScale = !!e.target.checked;
|
||
if (latestStats) renderCharts(latestStats);
|
||
});
|
||
|
||
$('chartLogToggle').addEventListener('change', (e) => {
|
||
chartLogScale = !!e.target.checked;
|
||
if (latestStats) renderCharts(latestStats);
|
||
});
|
||
|
||
$('contactsPrevBtn').addEventListener('click', () => {
|
||
if (!latestStats) return;
|
||
contactsPage = Math.max(0, contactsPage - 1);
|
||
renderPagedBars();
|
||
});
|
||
$('contactsNextBtn').addEventListener('click', () => {
|
||
if (!latestStats) return;
|
||
const maxPage = Math.max(0, Math.ceil((latestStats.topContacts || []).length / PAGE_SIZE) - 1);
|
||
contactsPage = Math.min(maxPage, contactsPage + 1);
|
||
renderPagedBars();
|
||
});
|
||
$('groupsPrevBtn').addEventListener('click', () => {
|
||
if (!latestStats) return;
|
||
groupsPage = Math.max(0, groupsPage - 1);
|
||
renderPagedBars();
|
||
});
|
||
$('groupsNextBtn').addEventListener('click', () => {
|
||
if (!latestStats) return;
|
||
const maxPage = Math.max(0, Math.ceil((latestStats.topGroups || []).length / PAGE_SIZE) - 1);
|
||
groupsPage = Math.min(maxPage, groupsPage + 1);
|
||
renderPagedBars();
|
||
});
|
||
|
||
$('exportMapBtn').addEventListener('click', () => {
|
||
const blob = new Blob([JSON.stringify(identityMapping, null, 2)], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'threema_whatsapp_mapping.json';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
});
|
||
|
||
$('importMapBtn').addEventListener('click', () => $('importMapInput').click());
|
||
$('importMapInput').addEventListener('change', async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
try {
|
||
const parsed = JSON.parse(await readText(file));
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('Invalid mapping format');
|
||
identityMapping = Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).trim().toUpperCase(), normalizeNumber(v)]).filter(([k, v]) => k && v));
|
||
saveThreemaMapping(identityMapping);
|
||
setMappingStatus(`Imported ${Object.keys(identityMapping).length} link(s).`);
|
||
await recomputeFromLatestFiles();
|
||
} catch (err) {
|
||
console.error(err);
|
||
setMappingStatus(`Import failed: ${err.message || err}`);
|
||
} finally {
|
||
e.target.value = '';
|
||
}
|
||
});
|
||
|
||
$('threemaMappingTable').addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button');
|
||
if (!btn) return;
|
||
const identity = btn.dataset.identity;
|
||
if (!identity) return;
|
||
if (btn.classList.contains('map-suggest-btn')) {
|
||
const input = document.querySelector(`input.map-input[data-identity="${identity}"]`);
|
||
if (input) input.value = btn.dataset.number || '';
|
||
return;
|
||
}
|
||
if (btn.classList.contains('map-save-btn')) {
|
||
const input = document.querySelector(`input.map-input[data-identity="${identity}"]`);
|
||
const mapped = normalizeNumber(input?.value || '');
|
||
if (!mapped) delete identityMapping[identity];
|
||
else identityMapping[identity] = mapped;
|
||
saveThreemaMapping(identityMapping);
|
||
setMappingStatus(`Saved ${Object.keys(identityMapping).length} link(s).`);
|
||
await recomputeFromLatestFiles();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
const btn = e.target.closest('.anon-btn');
|
||
if (!btn) return;
|
||
const key = btn.dataset.anonKey;
|
||
if (!key) return;
|
||
if (revealedNameKeys.has(key)) revealedNameKeys.delete(key);
|
||
else revealedNameKeys.add(key);
|
||
if (latestStats) renderDashboard(latestStats, { preservePagination: true });
|
||
});
|
||
|
||
function setStatus(t){ statusEl.textContent = t; }
|
||
function setMappingStatus(t){ $('mappingStatus').textContent = t; }
|
||
function loadThreemaMapping(){
|
||
try {
|
||
const raw = localStorage.getItem(MAPPING_STORAGE_KEY);
|
||
if (!raw) return {};
|
||
const parsed = JSON.parse(raw);
|
||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
|
||
return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k).trim().toUpperCase(), normalizeNumber(v)]).filter(([k, v]) => k && v));
|
||
} catch {
|
||
return {};
|
||
}
|
||
}
|
||
function saveThreemaMapping(map){
|
||
localStorage.setItem(MAPPING_STORAGE_KEY, JSON.stringify(map));
|
||
}
|
||
|
||
async function recomputeFromLatestFiles(){
|
||
const latestFiles = [...latestFolderFiles, ...extraVcfFiles];
|
||
if (!latestFiles.length) return;
|
||
setStatus(`Reading ${latestFiles.length} files...`);
|
||
try {
|
||
const stats = await computeStats(latestFiles);
|
||
renderDashboard(stats);
|
||
setStatus(`Done. Parsed ${stats.meta.filesParsed.toLocaleString()} files and ${stats.meta.totalMessages.toLocaleString()} events.`);
|
||
} catch (err) {
|
||
console.error(err);
|
||
setStatus(`Failed to parse files: ${err.message || err}`);
|
||
}
|
||
}
|
||
|
||
function readText(file){ return new Promise((res,rej)=>{ const r=new FileReader(); r.onload=()=>res(r.result); r.onerror=()=>rej(new Error(`Could not read ${file.name}`)); r.readAsText(file); }); }
|
||
function safeJsonParse(t,n){ try{return JSON.parse(t);}catch{ throw new Error(`Invalid JSON in ${n}`);} }
|
||
function normalizeText(v){ return typeof v==='string' ? v.replace(/<br\s*\/?>/gi,'\n').replace(/\s+/g,' ').trim() : ''; }
|
||
function yyyyMmDd(ts){ return new Date(ts*1000).toISOString().slice(0,10); }
|
||
function isoWeek(ts){ const d=new Date(ts*1000); const x=new Date(Date.UTC(d.getUTCFullYear(),d.getUTCMonth(),d.getUTCDate())); const day=x.getUTCDay()||7; x.setUTCDate(x.getUTCDate()+4-day); const y0=new Date(Date.UTC(x.getUTCFullYear(),0,1)); const w=Math.ceil((((x-y0)/86400000)+1)/7); return `${x.getUTCFullYear()}-W${String(w).padStart(2,'0')}`; }
|
||
function emptyBucket(){ return {messages:0,textMessages:0,chars:0,calls:0,callDurationSec:0,activeContacts:new Set()}; }
|
||
function getOrInit(m,k,f){ if(!m.has(k)) m.set(k,f()); return m.get(k); }
|
||
function normalizeNumber(v){ return String(v || '').replace(/\D+/g, ''); }
|
||
function escapeHtml(v){ return String(v || '').replace(/[&<>"']/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); }
|
||
function relPath(file){ return (file.webkitRelativePath || file.name || '').replace(/\\/g, '/'); }
|
||
function isLikelyGroup(chatId,fileName){ if(!chatId) return /group|\bg\.us\b/i.test(fileName); return chatId.includes('@g.us')||/\bg\.us\b/.test(fileName); }
|
||
function looksLikeCall(msg){ const data=(msg?.data||'').toLowerCase(); const key=msg?.key_id||''; return key.startsWith('call:')||/^a (voice|video) call /.test(data)||data.includes('call was missed')||data.includes('call was not answered')||data.includes('lasted for'); }
|
||
function parseDurationToSeconds(f){ if(!f) return 0; const t=f.toLowerCase(); const h=+(t.match(/(\d+)\s*hour/)?.[1]||0); const m=+(t.match(/(\d+)\s*minute/)?.[1]||0); const s=+(t.match(/(\d+)\s*second/)?.[1]||0); return h*3600+m*60+s; }
|
||
function parseCallInfo(msg){ const data=(msg?.data||'').toLowerCase(); if(!looksLikeCall(msg)) return null; const type=data.includes('video call')?'video':'voice'; const status=data.includes('missed')?'missed':data.includes('not answered')?'not_answered':data.includes('lasted for')?'completed':'other'; const dm=data.match(/lasted for (.+?) with/i); return {type,status,durationSec:dm?parseDurationToSeconds(dm[1]):0}; }
|
||
function normalizeIdentity(id){ return String(id || '').trim().toUpperCase(); }
|
||
function hashString(str){ let h=2166136261; for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h,16777619);} return h>>>0; }
|
||
function anonKey(kind,key){ return `${kind}:${String(key || '').trim()}`; }
|
||
function getAnonAlias(kind,key){
|
||
const k = anonKey(kind,key);
|
||
if (anonAliasCache.has(k)) return anonAliasCache.get(k);
|
||
const h = hashString(k);
|
||
const mon = ANON_MONSTERS[h % ANON_MONSTERS.length];
|
||
const num = String((h % 900) + 100);
|
||
const prefix = kind === 'group' ? 'Team' : '';
|
||
const alias = prefix ? `${prefix} ${mon} ${num}` : `${mon} ${num}`;
|
||
anonAliasCache.set(k, alias);
|
||
return alias;
|
||
}
|
||
function renderNameLabel(name, key, kind='person'){
|
||
const real = normalizeText(name || '') || String(key || 'unknown');
|
||
const k = anonKey(kind, key || real);
|
||
const revealed = revealedNameKeys.has(k);
|
||
if (!anonymityMode || revealed) {
|
||
if (!anonymityMode) return escapeHtml(real);
|
||
return `<button type="button" class="anon-btn" data-anon-key="${escapeHtml(k)}" title="Hide real name">👁</button>${escapeHtml(real)}`;
|
||
}
|
||
const alias = getAnonAlias(kind, key || real);
|
||
return `<button type="button" class="anon-btn" data-anon-key="${escapeHtml(k)}" title="Reveal real name">🙈</button><span class="anon-pill">${escapeHtml(alias)}</span>`;
|
||
}
|
||
function buildDisplayName(parts){
|
||
const clean = parts.map((x) => normalizeText(x || '')).filter(Boolean);
|
||
return clean.join(' ').trim();
|
||
}
|
||
function buildThreemaContactName(row){
|
||
const explicit = buildDisplayName([row.firstname, row.lastname]);
|
||
if (explicit) return explicit;
|
||
const nick = normalizeText(row.nick_name || '');
|
||
if (nick) return nick;
|
||
return normalizeIdentity(row.identity || '');
|
||
}
|
||
|
||
function unfoldVcfLines(text){
|
||
const lines = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||
const out = [];
|
||
for (const line of lines) {
|
||
if (!line) { out.push(''); continue; }
|
||
if ((line.startsWith(' ') || line.startsWith('\t')) && out.length) out[out.length - 1] += line.slice(1);
|
||
else out.push(line);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function parseVcfContacts(text){
|
||
const lines = unfoldVcfLines(text);
|
||
const map = new Map();
|
||
let inCard = false;
|
||
let name = '';
|
||
let tels = [];
|
||
|
||
const flush = () => {
|
||
if (!name || !tels.length) return;
|
||
for (const tel of tels) {
|
||
const n = normalizeNumber(tel);
|
||
if (n) map.set(n, name);
|
||
}
|
||
};
|
||
|
||
for (const rawLine of lines) {
|
||
const line = rawLine.trim();
|
||
if (!line) continue;
|
||
const up = line.toUpperCase();
|
||
if (up === 'BEGIN:VCARD') {
|
||
inCard = true;
|
||
name = '';
|
||
tels = [];
|
||
continue;
|
||
}
|
||
if (up === 'END:VCARD') {
|
||
flush();
|
||
inCard = false;
|
||
continue;
|
||
}
|
||
if (!inCard) continue;
|
||
|
||
const sep = line.indexOf(':');
|
||
if (sep < 0) continue;
|
||
const key = line.slice(0, sep).toUpperCase();
|
||
const value = normalizeText(line.slice(sep + 1));
|
||
if (!value) continue;
|
||
if (key.startsWith('FN')) name = value;
|
||
else if (key.startsWith('N') && !name) {
|
||
const parts = value.split(';').map((x) => normalizeText(x)).filter(Boolean);
|
||
if (parts.length) name = parts.join(' ');
|
||
} else if (key.startsWith('TEL')) {
|
||
tels.push(value);
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function forEachCsvRow(text, onRow){
|
||
let i = 0;
|
||
let field = '';
|
||
let row = [];
|
||
let inQuotes = false;
|
||
let headers = null;
|
||
|
||
const commitField = () => {
|
||
row.push(field);
|
||
field = '';
|
||
};
|
||
const commitRow = () => {
|
||
if (!headers) {
|
||
headers = row.map((h) => String(h || '').trim().replace(/^"|"$/g, '').toLowerCase());
|
||
} else if (row.length) {
|
||
const obj = {};
|
||
for (let c = 0; c < headers.length; c += 1) obj[headers[c]] = row[c] ?? '';
|
||
onRow(obj);
|
||
}
|
||
row = [];
|
||
};
|
||
|
||
while (i < text.length) {
|
||
const ch = text[i];
|
||
if (inQuotes) {
|
||
if (ch === '"') {
|
||
if (text[i + 1] === '"') {
|
||
field += '"';
|
||
i += 1;
|
||
} else {
|
||
inQuotes = false;
|
||
}
|
||
} else {
|
||
field += ch;
|
||
}
|
||
} else if (ch === '"') {
|
||
inQuotes = true;
|
||
} else if (ch === ',') {
|
||
commitField();
|
||
} else if (ch === '\n') {
|
||
commitField();
|
||
commitRow();
|
||
} else if (ch !== '\r') {
|
||
field += ch;
|
||
}
|
||
i += 1;
|
||
}
|
||
if (field.length || row.length) {
|
||
commitField();
|
||
commitRow();
|
||
}
|
||
}
|
||
|
||
function nameKey(name){ return normalizeText(name || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim(); }
|
||
function scoreNameSimilarity(a, b){
|
||
const x = nameKey(a);
|
||
const y = nameKey(b);
|
||
if (!x || !y) return 0;
|
||
if (x === y) return 10;
|
||
let s = 0;
|
||
if (x.includes(y) || y.includes(x)) s += 4;
|
||
const xa = new Set(x.split(' '));
|
||
const ya = new Set(y.split(' '));
|
||
let overlap = 0;
|
||
xa.forEach((t) => { if (t.length > 1 && ya.has(t)) overlap += 1; });
|
||
s += overlap * 2;
|
||
return s;
|
||
}
|
||
|
||
function buildThreemaSuggestions(threemaContacts, waContacts){
|
||
return (threemaContacts || []).map((tc) => {
|
||
if (tc.mappedTo) return { ...tc, suggestions: [] };
|
||
const suggestions = waContacts
|
||
.map((wc) => ({ number: wc.number, name: wc.name, score: scoreNameSimilarity(tc.name, wc.name) }))
|
||
.filter((x) => x.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, 3);
|
||
return { ...tc, suggestions };
|
||
});
|
||
}
|
||
|
||
function safeJsonParseLoose(text){
|
||
try { return JSON.parse(text); } catch { return null; }
|
||
}
|
||
|
||
function parseThreemaCallEvent(row, isGroup = false){
|
||
const type = String(row?.type || '').toUpperCase();
|
||
const body = String(row?.body || '');
|
||
|
||
if (type === 'VOIP_STATUS') {
|
||
const parsed = safeJsonParseLoose(body);
|
||
const payload = Array.isArray(parsed) ? parsed[1] : parsed;
|
||
const status = Number(payload?.status);
|
||
let durationSec = Number(payload?.duration || 0);
|
||
if (!Number.isFinite(durationSec)) durationSec = 0;
|
||
if (durationSec > 100000) durationSec = Math.round(durationSec / 1000);
|
||
const normalizedStatus = status === 2 ? 'completed' : (status === 1 || status === 4 ? 'missed' : 'other');
|
||
return { isCall: true, type: 'voice', status: normalizedStatus, durationSec: Math.max(0, Math.round(durationSec)) };
|
||
}
|
||
|
||
if (type === 'GROUP_CALL_STATUS') {
|
||
const parsed = safeJsonParseLoose(body);
|
||
const payload = Array.isArray(parsed) ? parsed[1] : parsed;
|
||
const status = Number(payload?.status);
|
||
if (status !== 1) return null; // avoid counting end-events as separate calls
|
||
return { isCall: true, type: 'voice', status: 'other', durationSec: 0 };
|
||
}
|
||
|
||
if (!isGroup && type === 'VIDEO') {
|
||
const parsed = safeJsonParseLoose(body);
|
||
let durationSec = 0;
|
||
if (Array.isArray(parsed)) {
|
||
const candidate = parsed[parsed.length - 1];
|
||
if (typeof candidate === 'number' && Number.isFinite(candidate)) {
|
||
durationSec = candidate > 100000 ? Math.round(candidate / 1000) : Math.round(candidate);
|
||
}
|
||
}
|
||
return { isCall: true, type: 'video', status: durationSec > 0 ? 'completed' : 'other', durationSec: Math.max(0, durationSec) };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
const fmtInt = (v)=>Number(v||0).toLocaleString();
|
||
const fmtFloat = (v,d=1)=>Number(v||0).toLocaleString(undefined,{maximumFractionDigits:d,minimumFractionDigits:d});
|
||
function fmtDuration(sec){ const s=Math.round(sec||0); const h=Math.floor(s/3600); const m=Math.floor((s%3600)/60); const r=s%60; if(h) return `${h}h ${m}m`; if(m) return `${m}m ${r}s`; return `${r}s`; }
|
||
function fmtHours(sec){ return `${fmtFloat((Number(sec)||0)/3600,2)}h`; }
|
||
|
||
function getContactActivityValue(c){
|
||
const messages = Number(c?.messages || 0);
|
||
const calls = Number(c?.calls || 0);
|
||
return messages + calls;
|
||
}
|
||
|
||
function getSortedContacts(stats){
|
||
const base = [...(stats?.topContacts || [])];
|
||
const valueFor = (c) => {
|
||
if (contactSortMode === 'messages') return Number(c.messages || 0);
|
||
if (contactSortMode === 'calls') return Number(c.calls || 0);
|
||
if (contactSortMode === 'callDuration') return Number(c.callDurationSec || 0);
|
||
if (contactSortMode === 'consistency') return Number(c.consistency || 0);
|
||
return getContactActivityValue(c);
|
||
};
|
||
base.sort((a, b) => {
|
||
const av = valueFor(a[1]);
|
||
const bv = valueFor(b[1]);
|
||
if (bv !== av) return bv - av;
|
||
return getContactActivityValue(b[1]) - getContactActivityValue(a[1]);
|
||
});
|
||
return base;
|
||
}
|
||
|
||
function getActiveYears(stats){
|
||
if (!stats) return [];
|
||
if (selectedChartContactKey === '__all__') return stats.availableYears || [];
|
||
const series = stats.contactSeriesByKey?.get(selectedChartContactKey);
|
||
if (!series) return stats.availableYears || [];
|
||
return [...new Set([...(series.weekMap?.keys() || [])].map((w) => Number(String(w).slice(0, 4))).filter(Number.isFinite))].sort((a, b) => a - b);
|
||
}
|
||
|
||
function syncYearState(stats){
|
||
const years = getActiveYears(stats);
|
||
if (!years.length) {
|
||
selectedYear = null;
|
||
$('yearLabel').textContent = chartMode === 'weekly' ? 'No data for selected focus' : 'Recent window';
|
||
$('prevYearBtn').disabled = true;
|
||
$('nextYearBtn').disabled = true;
|
||
return;
|
||
}
|
||
if (selectedYear === null || !years.includes(selectedYear)) selectedYear = years[years.length - 1];
|
||
const idx = years.indexOf(selectedYear);
|
||
const weeklyMode = chartMode === 'weekly';
|
||
$('prevYearBtn').disabled = !weeklyMode || idx <= 0;
|
||
$('nextYearBtn').disabled = !weeklyMode || idx >= years.length - 1;
|
||
$('yearLabel').textContent = weeklyMode ? `Year ${selectedYear}` : 'Recent window';
|
||
}
|
||
|
||
function getFocusedSeries(stats){
|
||
if (!stats || selectedChartContactKey === '__all__') return null;
|
||
return stats.contactSeriesByKey?.get(selectedChartContactKey) || null;
|
||
}
|
||
|
||
function getFocusedLabel(stats){
|
||
const focused = getFocusedSeries(stats);
|
||
if (!focused) return 'All contacts';
|
||
return normalizeText(focused.label || focused.key || 'Selected contact') || 'Selected contact';
|
||
}
|
||
|
||
function populateChartContactSelect(stats){
|
||
const select = $('chartContactSelect');
|
||
const previous = selectedChartContactKey;
|
||
const contacts = getSortedContacts(stats).slice(0, 800);
|
||
const options = ['<option value="__all__">All contacts</option>'];
|
||
for (const [name, c] of contacts) {
|
||
const key = String(c.key || c.number || name || '').trim();
|
||
if (!key) continue;
|
||
const label = anonymityMode ? getAnonAlias('person', key) : String(name || c.name || key);
|
||
options.push(`<option value="${escapeHtml(key)}">${escapeHtml(label)} (${fmtInt(c.messages)} msg · ${fmtInt(c.calls)} calls)</option>`);
|
||
}
|
||
select.innerHTML = options.join('');
|
||
if ([...select.options].some((o) => o.value === previous)) selectedChartContactKey = previous;
|
||
else selectedChartContactKey = '__all__';
|
||
select.value = selectedChartContactKey;
|
||
}
|
||
|
||
async function computeStats(files){
|
||
const jsonFiles = files.filter((f) => /\.json$/i.test(f.name));
|
||
const csvFiles = files.filter((f) => /\.csv$/i.test(f.name));
|
||
const vcfFiles = files.filter((f) => /\.vcf$/i.test(f.name));
|
||
const dayMap=new Map(), weekMap=new Map(), chatTotals=new Map(), contactMap=new Map();
|
||
const contactSeriesByKey = new Map();
|
||
const contactTotals=new Map(), groupTotals=new Map();
|
||
const threemaActivity=new Map();
|
||
const threemaContactsByIdNumber=new Map(), threemaContactsByIdentity=new Map(), threemaGroupsByUid=new Map();
|
||
let filesParsed=0, allMessages=0, totalMessages=0, textMessages=0, totalChars=0, myMessages=0, theirMessages=0;
|
||
let callsTotal=0, callsMissed=0, callsCompleted=0, callDurationSec=0, voiceCalls=0, videoCalls=0;
|
||
const cutoffTimestamp = Math.floor(Date.UTC(cutoffYear, 0, 1, 0, 0, 0) / 1000);
|
||
const directContactsSeen=new Set();
|
||
let waMessages = 0, threemaMessages = 0;
|
||
const hasCallsFile=jsonFiles.some(f=>f.name.toLowerCase()==='whatsapp-calls.json');
|
||
const vcfNameByNumber = new Map();
|
||
|
||
for (const vcfFile of vcfFiles) {
|
||
const parsed = parseVcfContacts(await readText(vcfFile));
|
||
parsed.forEach((name, num) => {
|
||
if (name && num && !vcfNameByNumber.has(num)) vcfNameByNumber.set(num, name);
|
||
});
|
||
filesParsed += 1;
|
||
}
|
||
|
||
const ensureContact = (id, fallbackName) => {
|
||
const raw = String(id || '').trim();
|
||
if (!raw) return null;
|
||
const n = normalizeNumber(raw);
|
||
const key = n || raw;
|
||
const c = getOrInit(contactMap, key, () => ({
|
||
number: key,
|
||
name: fallbackName || key,
|
||
messages: 0,
|
||
calls: 0,
|
||
callDurationSec: 0,
|
||
textMessages: 0,
|
||
chars: 0,
|
||
myMessages: 0,
|
||
theirMessages: 0,
|
||
firstTs: null,
|
||
lastTs: null,
|
||
activeDays: new Set(),
|
||
activeWeeks: new Set(),
|
||
consistency: 0,
|
||
}));
|
||
const preferredName = n ? (vcfNameByNumber.get(n) || fallbackName) : fallbackName;
|
||
if (preferredName && (c.name === c.number || !c.name || vcfNameByNumber.has(n))) c.name = preferredName;
|
||
return c;
|
||
};
|
||
|
||
const ensureListTotal = (map, key, label) => {
|
||
const item = getOrInit(map, key, () => ({ key, label: label || key, messages: 0, calls: 0, chars: 0, callDurationSec: 0, consistency: 0 }));
|
||
if (label && (!item.label || item.label === item.key)) item.label = label;
|
||
return item;
|
||
};
|
||
|
||
const ensureContactSeries = (key, label) => {
|
||
return getOrInit(contactSeriesByKey, key, () => ({ key, label: label || key, dayMap: new Map(), weekMap: new Map() }));
|
||
};
|
||
|
||
const bumpContactSeries = (key, label, dayKey, weekKey, delta) => {
|
||
if (!key) return;
|
||
const s = ensureContactSeries(key, label);
|
||
if (label && (!s.label || s.label === s.key)) s.label = label;
|
||
const day = getOrInit(s.dayMap, dayKey, () => ({ messages: 0, calls: 0, callDurationSec: 0 }));
|
||
const week = getOrInit(s.weekMap, weekKey, () => ({ messages: 0, calls: 0, callDurationSec: 0 }));
|
||
day.messages += Number(delta.messages || 0);
|
||
day.calls += Number(delta.calls || 0);
|
||
day.callDurationSec += Number(delta.callDurationSec || 0);
|
||
week.messages += Number(delta.messages || 0);
|
||
week.calls += Number(delta.calls || 0);
|
||
week.callDurationSec += Number(delta.callDurationSec || 0);
|
||
};
|
||
|
||
for(let i=0;i<jsonFiles.length;i++){
|
||
if(i%25===0) setStatus(`Reading WhatsApp files ${i+1}/${jsonFiles.length}...`);
|
||
const file=jsonFiles[i];
|
||
const root=safeJsonParse(await readText(file),file.name);
|
||
const rootKey=Object.keys(root||{})[0];
|
||
if(!rootKey||!root[rootKey]?.messages) continue;
|
||
const chat=root[rootKey];
|
||
const chatName=chat.name||rootKey.replace('@s.whatsapp.net','').replace('@g.us','')||file.name.replace('.json','');
|
||
const rootNumber=normalizeNumber(rootKey.split('@')[0] || '');
|
||
const isGroup=isLikelyGroup(rootKey,file.name);
|
||
const isCallFile = file.name.toLowerCase()==='whatsapp-calls.json';
|
||
if(!isGroup && !isCallFile) directContactsSeen.add(rootKey);
|
||
|
||
filesParsed++;
|
||
for(const msg of Object.values(chat.messages||{})){
|
||
const ts=Number(msg.timestamp); if(!Number.isFinite(ts)||ts<=0) continue;
|
||
if (ts < cutoffTimestamp) continue;
|
||
allMessages++;
|
||
waMessages++;
|
||
const day=getOrInit(dayMap,yyyyMmDd(ts),emptyBucket);
|
||
const week=getOrInit(weekMap,isoWeek(ts),emptyBucket);
|
||
const chatTotal=getOrInit(chatTotals,chatName,()=>({messages:0,calls:0,chars:0}));
|
||
const listMap = isGroup ? groupTotals : contactTotals;
|
||
const listKey = isGroup ? chatName : (rootNumber || `wa:${chatName}`);
|
||
const preferredLabel = (!isGroup && rootNumber && vcfNameByNumber.has(rootNumber)) ? vcfNameByNumber.get(rootNumber) : chatName;
|
||
|
||
const call=parseCallInfo(msg);
|
||
const effectiveNumber = isCallFile ? normalizeNumber(msg.sender) : (!isGroup ? rootNumber : '');
|
||
const contactFallbackName = isCallFile
|
||
? (vcfNameByNumber.get(effectiveNumber) || effectiveNumber || chatName)
|
||
: chatName;
|
||
const contact = ensureContact(effectiveNumber, contactFallbackName);
|
||
if(call && (isCallFile || !hasCallsFile)){
|
||
const callListKey = !isGroup ? (effectiveNumber || listKey) : listKey;
|
||
const callListLabel = !isGroup
|
||
? (contact?.name || vcfNameByNumber.get(effectiveNumber) || preferredLabel)
|
||
: preferredLabel;
|
||
const callListTotal = ensureListTotal(listMap, callListKey, callListLabel);
|
||
callsTotal++; if(call.status==='missed'||call.status==='not_answered') callsMissed++; if(call.status==='completed') callsCompleted++;
|
||
if(call.type==='voice') voiceCalls++; if(call.type==='video') videoCalls++; callDurationSec+=call.durationSec;
|
||
day.calls++; day.callDurationSec+=call.durationSec; week.calls++; week.callDurationSec+=call.durationSec;
|
||
if (!isCallFile) chatTotal.calls++;
|
||
callListTotal.calls++;
|
||
callListTotal.callDurationSec += call.durationSec;
|
||
bumpContactSeries(callListKey, callListLabel, yyyyMmDd(ts), isoWeek(ts), { calls: 1, callDurationSec: call.durationSec });
|
||
if (contact) {
|
||
contact.calls += 1;
|
||
contact.callDurationSec += call.durationSec;
|
||
contact.activeDays.add(yyyyMmDd(ts));
|
||
contact.activeWeeks.add(isoWeek(ts));
|
||
contact.firstTs = contact.firstTs === null ? ts : Math.min(contact.firstTs, ts);
|
||
contact.lastTs = contact.lastTs === null ? ts : Math.max(contact.lastTs, ts);
|
||
}
|
||
if(!isGroup){ day.activeContacts.add(callListKey); week.activeContacts.add(callListKey); }
|
||
continue;
|
||
}
|
||
|
||
const listTotal = ensureListTotal(listMap, listKey, preferredLabel);
|
||
|
||
totalMessages++; chatTotal.messages++; listTotal.messages++; day.messages++; week.messages++;
|
||
const clean=normalizeText(msg.data||msg.caption||'');
|
||
const meaningful=clean && clean!=='The media is missing' && !/end-to-end encrypted/i.test(clean);
|
||
if(meaningful){ const len=clean.length; totalChars+=len; textMessages++; day.textMessages++; day.chars+=len; week.textMessages++; week.chars+=len; chatTotal.chars+=len; listTotal.chars+=len; }
|
||
if(msg.from_me) myMessages++; else theirMessages++;
|
||
bumpContactSeries(listKey, preferredLabel, yyyyMmDd(ts), isoWeek(ts), { messages: 1 });
|
||
if (contact) {
|
||
contact.messages += 1;
|
||
if (meaningful) {
|
||
const len = clean.length;
|
||
contact.textMessages += 1;
|
||
contact.chars += len;
|
||
}
|
||
if (msg.from_me) contact.myMessages += 1;
|
||
else contact.theirMessages += 1;
|
||
contact.activeDays.add(yyyyMmDd(ts));
|
||
contact.activeWeeks.add(isoWeek(ts));
|
||
contact.firstTs = contact.firstTs === null ? ts : Math.min(contact.firstTs, ts);
|
||
contact.lastTs = contact.lastTs === null ? ts : Math.max(contact.lastTs, ts);
|
||
}
|
||
if(!isGroup){ day.activeContacts.add(listKey); week.activeContacts.add(listKey); }
|
||
}
|
||
}
|
||
|
||
const contactsCsv = csvFiles.find((f) => /(^|\/)contacts\.csv$/i.test(relPath(f)));
|
||
const groupsCsv = csvFiles.find((f) => /(^|\/)groups\.csv$/i.test(relPath(f)));
|
||
|
||
if (contactsCsv) {
|
||
forEachCsvRow(await readText(contactsCsv), (row) => {
|
||
const identity = normalizeIdentity(row.identity);
|
||
const identityId = normalizeNumber(row.identity_id);
|
||
const name = buildThreemaContactName(row) || identity;
|
||
if (!identity) return;
|
||
const info = { identity, identityId, name };
|
||
if (identityId) threemaContactsByIdNumber.set(identityId, info);
|
||
threemaContactsByIdentity.set(identity, info);
|
||
});
|
||
filesParsed += 1;
|
||
}
|
||
|
||
if (groupsCsv) {
|
||
forEachCsvRow(await readText(groupsCsv), (row) => {
|
||
const uid = normalizeNumber(row.group_uid);
|
||
if (!uid) return;
|
||
const groupName = normalizeText(row.groupname || '') || `Group ${uid}`;
|
||
threemaGroupsByUid.set(uid, groupName);
|
||
});
|
||
filesParsed += 1;
|
||
}
|
||
|
||
const directMsgCsv = csvFiles.filter((f) => {
|
||
const p = relPath(f).toLowerCase();
|
||
return /(^|\/)message_\d+\.csv$/.test(p) && !/group_message_\d+\.csv$/.test(p);
|
||
});
|
||
|
||
for (let i = 0; i < directMsgCsv.length; i += 1) {
|
||
const file = directMsgCsv[i];
|
||
if (i % 25 === 0) setStatus(`Reading Threema direct files ${i + 1}/${directMsgCsv.length}...`);
|
||
const match = relPath(file).match(/message_(\d+)\.csv$/i);
|
||
const identityId = normalizeNumber(match?.[1] || '');
|
||
const info = threemaContactsByIdNumber.get(identityId);
|
||
const identity = normalizeIdentity(info?.identity || `ID_${identityId}`);
|
||
const displayName = info?.name || identity;
|
||
const mappedNumber = normalizeNumber(identityMapping[identity] || '');
|
||
const listKey = mappedNumber || `threema:${identity}`;
|
||
const listLabel = mappedNumber ? (displayName || mappedNumber) : `${displayName} [Threema]`;
|
||
const listTotal = ensureListTotal(contactTotals, listKey, listLabel);
|
||
const activity = getOrInit(threemaActivity, identity, () => ({ identity, name: displayName, messages: 0, mappedTo: mappedNumber || '' }));
|
||
if (mappedNumber) activity.mappedTo = mappedNumber;
|
||
|
||
const text = await readText(file);
|
||
forEachCsvRow(text, (row) => {
|
||
const rawTs = Number(row.posted_at || row.created_at || 0);
|
||
if (!Number.isFinite(rawTs) || rawTs <= 0) return;
|
||
const ts = rawTs > 100000000000 ? Math.floor(rawTs / 1000) : Math.floor(rawTs);
|
||
if (ts < cutoffTimestamp) return;
|
||
allMessages += 1;
|
||
threemaMessages += 1;
|
||
const day = getOrInit(dayMap, yyyyMmDd(ts), emptyBucket);
|
||
const week = getOrInit(weekMap, isoWeek(ts), emptyBucket);
|
||
const call = parseThreemaCallEvent(row, false);
|
||
const contactId = mappedNumber || `threema:${identity}`;
|
||
const contact = ensureContact(contactId, listLabel);
|
||
|
||
if (call?.isCall) {
|
||
callsTotal += 1;
|
||
if (call.status === 'missed' || call.status === 'not_answered') callsMissed += 1;
|
||
if (call.status === 'completed') callsCompleted += 1;
|
||
if (call.type === 'voice') voiceCalls += 1;
|
||
if (call.type === 'video') videoCalls += 1;
|
||
callDurationSec += call.durationSec;
|
||
day.calls += 1;
|
||
day.callDurationSec += call.durationSec;
|
||
week.calls += 1;
|
||
week.callDurationSec += call.durationSec;
|
||
listTotal.calls += 1;
|
||
listTotal.callDurationSec += call.durationSec;
|
||
bumpContactSeries(listKey, listLabel, yyyyMmDd(ts), isoWeek(ts), { calls: 1, callDurationSec: call.durationSec });
|
||
day.activeContacts.add(listKey);
|
||
week.activeContacts.add(listKey);
|
||
if (contact) {
|
||
contact.calls += 1;
|
||
contact.callDurationSec += call.durationSec;
|
||
contact.activeDays.add(yyyyMmDd(ts));
|
||
contact.activeWeeks.add(isoWeek(ts));
|
||
contact.firstTs = contact.firstTs === null ? ts : Math.min(contact.firstTs, ts);
|
||
contact.lastTs = contact.lastTs === null ? ts : Math.max(contact.lastTs, ts);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (String(row.isstatusmessage || '') === '1') return;
|
||
|
||
totalMessages += 1;
|
||
listTotal.messages += 1;
|
||
day.messages += 1;
|
||
week.messages += 1;
|
||
day.activeContacts.add(listKey);
|
||
week.activeContacts.add(listKey);
|
||
bumpContactSeries(listKey, listLabel, yyyyMmDd(ts), isoWeek(ts), { messages: 1 });
|
||
|
||
const clean = normalizeText(row.body || row.caption || '');
|
||
if (clean) {
|
||
const len = clean.length;
|
||
totalChars += len;
|
||
textMessages += 1;
|
||
day.textMessages += 1;
|
||
day.chars += len;
|
||
week.textMessages += 1;
|
||
week.chars += len;
|
||
listTotal.chars += len;
|
||
}
|
||
|
||
const fromMe = String(row.isoutbox || '') === '1';
|
||
if (fromMe) myMessages += 1;
|
||
else theirMessages += 1;
|
||
|
||
if (contact) {
|
||
contact.messages += 1;
|
||
if (clean) {
|
||
contact.textMessages += 1;
|
||
contact.chars += clean.length;
|
||
}
|
||
if (fromMe) contact.myMessages += 1;
|
||
else contact.theirMessages += 1;
|
||
contact.activeDays.add(yyyyMmDd(ts));
|
||
contact.activeWeeks.add(isoWeek(ts));
|
||
contact.firstTs = contact.firstTs === null ? ts : Math.min(contact.firstTs, ts);
|
||
contact.lastTs = contact.lastTs === null ? ts : Math.max(contact.lastTs, ts);
|
||
}
|
||
activity.messages += 1;
|
||
});
|
||
filesParsed += 1;
|
||
}
|
||
|
||
const groupMsgCsv = csvFiles.filter((f) => /(^|\/)group_message_\d+\.csv$/i.test(relPath(f)));
|
||
for (let i = 0; i < groupMsgCsv.length; i += 1) {
|
||
const file = groupMsgCsv[i];
|
||
if (i % 20 === 0) setStatus(`Reading Threema group files ${i + 1}/${groupMsgCsv.length}...`);
|
||
const uid = normalizeNumber(relPath(file).match(/group_message_(\d+)\.csv$/i)?.[1] || '');
|
||
const groupName = threemaGroupsByUid.get(uid) || `Threema group ${uid || file.name}`;
|
||
const listTotal = ensureListTotal(groupTotals, `threema-group:${uid || file.name}`, `${groupName} [Threema]`);
|
||
const chatTotal = getOrInit(chatTotals, `${groupName} [Threema]`, () => ({ messages: 0, calls: 0, chars: 0 }));
|
||
|
||
const text = await readText(file);
|
||
forEachCsvRow(text, (row) => {
|
||
const rawTs = Number(row.posted_at || row.created_at || 0);
|
||
if (!Number.isFinite(rawTs) || rawTs <= 0) return;
|
||
const ts = rawTs > 100000000000 ? Math.floor(rawTs / 1000) : Math.floor(rawTs);
|
||
if (ts < cutoffTimestamp) return;
|
||
allMessages += 1;
|
||
threemaMessages += 1;
|
||
const day = getOrInit(dayMap, yyyyMmDd(ts), emptyBucket);
|
||
const week = getOrInit(weekMap, isoWeek(ts), emptyBucket);
|
||
const call = parseThreemaCallEvent(row, true);
|
||
if (call?.isCall) {
|
||
callsTotal += 1;
|
||
if (call.status === 'missed' || call.status === 'not_answered') callsMissed += 1;
|
||
if (call.status === 'completed') callsCompleted += 1;
|
||
if (call.type === 'voice') voiceCalls += 1;
|
||
if (call.type === 'video') videoCalls += 1;
|
||
callDurationSec += call.durationSec;
|
||
day.calls += 1;
|
||
day.callDurationSec += call.durationSec;
|
||
week.calls += 1;
|
||
week.callDurationSec += call.durationSec;
|
||
listTotal.calls += 1;
|
||
chatTotal.calls += 1;
|
||
return;
|
||
}
|
||
|
||
if (String(row.isstatusmessage || '') === '1') return;
|
||
|
||
totalMessages += 1;
|
||
listTotal.messages += 1;
|
||
chatTotal.messages += 1;
|
||
day.messages += 1;
|
||
week.messages += 1;
|
||
|
||
const clean = normalizeText(row.body || row.caption || '');
|
||
if (clean) {
|
||
const len = clean.length;
|
||
totalChars += len;
|
||
textMessages += 1;
|
||
day.textMessages += 1;
|
||
day.chars += len;
|
||
week.textMessages += 1;
|
||
week.chars += len;
|
||
listTotal.chars += len;
|
||
chatTotal.chars += len;
|
||
}
|
||
|
||
const fromMe = String(row.isoutbox || '') === '1';
|
||
if (fromMe) myMessages += 1;
|
||
else theirMessages += 1;
|
||
});
|
||
filesParsed += 1;
|
||
}
|
||
|
||
const sortedDays=[...dayMap.entries()].sort((a,b)=>a[0].localeCompare(b[0]));
|
||
const sortedWeeks=[...weekMap.entries()].sort((a,b)=>a[0].localeCompare(b[0]));
|
||
const allWeeks = sortedWeeks;
|
||
const availableYears = [...new Set(allWeeks.map(([w]) => Number(w.slice(0, 4))))].sort((a, b) => a - b);
|
||
const waContactsForSuggestion = [...contactMap.values()].filter((c) => /^\d+$/.test(c.number));
|
||
const threemaContacts = buildThreemaSuggestions(
|
||
[...threemaActivity.values()].sort((a, b) => b.messages - a.messages),
|
||
waContactsForSuggestion
|
||
);
|
||
|
||
const maxWeeklyEventsPerContact = Math.max(1, ...[...contactMap.values()].map((c) => {
|
||
const weeklyEvents = (Number(c.messages || 0) + Number(c.calls || 0) * 4) / Math.max(1, c.activeWeeks?.size || 0);
|
||
return weeklyEvents;
|
||
}));
|
||
|
||
for (const c of contactMap.values()) {
|
||
const first = Number(c.firstTs || 0);
|
||
const last = Number(c.lastTs || first);
|
||
const spanWeeks = Math.max(1, Math.ceil(Math.max(1, last - first + 1) / 604800));
|
||
const activeWeeks = Math.max(0, c.activeWeeks?.size || 0);
|
||
const totalEvents = Number(c.messages || 0) + Number(c.calls || 0) * 4;
|
||
const coverage = Math.min(1, activeWeeks / spanWeeks);
|
||
const weeklyEvents = totalEvents / Math.max(1, activeWeeks);
|
||
const volumeFactor = Math.min(1, weeklyEvents / maxWeeklyEventsPerContact);
|
||
const eventsFactor = Math.min(1, totalEvents / 120);
|
||
const weeksFactor = Math.min(1, activeWeeks / 12);
|
||
const stability = Math.sqrt(eventsFactor * weeksFactor);
|
||
c.consistency = Math.max(0, Math.min(100, coverage * Math.pow(volumeFactor, 0.8) * stability * 100));
|
||
}
|
||
|
||
for (const item of contactTotals.values()) {
|
||
const key = String(item.key || '').trim();
|
||
const c = contactMap.get(key);
|
||
item.consistency = Number(c?.consistency || 0);
|
||
}
|
||
|
||
const sortedContactTotals = [...contactTotals.values()].sort((a, b) => (b.messages + b.calls) - (a.messages + a.calls));
|
||
const sortedGroupTotals = [...groupTotals.values()].sort((a, b) => (b.messages + b.calls) - (a.messages + a.calls));
|
||
return {
|
||
meta:{ filesParsed,totalMessages:allMessages,directContactsCount:directContactsSeen.size,daysCount:sortedDays.length },
|
||
totals:{ messages:totalMessages,textMessages,totalChars,myMessages,theirMessages,avgMessageLen:textMessages?totalChars/textMessages:0,
|
||
contactsPerDayAvg:sortedDays.length?sortedDays.reduce((acc,[,d])=>acc+d.activeContacts.size,0)/sortedDays.length:0,
|
||
messagesPerDayAvg:sortedDays.length?totalMessages/sortedDays.length:0,
|
||
callsTotal,callsMissed,callsCompleted,callDurationSec,voiceCalls,videoCalls },
|
||
sourceTotals:{ whatsappEvents:waMessages, threemaEvents:threemaMessages },
|
||
recentDays:sortedDays.slice(-14), recentMonthDays:sortedDays.slice(-30), recentWeeks:sortedWeeks.slice(-16), allWeeks, availableYears,
|
||
chatTop:[...chatTotals.entries()].sort((a,b)=>(b[1].messages+b[1].calls)-(a[1].messages+a[1].calls)).slice(0,10),
|
||
topContacts:sortedContactTotals.map((c) => [c.label, c]),
|
||
topGroups:sortedGroupTotals.map((c) => [c.label, c]),
|
||
contacts:[...contactMap.values()],
|
||
contactSeriesByKey,
|
||
threemaContacts
|
||
};
|
||
}
|
||
|
||
function trendClass(v){ return v>0?'up':v<0?'down':'flat'; }
|
||
function trendText(v,s='%'){ const sign=v>0?'+':''; if(!Number.isFinite(v)) return 'n/a'; return `${sign}${fmtFloat(v,1)}${s}`; }
|
||
function createKpi(title,value,deltaText,deltaClass){ const c=document.createElement('div'); c.className='card'; c.innerHTML=`<div class="k">${title}</div><div class="v">${value}</div>${deltaText?`<div class="delta ${deltaClass}">${deltaText}</div>`:''}`; return c; }
|
||
|
||
function renderBars(containerId,rows,kind='person'){
|
||
const el=$(containerId); el.innerHTML=''; if(!rows.length){ el.innerHTML='<div class="small">No data.</div>'; return; }
|
||
const max=Math.max(...rows.map(r=>r.value),1);
|
||
for(const row of rows){
|
||
const pct=(row.value/max)*100;
|
||
const n=document.createElement('div');
|
||
n.className='bar-row';
|
||
const key = row.key || row.label;
|
||
const labelHtml = row.labelHtml || renderNameLabel(row.label, key, kind);
|
||
n.innerHTML=`<div class="bar-label">${labelHtml}</div><div class="bar-track"><div class="bar-fill" style="width:${pct}%;"></div></div><div class="bar-value">${row.display||fmtInt(row.value)}</div>`;
|
||
el.appendChild(n);
|
||
}
|
||
}
|
||
|
||
function renderPagedBars(){
|
||
if (!latestStats) return;
|
||
const contacts = getSortedContacts(latestStats);
|
||
const groups = latestStats.topGroups || [];
|
||
|
||
const contactsMaxPage = Math.max(0, Math.ceil(contacts.length / PAGE_SIZE) - 1);
|
||
const groupsMaxPage = Math.max(0, Math.ceil(groups.length / PAGE_SIZE) - 1);
|
||
contactsPage = Math.min(contactsPage, contactsMaxPage);
|
||
groupsPage = Math.min(groupsPage, groupsMaxPage);
|
||
|
||
const contactsSlice = contacts.slice(contactsPage * PAGE_SIZE, contactsPage * PAGE_SIZE + PAGE_SIZE);
|
||
const groupsSlice = groups.slice(groupsPage * PAGE_SIZE, groupsPage * PAGE_SIZE + PAGE_SIZE);
|
||
|
||
renderBars('contactBars', contactsSlice.map(([name, c]) => ({
|
||
label: name,
|
||
key: c.key || name,
|
||
value: contactSortMode === 'messages' ? c.messages
|
||
: contactSortMode === 'calls' ? c.calls
|
||
: contactSortMode === 'callDuration' ? c.callDurationSec
|
||
: contactSortMode === 'consistency' ? c.consistency
|
||
: (c.messages + c.calls),
|
||
display: contactSortMode === 'callDuration'
|
||
? `${fmtHours(c.callDurationSec || 0)} • ${fmtInt(c.calls)} calls`
|
||
: contactSortMode === 'consistency'
|
||
? `${fmtFloat(c.consistency || 0, 1)}% consistency • ${fmtInt(c.messages)} msg • ${fmtInt(c.calls)} calls`
|
||
: `${fmtInt(c.messages)} msg • ${fmtInt(c.calls)} calls • ${fmtHours(c.callDurationSec || 0)}`,
|
||
})), 'person');
|
||
renderBars('groupBars', groupsSlice.map(([name, c]) => ({
|
||
label: name,
|
||
key: c.key || name,
|
||
value: c.messages + c.calls,
|
||
display: `${fmtInt(c.messages)} msg • ${fmtInt(c.calls)} calls`,
|
||
})), 'group');
|
||
|
||
$('contactsPrevBtn').disabled = contactsPage <= 0;
|
||
$('contactsNextBtn').disabled = contactsPage >= contactsMaxPage;
|
||
$('groupsPrevBtn').disabled = groupsPage <= 0;
|
||
$('groupsNextBtn').disabled = groupsPage >= groupsMaxPage;
|
||
|
||
$('contactsPageInfo').textContent = contacts.length ? `Page ${contactsPage + 1} / ${contactsMaxPage + 1} · ${contacts.length} contacts` : 'No contacts found';
|
||
$('groupsPageInfo').textContent = groups.length ? `Page ${groupsPage + 1} / ${groupsMaxPage + 1} · ${groups.length} groups` : 'No groups found';
|
||
}
|
||
|
||
function renderThreemaMapping(stats){
|
||
const table = $('threemaMappingTable');
|
||
const rows = (stats.threemaContacts || []).slice(0, 80);
|
||
if (!rows.length) {
|
||
table.innerHTML = '<tr><td class="small">No Threema contacts detected yet.</td></tr>';
|
||
setMappingStatus(`${Object.keys(identityMapping).length} persisted link(s).`);
|
||
return;
|
||
}
|
||
|
||
const body = rows.map((row) => {
|
||
const linked = normalizeNumber(identityMapping[row.identity] || row.mappedTo || '');
|
||
const suggestionHtml = (row.suggestions || []).length
|
||
? `<div class="suggestions">${row.suggestions.map((s) => `<button type="button" class="map-suggest-btn" data-identity="${escapeHtml(row.identity)}" data-number="${escapeHtml(s.number)}">${anonymityMode ? escapeHtml(getAnonAlias('person', s.number || s.name || 'suggested')) : escapeHtml(s.name || s.number)} → ${escapeHtml(s.number)}</button>`).join('')}</div>`
|
||
: '<span class="small">No strong name match</span>';
|
||
return `<tr>
|
||
<td><strong>${renderNameLabel(row.name || row.identity, row.identity, 'person')}</strong><br/><span class="small">${escapeHtml(row.identity)}</span></td>
|
||
<td>${fmtInt(row.messages)}</td>
|
||
<td>
|
||
<input class="map-input" data-identity="${escapeHtml(row.identity)}" type="text" placeholder="WhatsApp number" value="${escapeHtml(linked)}" />
|
||
</td>
|
||
<td><button type="button" class="map-save-btn" data-identity="${escapeHtml(row.identity)}">Save</button></td>
|
||
<td>${suggestionHtml}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
|
||
table.innerHTML = `<thead><tr>
|
||
<th>Threema contact</th>
|
||
<th>Messages</th>
|
||
<th>Mapped WhatsApp number</th>
|
||
<th>Action</th>
|
||
<th>Suggested links</th>
|
||
</tr></thead><tbody>${body}</tbody>`;
|
||
const mappedCount = rows.filter((r) => normalizeNumber(identityMapping[r.identity] || r.mappedTo)).length;
|
||
setMappingStatus(`${Object.keys(identityMapping).length} persisted link(s) · ${mappedCount}/${rows.length} visible contacts mapped.`);
|
||
}
|
||
|
||
function renderWeeklyTable(weeks){
|
||
const table=$('weeklyTable'); if(!weeks.length){ table.innerHTML='<tr><td class="small">No weekly data.</td></tr>'; return; }
|
||
const rows=[]; let prev=null;
|
||
for(const [week,w] of weeks){ const avgLen=w.textMessages?w.chars/w.textMessages:0; const d=prev?((w.messages-prev.messages)/Math.max(prev.messages,1))*100:0;
|
||
rows.push(`<tr><td>${week}</td><td>${fmtInt(w.messages)}</td><td>${fmtInt(w.activeContacts.size)}</td><td>${fmtFloat(avgLen,1)}</td><td>${fmtInt(w.calls)}</td><td>${fmtDuration(w.callDurationSec)}</td><td class="${trendClass(d)}">${trendText(d)}</td></tr>`); prev=w; }
|
||
table.innerHTML=`<thead><tr><th>Week</th><th>Messages</th><th>Active contacts</th><th>Avg text length</th><th>Calls</th><th>Call duration</th><th>WoW msg Δ</th></tr></thead><tbody>${rows.join('')}</tbody>`;
|
||
}
|
||
|
||
function renderCallTable(t){ const ans=t.callsTotal?(t.callsCompleted/t.callsTotal)*100:0; $('callTable').innerHTML=`<tbody><tr><th>Total calls</th><td>${fmtInt(t.callsTotal)}</td></tr><tr><th>Completed calls</th><td>${fmtInt(t.callsCompleted)}</td></tr><tr><th>Missed / not answered</th><td>${fmtInt(t.callsMissed)}</td></tr><tr><th>Answered rate</th><td>${fmtFloat(ans,1)}%</td></tr><tr><th>Voice calls</th><td>${fmtInt(t.voiceCalls)}</td></tr><tr><th>Video calls</th><td>${fmtInt(t.videoCalls)}</td></tr><tr><th>Total call duration</th><td>${fmtDuration(t.callDurationSec)}</td></tr><tr><th>Average completed call</th><td>${fmtDuration(t.callsCompleted?t.callDurationSec/t.callsCompleted:0)}</td></tr></tbody>`; }
|
||
|
||
function getYearlyAggregates(stats){
|
||
const byYear = new Map();
|
||
for (const [weekKey, w] of (stats.allWeeks || [])) {
|
||
const year = Number(weekKey.slice(0, 4));
|
||
const y = getOrInit(byYear, year, () => ({
|
||
year,
|
||
weeks: 0,
|
||
messages: 0,
|
||
calls: 0,
|
||
callDurationSec: 0,
|
||
chars: 0,
|
||
textMessages: 0,
|
||
activeContactsTotal: 0,
|
||
}));
|
||
y.weeks += 1;
|
||
y.messages += w.messages;
|
||
y.calls += w.calls;
|
||
y.callDurationSec += w.callDurationSec;
|
||
y.chars += w.chars;
|
||
y.textMessages += w.textMessages;
|
||
y.activeContactsTotal += w.activeContacts.size;
|
||
}
|
||
|
||
return [...byYear.values()].sort((a, b) => a.year - b.year).map((y) => ({
|
||
year: y.year,
|
||
weeks: y.weeks,
|
||
messages: y.messages,
|
||
calls: y.calls,
|
||
callDurationSec: y.callDurationSec,
|
||
avgMsgPerWeek: y.weeks ? y.messages / y.weeks : 0,
|
||
avgActive: y.weeks ? y.activeContactsTotal / y.weeks : 0,
|
||
avgMsgLen: y.textMessages ? y.chars / y.textMessages : 0,
|
||
callHours: y.callDurationSec / 3600,
|
||
}));
|
||
}
|
||
|
||
function renderYearlyTable(stats){
|
||
const table = $('yearlyTable');
|
||
const yearly = getYearlyAggregates(stats);
|
||
if (!yearly.length) {
|
||
table.innerHTML = '<tr><td class="small">No yearly data.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const rows = yearly.map((y) => `<tr>
|
||
<td>${y.year}</td>
|
||
<td>${fmtInt(y.messages)}</td>
|
||
<td>${fmtInt(y.calls)}</td>
|
||
<td>${fmtDuration(y.callDurationSec)}</td>
|
||
<td>${fmtFloat(y.avgMsgPerWeek, 1)}</td>
|
||
<td>${fmtFloat(y.avgActive, 1)}</td>
|
||
<td>${fmtFloat(y.avgMsgLen, 1)}</td>
|
||
</tr>`);
|
||
|
||
table.innerHTML = `<thead><tr>
|
||
<th>Year</th>
|
||
<th>Messages</th>
|
||
<th>Calls</th>
|
||
<th>Call duration</th>
|
||
<th>Avg msgs / week</th>
|
||
<th>Avg active contacts / week</th>
|
||
<th>Avg text length</th>
|
||
</tr></thead><tbody>${rows.join('')}</tbody>`;
|
||
}
|
||
|
||
function fitCanvas(canvas){ const dpr=Math.max(window.devicePixelRatio||1,1); const w=Math.max(10,Math.floor(canvas.clientWidth)); const h=Math.max(10,Math.floor(canvas.clientHeight)); canvas.width=Math.floor(w*dpr); canvas.height=Math.floor(h*dpr); const ctx=canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0); return {ctx,w,h}; }
|
||
function drawGrid(ctx,x,y,w,h,steps=5){ ctx.strokeStyle='rgba(149,163,199,.14)'; ctx.lineWidth=1; for(let i=0;i<=steps;i++){ const gy=y+(h/steps)*i; ctx.beginPath(); ctx.moveTo(x,gy); ctx.lineTo(x+w,gy); ctx.stroke(); }}
|
||
|
||
function drawYearlySingleBars(canvas, yearly, key, color){
|
||
const { ctx, w, h } = fitCanvas(canvas);
|
||
ctx.clearRect(0, 0, w, h);
|
||
if (!yearly.length) return;
|
||
|
||
const pad = { t: 14, r: 12, b: 28, l: 40 };
|
||
const cw = w - pad.l - pad.r;
|
||
const ch = h - pad.t - pad.b;
|
||
drawGrid(ctx, pad.l, pad.t, cw, ch, 4);
|
||
|
||
const maxValue = Math.max(1, ...yearly.map((y) => y[key] || 0));
|
||
|
||
const groupW = cw / yearly.length;
|
||
const barW = Math.max(4, Math.min(24, groupW * 0.7));
|
||
|
||
for (let i = 0; i < yearly.length; i += 1) {
|
||
const x = pad.l + i * groupW + Math.max(1, (groupW - barW) / 2);
|
||
const value = yearly[i][key] || 0;
|
||
const bh = (value / maxValue) * ch;
|
||
const y = pad.t + ch - bh;
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x, y, barW, bh);
|
||
}
|
||
|
||
ctx.fillStyle = 'rgba(149,163,199,.95)';
|
||
ctx.font = '11px Inter, sans-serif';
|
||
[0, Math.floor((yearly.length - 1) / 2), yearly.length - 1].filter((v, i, a) => a.indexOf(v) === i).forEach((idx) => {
|
||
const x = pad.l + idx * groupW + groupW * 0.34;
|
||
ctx.fillText(String(yearly[idx].year), Math.max(2, x - 18), h - 7);
|
||
});
|
||
|
||
for (let i = 0; i <= 4; i += 1) {
|
||
const y = pad.t + (ch / 4) * i;
|
||
const v = Math.round(maxValue * (1 - i / 4));
|
||
ctx.fillText(fmtInt(v), 4, y + 4);
|
||
}
|
||
}
|
||
|
||
function drawHistogram(canvas, values, useLogScale = true){
|
||
const { ctx, w, h } = fitCanvas(canvas);
|
||
ctx.clearRect(0, 0, w, h);
|
||
if (!values.length) return;
|
||
|
||
const clean = values.filter((v) => Number.isFinite(v) && v >= 0);
|
||
if (!clean.length) return;
|
||
|
||
const minV = Math.min(...clean);
|
||
const maxV = Math.max(...clean);
|
||
const binCount = Math.max(6, Math.min(16, Math.round(Math.sqrt(clean.length))));
|
||
const width = Math.max(1, Math.ceil((maxV - minV + 1) / binCount));
|
||
|
||
const bins = Array.from({ length: binCount }, (_, i) => ({
|
||
start: minV + i * width,
|
||
end: minV + (i + 1) * width - 1,
|
||
count: 0,
|
||
}));
|
||
|
||
for (const v of clean) {
|
||
const idx = Math.min(binCount - 1, Math.floor((v - minV) / width));
|
||
bins[idx].count += 1;
|
||
}
|
||
|
||
const maxCount = Math.max(1, ...bins.map((b) => b.count));
|
||
const transform = (v) => useLogScale ? Math.log10(v + 1) : v;
|
||
const maxTrans = Math.max(1e-9, transform(maxCount));
|
||
const pad = { t: 14, r: 12, b: 36, l: 40 };
|
||
const cw = w - pad.l - pad.r;
|
||
const ch = h - pad.t - pad.b;
|
||
drawGrid(ctx, pad.l, pad.t, cw, ch, 4);
|
||
|
||
const barGap = 2;
|
||
const barW = Math.max(2, Math.floor(cw / bins.length) - barGap);
|
||
bins.forEach((b, i) => {
|
||
const x = pad.l + i * (barW + barGap);
|
||
const bh = (transform(b.count) / maxTrans) * ch;
|
||
const y = pad.t + ch - bh;
|
||
ctx.fillStyle = '#6ea8fe';
|
||
ctx.fillRect(x, y, barW, bh);
|
||
});
|
||
|
||
ctx.fillStyle = 'rgba(149,163,199,.95)';
|
||
ctx.font = '11px Inter, sans-serif';
|
||
for (let i = 0; i <= 4; i += 1) {
|
||
const y = pad.t + (ch / 4) * i;
|
||
const ratio = 1 - i / 4;
|
||
const v = useLogScale
|
||
? Math.round((10 ** (maxTrans * ratio)) - 1)
|
||
: Math.round(maxCount * ratio);
|
||
ctx.fillText(String(v), 6, y + 4);
|
||
}
|
||
|
||
const tickIdx = [0, Math.floor((bins.length - 1) / 2), bins.length - 1].filter((v, i, a) => a.indexOf(v) === i);
|
||
tickIdx.forEach((i) => {
|
||
const b = bins[i];
|
||
const label = `${fmtInt(b.start)}-${fmtInt(b.end)}`;
|
||
const x = pad.l + i * (barW + barGap);
|
||
ctx.fillText(label, Math.max(2, x - 12), h - 10);
|
||
});
|
||
}
|
||
|
||
function drawDualAxisLine(canvas,labels,leftSeries,rightSeries,options={}){
|
||
const {ctx,w,h}=fitCanvas(canvas); ctx.clearRect(0,0,w,h); if(!labels.length) return;
|
||
const pad={t:14,r:42,b:26,l:42}; const cw=w-pad.l-pad.r; const ch=h-pad.t-pad.b;
|
||
const lvals=leftSeries.flatMap(s=>s.values); const rvals=rightSeries.flatMap(s=>s.values);
|
||
const maxL=Math.max(1,...lvals), maxR=Math.max(1,...rvals);
|
||
const lfmt=options.leftFormatter||((v)=>String(Math.round(v))); const rfmt=options.rightFormatter||((v)=>String(Math.round(v)));
|
||
drawGrid(ctx,pad.l,pad.t,cw,ch,5);
|
||
ctx.fillStyle='rgba(149,163,199,.9)'; ctx.font='11px Inter, sans-serif';
|
||
for(let i=0;i<=5;i++){ const y=pad.t+(ch/5)*i; const ll=lfmt(maxL*(1-i/5)); const rr=rfmt(maxR*(1-i/5)); ctx.fillText(ll,6,y+4); const tw=ctx.measureText(rr).width; ctx.fillText(rr,w-tw-4,y+4); }
|
||
const xAt=(i)=>pad.l+(i/Math.max(labels.length-1,1))*cw; const yL=(v)=>pad.t+ch-(v/maxL)*ch; const yR=(v)=>pad.t+ch-(v/maxR)*ch;
|
||
const draw=(series,map)=>{ for(const s of series){ ctx.strokeStyle=s.color; ctx.lineWidth=2; ctx.beginPath(); s.values.forEach((v,i)=>{const x=xAt(i), y=map(v); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);}); ctx.stroke(); ctx.fillStyle=s.color; s.values.forEach((v,i)=>{ if(labels.length>40&&i%2!==0) return; ctx.beginPath(); ctx.arc(xAt(i),map(v),2.2,0,Math.PI*2); ctx.fill(); }); }};
|
||
draw(leftSeries,yL); draw(rightSeries,yR);
|
||
const ticks=[0,Math.floor((labels.length-1)/2),labels.length-1].filter((v,i,a)=>a.indexOf(v)===i); ctx.fillStyle='rgba(149,163,199,.95)'; ticks.forEach((i)=>ctx.fillText(labels[i],Math.max(2,xAt(i)-28),h-7));
|
||
}
|
||
|
||
function drawGroupedBarsSingleAxis(canvas, labels, series, options = {}) {
|
||
const { ctx, w, h } = fitCanvas(canvas);
|
||
ctx.clearRect(0, 0, w, h);
|
||
if (!labels.length || !series.length) return;
|
||
|
||
const logScale = options.logScale !== false;
|
||
const yFormatter = options.yFormatter || ((v) => fmtInt(Math.round(v)));
|
||
const pad = { t: 14, r: 12, b: 28, l: 52 };
|
||
const cw = w - pad.l - pad.r;
|
||
const ch = h - pad.t - pad.b;
|
||
drawGrid(ctx, pad.l, pad.t, cw, ch, 5);
|
||
|
||
const allValues = series
|
||
.flatMap((s) => s.values || [])
|
||
.map((v) => Math.max(0, Number(v || 0)));
|
||
const maxValue = Math.max(1, ...allValues);
|
||
const transform = (v) => logScale ? Math.log10(v + 1) : v;
|
||
const invTransform = (t) => logScale ? (10 ** t) - 1 : t;
|
||
const maxTrans = Math.max(1e-9, transform(maxValue));
|
||
|
||
ctx.fillStyle = 'rgba(149,163,199,.9)';
|
||
ctx.font = '11px Inter, sans-serif';
|
||
for (let i = 0; i <= 5; i += 1) {
|
||
const y = pad.t + (ch / 5) * i;
|
||
const ratio = 1 - i / 5;
|
||
const raw = invTransform(maxTrans * ratio);
|
||
ctx.fillText(yFormatter(raw), 6, y + 4);
|
||
}
|
||
|
||
const groupW = cw / labels.length;
|
||
const sCount = series.length;
|
||
const innerPad = Math.min(10, groupW * 0.12);
|
||
const usable = Math.max(2, groupW - innerPad * 2);
|
||
const barGap = 1;
|
||
const barW = Math.max(1, Math.floor((usable - (sCount - 1) * barGap) / sCount));
|
||
|
||
for (let i = 0; i < labels.length; i += 1) {
|
||
const startX = pad.l + i * groupW + innerPad;
|
||
for (let s = 0; s < sCount; s += 1) {
|
||
const value = Math.max(0, Number(series[s].values[i] || 0));
|
||
const trans = transform(value);
|
||
const bh = (trans / maxTrans) * ch;
|
||
const x = startX + s * (barW + barGap);
|
||
const y = pad.t + ch - bh;
|
||
ctx.fillStyle = series[s].color || '#6ea8fe';
|
||
ctx.fillRect(x, y, barW, bh);
|
||
}
|
||
}
|
||
|
||
const ticks = [0, Math.floor((labels.length - 1) / 2), labels.length - 1].filter((v, i, a) => a.indexOf(v) === i);
|
||
ctx.fillStyle = 'rgba(149,163,199,.95)';
|
||
ticks.forEach((i) => {
|
||
const x = pad.l + (i / Math.max(labels.length - 1, 1)) * cw;
|
||
ctx.fillText(labels[i], Math.max(2, x - 22), h - 7);
|
||
});
|
||
}
|
||
|
||
function updateModeTitles(weeklyMode){
|
||
const yearSuffix = selectedYear ? ` · ${selectedYear}` : '';
|
||
const focusLabel = getFocusedLabel(latestStats || {});
|
||
const focusSuffix = focusLabel === 'All contacts' ? '' : ` · ${focusLabel}`;
|
||
$('trendMessagesTitle').textContent = weeklyMode
|
||
? `Trend: messages (selected year${yearSuffix ? yearSuffix : ''}${focusSuffix})`
|
||
: `Trend: messages (last 30 days${focusSuffix})`;
|
||
$('trendActivityTitle').textContent = weeklyMode
|
||
? `Trend: active contacts / calls (selected year${yearSuffix ? yearSuffix : ''}${focusSuffix})`
|
||
: `Trend: active contacts / calls (last 30 days${focusSuffix})`;
|
||
$('volumeMessagesTitle').textContent = weeklyMode
|
||
? `Weekly volume: messages (selected year${yearSuffix ? yearSuffix : ''}${focusSuffix})`
|
||
: `Weekly volume: messages (last 16 weeks${focusSuffix})`;
|
||
$('volumeCallsTitle').textContent = weeklyMode
|
||
? `Weekly volume: call duration (selected year${yearSuffix ? yearSuffix : ''}${focusSuffix})`
|
||
: `Weekly volume: call duration (last 16 weeks${focusSuffix})`;
|
||
$('weeklyComparisonTitle').textContent = weeklyMode
|
||
? `Weekly comparison (selected year${yearSuffix ? yearSuffix : ''})`
|
||
: 'Weekly comparison (recent 16 weeks)';
|
||
}
|
||
|
||
function getYearTopContacts(stats, year, topN = 5){
|
||
const rows = [];
|
||
const groupKeys = new Set((stats.topGroups || []).map(([, g]) => String(g?.key || '').trim()).filter(Boolean));
|
||
for (const [key, series] of (stats.contactSeriesByKey || new Map()).entries()) {
|
||
const normalizedKey = String(key || '').trim();
|
||
if (!normalizedKey) continue;
|
||
if (groupKeys.has(normalizedKey) || normalizedKey.startsWith('threema-group:')) continue;
|
||
|
||
let messages = 0;
|
||
let calls = 0;
|
||
let callDurationSec = 0;
|
||
let activeWeeks = 0;
|
||
for (const [weekKey, w] of (series.weekMap || new Map()).entries()) {
|
||
if (Number(String(weekKey).slice(0, 4)) !== year) continue;
|
||
const m = Number(w?.messages || 0);
|
||
const c = Number(w?.calls || 0);
|
||
const d = Number(w?.callDurationSec || 0);
|
||
messages += m;
|
||
calls += c;
|
||
callDurationSec += d;
|
||
if (m > 0 || c > 0 || d > 0) activeWeeks += 1;
|
||
}
|
||
const activity = messages + calls;
|
||
if (activity <= 0 && callDurationSec <= 0) continue;
|
||
rows.push({
|
||
key: normalizedKey,
|
||
label: normalizeText(series.label || normalizedKey) || normalizedKey,
|
||
value: Math.max(1, activity),
|
||
messages,
|
||
calls,
|
||
callDurationSec,
|
||
activeWeeks,
|
||
});
|
||
}
|
||
rows.sort((a, b) => {
|
||
if (b.value !== a.value) return b.value - a.value;
|
||
if (b.callDurationSec !== a.callDurationSec) return b.callDurationSec - a.callDurationSec;
|
||
return b.messages - a.messages;
|
||
});
|
||
return rows.slice(0, topN);
|
||
}
|
||
|
||
function renderYearTopContactsTable(stats){
|
||
const table = $('yearTopContactsTable');
|
||
if (!table) return;
|
||
const years = (stats.availableYears || []).slice().sort((a, b) => a - b);
|
||
if (!years.length) {
|
||
table.innerHTML = '<tr><td class="small">No yearly data.</td></tr>';
|
||
return;
|
||
}
|
||
|
||
const topByYear = new Map(years.map((y) => [y, getYearTopContacts(stats, y, 5)]));
|
||
const seenBefore = new Map();
|
||
const bodyRows = years.map((year, idx) => {
|
||
const list = topByYear.get(year) || [];
|
||
const prevList = idx > 0 ? (topByYear.get(years[idx - 1]) || []) : [];
|
||
const prevRank = new Map(prevList.map((it, i) => [it.key, i + 1]));
|
||
|
||
const cells = Array.from({ length: 5 }, (_, pos) => {
|
||
const row = list[pos];
|
||
if (!row) return '<td class="small">—</td>';
|
||
const rank = pos + 1;
|
||
let move = 'NEW';
|
||
let moveHtml = '<span style="color:#b6c4ea;">—</span>';
|
||
if (idx === 0) move = '—';
|
||
else if (prevRank.has(row.key)) {
|
||
const prev = prevRank.get(row.key);
|
||
if (prev === rank) move = '→';
|
||
else if (prev > rank) move = `↑${prev - rank}`;
|
||
else move = `↓${rank - prev}`;
|
||
} else if (seenBefore.has(row.key)) {
|
||
move = '↺';
|
||
}
|
||
if (move.startsWith('↑')) moveHtml = `<span style="color:#3ddc97; font-weight:600;">${escapeHtml(move)}</span>`;
|
||
else if (move.startsWith('↓')) moveHtml = `<span style="color:#ff6b6b; font-weight:600;">${escapeHtml(move)}</span>`;
|
||
else if (move === 'NEW') moveHtml = '<span style="color:#ffd166; font-weight:600;">✨ NEW</span>';
|
||
else if (move === '↺') moveHtml = '<span style="color:#ffd166; font-weight:600;">↺ RETURN</span>';
|
||
else if (move === '→') moveHtml = '<span style="color:#95a3c7;">→</span>';
|
||
const metricsTooltip = `${fmtInt(row.messages)} messages • ${fmtInt(row.calls)} calls • ${fmtHours(row.callDurationSec)} call duration`;
|
||
const infoHtml = `<span title="${escapeHtml(metricsTooltip)}" style="color:#95a3c7; cursor:help; margin-left:6px;">ℹ️</span>`;
|
||
return `<td>
|
||
<div><strong>#${rank}</strong> ${renderNameLabel(row.label, row.key, 'person')}${infoHtml}</div>
|
||
<div class="small">Move: ${moveHtml}</div>
|
||
</td>`;
|
||
}).join('');
|
||
|
||
list.forEach((item, i) => {
|
||
seenBefore.set(item.key, i + 1);
|
||
});
|
||
|
||
const isSelected = chartMode === 'weekly' && selectedYear === year;
|
||
const rowStyle = isSelected ? ' style="background:rgba(110,168,254,.08);"' : '';
|
||
return `<tr${rowStyle}><td><strong>${year}</strong></td>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
table.innerHTML = `<thead><tr>
|
||
<th>Year</th>
|
||
<th>#1</th>
|
||
<th>#2</th>
|
||
<th>#3</th>
|
||
<th>#4</th>
|
||
<th>#5</th>
|
||
</tr></thead><tbody>${bodyRows}</tbody>`;
|
||
}
|
||
|
||
function renderCharts(stats){
|
||
syncYearState(stats);
|
||
const weeklyMode = chartMode === 'weekly';
|
||
const focused = getFocusedSeries(stats);
|
||
updateModeTitles(weeklyMode);
|
||
renderYearTopContactsTable(stats);
|
||
const recentDaysBase = stats.recentMonthDays || [];
|
||
const recentWeeksBase = stats.recentWeeks || [];
|
||
const allWeeksBase = stats.allWeeks || [];
|
||
|
||
const mapDay = focused?.dayMap || null;
|
||
const mapWeek = focused?.weekMap || null;
|
||
|
||
const recentDays = mapDay
|
||
? recentDaysBase.map(([d]) => [d, mapDay.get(d) || { messages: 0, calls: 0, callDurationSec: 0, activeContacts: new Set() }])
|
||
: recentDaysBase;
|
||
const recentWeeks = mapWeek
|
||
? recentWeeksBase.map(([w]) => [w, mapWeek.get(w) || { messages: 0, calls: 0, callDurationSec: 0, activeContacts: new Set() }])
|
||
: recentWeeksBase;
|
||
const allWeeks = mapWeek
|
||
? allWeeksBase.map(([w]) => [w, mapWeek.get(w) || { messages: 0, calls: 0, callDurationSec: 0, activeContacts: new Set() }])
|
||
: allWeeksBase;
|
||
|
||
const yearWeeks = weeklyMode ? allWeeks.filter(([w]) => Number(w.slice(0, 4)) === selectedYear) : [];
|
||
|
||
if (weeklyMode) {
|
||
const labels = yearWeeks.map(([w]) => `W${w.slice(-2)}`);
|
||
drawGroupedBarsSingleAxis($('trendMessagesChart'), labels, [
|
||
{ color:'#6ea8fe', values: yearWeeks.map(([,d])=>d.messages) },
|
||
], { logScale: chartLogScale, yFormatter: (v) => fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('trendActivityChart'), labels, [
|
||
{ color:'#7effc7', values: focused ? yearWeeks.map(([,d])=>d.calls) : yearWeeks.map(([,d])=>d.activeContacts.size) },
|
||
], { logScale: chartLogScale, yFormatter: (v) => fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('volumeMessagesChart'), labels, [
|
||
{ color:'#c27bff', values: yearWeeks.map(([,w])=>w.messages) },
|
||
], { logScale: chartLogScale, yFormatter: (v) => fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('volumeCallsChart'), labels, [
|
||
{ color:'#ff6b6b', values: yearWeeks.map(([,w])=>w.callDurationSec/60) },
|
||
], { logScale: chartLogScale, yFormatter: (v) => fmtInt(Math.round(v)) });
|
||
const yearly = focused ? getYearlyAggregatesFromWeekEntries(allWeeks) : getYearlyAggregates(stats);
|
||
drawYearlySingleBars($('yearlyMessagesChart'), yearly, 'messages', '#6ea8fe');
|
||
drawYearlySingleBars($('yearlyCallsChart'), yearly, 'calls', '#ffce54');
|
||
drawYearlySingleBars($('yearlyDurationChart'), yearly, 'callHours', '#ff6b6b');
|
||
drawHistogram($('messagesHistogramChart'), (stats.topContacts || []).map(([, c]) => c.messages || 0), histogramLogScale);
|
||
return;
|
||
}
|
||
|
||
const dayLabels=recentDays.map(([d])=>d.slice(5));
|
||
drawGroupedBarsSingleAxis($('trendMessagesChart'),dayLabels,[
|
||
{color:'#6ea8fe',values:recentDays.map(([,d])=>d.messages)}
|
||
],{ logScale: chartLogScale, yFormatter:(v)=>fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('trendActivityChart'),dayLabels,[
|
||
{color:'#7effc7',values:focused ? recentDays.map(([,d])=>d.calls) : recentDays.map(([,d])=>d.activeContacts.size)}
|
||
],{ logScale: chartLogScale, yFormatter:(v)=>fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('volumeMessagesChart'),recentWeeks.map(([w])=>w.slice(-3)),[
|
||
{color:'#c27bff',values:recentWeeks.map(([,w])=>w.messages)}
|
||
],{ logScale: chartLogScale, yFormatter:(v)=>fmtInt(Math.round(v)) });
|
||
drawGroupedBarsSingleAxis($('volumeCallsChart'),recentWeeks.map(([w])=>w.slice(-3)),[
|
||
{color:'#ff6b6b',values:recentWeeks.map(([,w])=>w.callDurationSec/60)}
|
||
],{ logScale: chartLogScale, yFormatter:(v)=>fmtInt(Math.round(v)) });
|
||
|
||
const yearly = focused ? getYearlyAggregatesFromWeekEntries(allWeeks) : getYearlyAggregates(stats);
|
||
drawYearlySingleBars($('yearlyMessagesChart'), yearly, 'messages', '#6ea8fe');
|
||
drawYearlySingleBars($('yearlyCallsChart'), yearly, 'calls', '#ffce54');
|
||
drawYearlySingleBars($('yearlyDurationChart'), yearly, 'callHours', '#ff6b6b');
|
||
|
||
drawHistogram($('messagesHistogramChart'), (stats.topContacts || []).map(([, c]) => c.messages || 0), histogramLogScale);
|
||
}
|
||
|
||
function runLookup(){
|
||
const out = $('lookupResult');
|
||
if (!latestStats) { out.textContent = 'Load data first.'; return; }
|
||
const q = normalizeNumber($('numberLookup').value);
|
||
if (!q) { out.textContent = 'Please enter a number.'; return; }
|
||
|
||
const matches = latestStats.contacts
|
||
.filter((c) => c.number.includes(q))
|
||
.sort((a, b) => (b.messages + b.calls) - (a.messages + a.calls));
|
||
|
||
if (!matches.length) {
|
||
out.textContent = `No number matching "${q}" found.`;
|
||
return;
|
||
}
|
||
|
||
const c = matches[0];
|
||
const avgLen = c.textMessages ? c.chars / c.textMessages : 0;
|
||
out.innerHTML = `
|
||
Best match: <strong>${renderNameLabel(c.name || c.number, c.number, 'person')}</strong> (${c.number}) ·
|
||
${fmtInt(c.messages)} msgs · ${fmtInt(c.calls)} calls · ${fmtDuration(c.callDurationSec)} call duration ·
|
||
avg msg len ${fmtFloat(avgLen, 1)} chars.
|
||
${matches.length > 1 ? `<br/>Also ${matches.length - 1} additional match(es).` : ''}
|
||
`;
|
||
}
|
||
|
||
function getYearlyAggregatesFromWeekEntries(weeks){
|
||
const byYear = new Map();
|
||
for (const [weekKey, w] of (weeks || [])) {
|
||
const year = Number(String(weekKey).slice(0, 4));
|
||
const y = getOrInit(byYear, year, () => ({ year, weeks: 0, messages: 0, calls: 0, callDurationSec: 0 }));
|
||
y.weeks += 1;
|
||
y.messages += Number(w?.messages || 0);
|
||
y.calls += Number(w?.calls || 0);
|
||
y.callDurationSec += Number(w?.callDurationSec || 0);
|
||
}
|
||
return [...byYear.values()].sort((a, b) => a.year - b.year).map((y) => ({
|
||
year: y.year,
|
||
weeks: y.weeks,
|
||
messages: y.messages,
|
||
calls: y.calls,
|
||
callDurationSec: y.callDurationSec,
|
||
callHours: y.callDurationSec / 3600,
|
||
}));
|
||
}
|
||
|
||
function renderDashboard(stats, options = {}){
|
||
latestStats=stats;
|
||
if (!options.preservePagination) {
|
||
contactsPage = 0;
|
||
groupsPage = 0;
|
||
}
|
||
syncYearState(stats);
|
||
const {totals,recentWeeks,meta}=stats;
|
||
const kpi=$('kpiGrid'); kpi.innerHTML='';
|
||
kpi.appendChild(createKpi('Total messages',fmtInt(totals.messages)));
|
||
kpi.appendChild(createKpi('Active direct contacts',fmtInt(meta.directContactsCount)));
|
||
kpi.appendChild(createKpi('Average messages / day',fmtFloat(totals.messagesPerDayAvg,1)));
|
||
kpi.appendChild(createKpi('Average contacts / day',fmtFloat(totals.contactsPerDayAvg,1)));
|
||
kpi.appendChild(createKpi('Average text length',`${fmtFloat(totals.avgMessageLen,1)} chars`));
|
||
kpi.appendChild(createKpi('Calls total',fmtInt(totals.callsTotal)));
|
||
kpi.appendChild(createKpi('Call duration total',fmtDuration(totals.callDurationSec)));
|
||
kpi.appendChild(createKpi('You vs others',`${fmtInt(totals.myMessages)} / ${fmtInt(totals.theirMessages)}`));
|
||
kpi.appendChild(createKpi('WhatsApp events', fmtInt(stats.sourceTotals?.whatsappEvents || 0)));
|
||
kpi.appendChild(createKpi('Threema events', fmtInt(stats.sourceTotals?.threemaEvents || 0)));
|
||
|
||
populateChartContactSelect(stats);
|
||
renderPagedBars();
|
||
renderThreemaMapping(stats);
|
||
renderWeeklyTable(recentWeeks); renderCallTable(totals); renderYearlyTable(stats); renderCharts(stats);
|
||
}
|
||
|
||
setMappingStatus(`${Object.keys(identityMapping).length} persisted link(s).`);
|
||
</script>
|
||
</body>
|
||
</html>
|