first commit

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

327
index.html Normal file
View File

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