422 lines
15 KiB
JavaScript
422 lines
15 KiB
JavaScript
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(); });
|