Files
chatanalyzer/analyze.html
2026-03-27 16:45:08 +01:00

1791 lines
82 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[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>