Files

422 lines
15 KiB
JavaScript
Raw Permalink 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.
const $ = sel => document.querySelector(sel);
const now = new Date();
$('#dateline').textContent = now.toLocaleDateString('en-GB',{weekday:'short',day:'2-digit',month:'short',year:'numeric'});
let people = [], events = [], parseErrors = [];
async function loadData(){
try{
const [pRes, eRes, mRes, sRes] = await Promise.all([
fetch('/api/people'),
fetch('/api/events'),
fetch('/api/parse_errors'),
fetch('/api/scores')
]);
people = await pRes.json();
events = await eRes.json();
parseErrors = await mRes.json();
stats = await sRes.json();
} catch(e){
people = []; events = []; parseErrors = []; stats = {}
}
renderMeetups();
renderHeatmap();
renderTopContacts();
renderStats();
renderDecay();
renderParseFab();
}
function renderParseFab(){
const fab = $('#parse-fab');
const badge = $('#parse-fab-badge');
if(!parseErrors.length){
fab.style.display = 'none';
return;
}
fab.style.display = 'flex';
badge.textContent = parseErrors.length;
}
function findPerson(uid){ return people.find(p => p.uid === uid); }
function findEvent(uid){ return events.find(e => e.uid === uid); }
function toTitleCase(str){
return str.replace(/\w\S*/g, w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase());
}
function participantName(p){
if(typeof p === 'object' && p !== null) return toTitleCase(p.name || p.uid);
const match = findPerson(p);
return toTitleCase(match ? match.name : p);
}
function participantUid(p){
return (typeof p === 'object' && p !== null) ? p.uid : p;
}
function daysBetween(a,b){ return Math.floor((a-b)/86400000); }
function fmtDate(d){ return new Date(d).toLocaleDateString('en-GB',{day:'2-digit',month:'2-digit',year:'numeric'}); }
function fmtDateLong(d){ return new Date(d).toLocaleDateString('en-GB',{weekday:'long',day:'2-digit',month:'long',year:'numeric'}); }
function eventsForPerson(uid){
return events.filter(ev => (ev.participants||[]).some(p => participantUid(p) === uid))
.sort((a,b) => new Date(b.date) - new Date(a.date));
}
function eventsForDateKey(key){
return events.filter(ev => new Date(ev.date).toISOString().slice(0,10) === key)
.sort((a,b) => new Date(a.date) - new Date(b.date));
}
// ---- rendering ----
function renderMeetups(){
const el = $('#meetups');
const sorted = [...events]
.filter(ev => new Date(ev.date) <= now)
.sort((a,b) => new Date(b.date) - new Date(a.date));
if(!sorted.length){ el.innerHTML = '<div class="empty-note">No meetups logged yet.</div>'; return; }
el.innerHTML = sorted.map(ev => {
const whoSpans = (ev.participants||[]).map(p =>
`<span data-person-uid="${participantUid(p)}">${participantName(p)}</span>`).join(', ');
return `<div class="meetup clickable" data-event-uid="${ev.uid}">
<div class="what">${ev.summary}</div>
<div class="when">${fmtDate(ev.date)}</div>
${whoSpans ? `<div class="who">${whoSpans}</div>` : ''}
</div>`;
}).join('');
}
function renderHeatmap(){
const el = $('#heatmap');
const monthsEl = $('#heatmap-months');
const year = now.getFullYear();
const start = new Date(year,0,1);
const counts = {};
events.forEach(ev => {
const d = new Date(ev.date);
if(d.getFullYear() === year){
const key = d.toISOString().slice(0,10);
counts[key] = (counts[key]||0) + 1;
}
});
const firstDow = (start.getDay() + 6) % 7;
const dayEntries = [];
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
for(let d = new Date(start); d.getFullYear() === year && d <= endOfToday; d.setDate(d.getDate()+1)){
dayEntries.push(new Date(d));
}
const numWeeks = Math.ceil((firstDow + dayEntries.length) / 7);
const isMobile = window.matchMedia('(max-width:980px)').matches;
const colTemplate = isMobile ? `repeat(${numWeeks}, 12px)` : `repeat(${numWeeks}, 1fr)`;
const gridWidth = isMobile ? 'max-content' : '100%';
el.style.gridTemplateColumns = colTemplate;
el.style.width = gridWidth;
monthsEl.style.gridTemplateColumns = colTemplate;
monthsEl.style.width = gridWidth;
// month initials, placed above the week-column where each month's 1st falls
const monthNames = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const monthCols = {};
dayEntries.forEach((d, i) => {
if(d.getDate() === 1){
const slot = firstDow + i;
const col = Math.floor(slot / 7) + 1;
monthCols[col] = monthNames[d.getMonth()];
}
});
if(dayEntries.length && !(1 in monthCols)){
monthCols[1] = monthNames[dayEntries[0].getMonth()];
}
let monthsHtml = '';
for(let c = 1; c <= numWeeks; c++){
monthsHtml += `<span style="grid-column:${c}">${monthCols[c] || ''}</span>`;
}
monthsEl.innerHTML = monthsHtml;
el.innerHTML = dayEntries.map((d, i) => {
const slot = firstDow + i;
const col = Math.floor(slot / 7) + 1;
const row = (slot % 7) + 1;
const key = d.toISOString().slice(0,10);
const c = counts[key] || 0;
const bg = c > 0 ? 'var(--fresh)' : 'var(--border)';
return `<div class="cell clickable" data-date-key="${key}" style="grid-column:${col};grid-row:${row};background:${bg}" title="${key}: ${c} event(s)"></div>`;
}).join('');
if(isMobile){
const wrap = el.parentElement;
wrap.scrollLeft = wrap.scrollWidth;
}
}
function meetupCounts(){
const counts = {};
events.forEach(ev => {
(ev.participants||[]).forEach(p => {
const uid = participantUid(p);
counts[uid] = (counts[uid]||0) + 1;
});
});
return counts;
}
function renderTopContacts(){
const el = $('#topcontacts');
const counts = meetupCounts();
const ranked = Object.entries(counts)
.map(([uid,c]) => ({ uid, count:c, name: participantName(uid) }))
.sort((a,b) => b.count - a.count);
if(!ranked.length){ el.innerHTML = '<div class="empty-note">No participants recorded on any event yet.</div>'; return; }
const max = ranked[0].count;
el.innerHTML = ranked.map((r,i) => `
<div class="rank-item clickable" data-person-uid="${r.uid}">
<div class="rank-num">${i+1}</div>
<div class="rank-name">${r.name}</div>
<div class="rank-bar-track"><div class="rank-bar-fill" style="width:${(r.count/max*100).toFixed(0)}%"></div></div>
<div class="rank-count">${r.count}×</div>
</div>`).join('');
}
function renderStats(){
const monthEvents = events.filter(ev => {
const d = new Date(ev.date);
return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() && d <= now;
});
$('#events-month').textContent = monthEvents.length;
const cutoff = new Date(now); cutoff.setDate(cutoff.getDate()-90);
const recent = events.filter(ev => new Date(ev.date) >= cutoff && new Date(ev.date) <= now);
const uniqueContacts = new Set();
recent.forEach(ev => (ev.participants||[]).forEach(p => uniqueContacts.add(participantUid(p))));
const score = stats.total ?? 0;;
$('#social-score').textContent = score.toFixed(2);
}
function renderDecay(){
const el = $('#decay');
const lastSeenMap = {};
events.forEach(ev => {
(ev.participants||[]).forEach(p => {
const uid = participantUid(p);
const d = new Date(ev.date);
if(!lastSeenMap[uid] || d > lastSeenMap[uid]) lastSeenMap[uid] = d;
});
});
people.forEach(p => {
if(p.last_seen){
const d = new Date(p.last_seen);
if(!lastSeenMap[p.uid] || d > lastSeenMap[p.uid]) lastSeenMap[p.uid] = d;
}
});
const rows = Object.entries(lastSeenMap)
.map(([uid,d]) => ({ uid, name: participantName(uid), days: daysBetween(now, d) }))
.sort((a,b) => b.days - a.days);
if(!rows.length){ el.innerHTML = '<div class="empty-note">No contact history yet.</div>'; return; }
let overdue = 0;
el.innerHTML = rows.map(r => {
let cls = 'fresh';
if(r.days > 63){ cls = 'critical'; overdue++; }
else if(r.days > 21){ cls = 'warn'; }
const width = Math.max(6, 100 - Math.min(r.days, 100));
const label = r.days <= 0 ? 'today' : r.days === 1 ? '1d' : `${r.days}d`;
return `<div class="decay-row clickable" data-person-uid="${r.uid}">
<div class="decay-top">
<div class="decay-name"><span class="flag ${cls}"></span>${r.name}</div>
<div class="decay-days ${cls}">${label}</div>
</div>
<div class="decay-track"><div class="decay-fill ${cls}" style="width:${width}%"></div></div>
</div>`;
}).join('');
const badgeHolder = $('#warn-badge-holder');
badgeHolder.innerHTML = overdue ? `<span class="warn-badge">${overdue}</span>` : '';
}
// ---- avatar photo ----
const photoCache = new Map();
async function loadAvatar(uid){
if(photoCache.has(uid)) return photoCache.get(uid);
try{
const res = await fetch(`/api/people/${uid}/photo`);
if(!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const { format, photo } = await res.json();
const dataUri = `data:image/${format};base64,${photo}`;
photoCache.set(uid, dataUri);
return dataUri;
} catch(err){
console.error('avatar load failed for', uid, err);
return null;
}
}
function avatarHtml(person){
if(!person || !person.photo){
return ``;
}
return `<div class="modal-avatar" id="modal-avatar"></div>`;
}
// ---- modal ----
const overlay = $('#modal-overlay');
const modalBody = $('#modal-body');
function openModal(html){
modalBody.innerHTML = html;
overlay.classList.add('open');
}
function closeModal(){
overlay.classList.remove('open');
}
$('#modal-close').addEventListener('click', closeModal);
overlay.addEventListener('click', e => { if(e.target === overlay) closeModal(); });
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeModal(); });
function fmtBirthday(b){
if(!b) return null;
const d = new Date(b);
if(isNaN(d)) return b; // fallback if not a parseable date
const mm = String(d.getMonth()+1).padStart(2,'0');
const dd = String(d.getDate()).padStart(2,'0');
if(d.getFullYear() === 1604){
return `${mm}.${dd}`;
}
return `${mm}.${dd}.${d.getFullYear()}`;
}
function openPersonModal(uid){
const person = findPerson(uid);
const name = person ? toTitleCase(person.name) : uid;
const meetups = eventsForPerson(uid);
const lastSeenDate = meetups.length ? new Date(meetups[0].date) : (person && person.last_seen ? new Date(person.last_seen) : null);
const daysSince = lastSeenDate ? daysBetween(now, lastSeenDate) : null;
const rows = [
['Birthday', person && person.birthday ? fmtBirthday(person.birthday) : '—'],
['Last seen', daysSince !== null ? `${daysSince}d ago` : '—'],
['Meetups together', meetups.length],
['Numbers', person.numbers.join(",\n") ?? "no number"]
];
const meetupChips = meetups.length
? `<div class="modal-link-list">${meetups.map(ev =>
`<button class="modal-chip" data-event-uid="${ev.uid}"><i class="fa-solid fa-calendar-days"></i> ${ev.summary} · ${fmtDate(ev.date)}</button>`).join('')}</div>`
: `<div class="modal-empty">No shared meetups yet.</div>`;
openModal(`
<div class="modal-kicker">Person</div>
${avatarHtml(person)}
<div class="modal-title"><i class="fa-solid fa-circle-user"></i> ${name}</div>
${rows.map(([k,v]) => `<div class="modal-row"><span class="k">${k}</span><span class="v">${v}</span></div>`).join('')}
<div class="modal-section">
<h3>Meetups</h3>
${meetupChips}
</div>
`);
if(person && person.photo){
console.log("loading picture")
loadAvatar(uid).then(src => {
const el = $('#modal-avatar');
if(el) el.style.backgroundImage = `url(${src})`;
});
}
}
function openScoreModal(){
const rows = [
['Total', stats.total],
['Event frequency', stats.frequency],
['Diversity', stats.diversity],
];
openModal(`
<div class="modal-kicker">Social Score</div>
<div class="modal-title"><i class="fa-solid fa-arrow-trend-up"></i> Score breakdown</div>
${rows.map(([k,v]) => `<div class="modal-row"><span class="k">${k}</span><span class="v">${(v ?? 0).toFixed(2)}</span></div>`).join('')}
`);
}
function openEventModal(uid){
const ev = findEvent(uid);
if(!ev){ openModal(`<div class="modal-title">Not found</div>`); return; }
const participants = ev.participants || [];
const chips = participants.length
? `<div class="modal-link-list">${participants.map(p =>
`<button class="modal-chip" data-person-uid="${participantUid(p)}"><i class="fa-solid fa-user"></i> ${participantName(p)}</button>`).join('')}</div>`
: `<div class="modal-empty">No participants recorded.</div>`;
openModal(`
<div class="modal-kicker">Event</div>
<div class="modal-title"><i class="fa-solid fa-calendar-days"></i> ${ev.summary}</div>
<div class="modal-row"><span class="k">Date</span><span class="v">${fmtDate(ev.date)}</span></div>
<div class="modal-row"><span class="k">Attendees</span><span class="v">${participants.length}</span></div>
<div class="modal-section">
<h3>Who was there</h3>
${chips}
</div>
`);
}
function openDayModal(key){
const dayEvents = eventsForDateKey(key);
const chips = dayEvents.length
? `<div class="modal-link-list">${dayEvents.map(ev =>
`<button class="modal-chip" data-event-uid="${ev.uid}"><i class="fa-solid fa-calendar-days"></i> ${ev.summary}</button>`).join('')}</div>`
: `<div class="modal-empty">Nothing logged this day.</div>`;
openModal(`
<div class="modal-kicker">Day</div>
<div class="modal-title"><i class="fa-solid fa-map-pin"></i> ${fmtDateLong(key + 'T00:00:00')}</div>
<div class="modal-row"><span class="k">Events</span><span class="v">${dayEvents.length}</span></div>
<div class="modal-section">
<h3>What happened</h3>
${chips}
</div>
`);
}
function openParseErrorsModal(){
const rows = parseErrors.map(m => {
const ev = findEvent(m.event_uid);
const evLabel = ev ? `${ev.summary} · ${fmtDate(ev.date)}` : m.event_uid;
return `<div class="modal-row">
<span class="k">"${m.name}"</span>
<span class="v"><button class="modal-chip" data-event-uid="${m.event_uid}">${evLabel}</button></span>
</div>`;
}).join('');
openModal(`
<div class="modal-kicker">Unresolved participants</div>
<div class="modal-title">Couldn't match ${parseErrors.length} name${parseErrors.length === 1 ? '' : 's'}</div>
${rows || '<div class="modal-empty">Nothing to fix.</div>'}
`);
}
$('#parse-fab').addEventListener('click', openParseErrorsModal);
$('#score-card').addEventListener('click', openScoreModal);
// event delegation for the whole page + modal content
document.addEventListener('click', e => {
const personEl = e.target.closest('[data-person-uid]');
const eventEl = e.target.closest('[data-event-uid]');
const dayEl = e.target.closest('[data-date-key]');
if(personEl){
openPersonModal(personEl.dataset.personUid);
return;
}
if(eventEl){
openEventModal(eventEl.dataset.eventUid);
return;
}
if(dayEl){
openDayModal(dayEl.dataset.dateKey);
}
});
loadData();
window.addEventListener('resize', () => { if(events.length || people.length) renderHeatmap(); });