Files
whatsthat-perso/index.html
2025-10-14 13:13:17 +02:00

328 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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