/**
* GoingsOn - Contact Dashboard Module
*
* Full-page dashboard for a contact, showing linked tasks, events, and emails
* as a unified activity timeline. Modeled after the task overview page.
*/
(function() {
'use strict';
const esc = GoingsOn.utils.escapeHtml;
const escAttr = GoingsOn.utils.escapeAttrValue;
const escArg = GoingsOn.utils.escapeHandlerArg;
let currentContactId = null;
// How many activity rows the dashboard shows before the user expands "Show all".
const INITIAL_ACTIVITY_LIMIT = 20;
/**
* Open the contact dashboard for a given contact.
* @param {string} contactId
*/
async function open(contactId) {
currentContactId = contactId;
GoingsOn.navigation.switchView('contact-dashboard');
const content = document.getElementById('contact-dashboard-content');
const titleEl = document.getElementById('contact-dashboard-title');
const actionsEl = document.getElementById('contact-dashboard-actions');
try {
const [contact, activity] = await Promise.all([
GoingsOn.api.contacts.get(contactId),
GoingsOn.api.contacts.getActivity(contactId, INITIAL_ACTIVITY_LIMIT),
]);
titleEl.textContent = contact.displayName || contact.display_name;
render(content, actionsEl, contact, activity);
} catch (err) {
content.innerHTML = `
Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}
`;
}
}
function close() {
currentContactId = null;
GoingsOn.navigation.switchView('contacts');
}
/**
* Render one pre-computed activity row. All fields (kind, title, date,
* status, direction) are supplied by Rust — the JS only maps them to markup.
* @param {{kind: string, id: string, title: string, dateFormatted: string, status: (string|null), isOutgoing: boolean}} item
*/
function renderTimelineItem(item) {
const icon = item.kind === 'task' ? '☑'
: item.kind === 'event' ? '📅'
: (item.isOutgoing ? '✉︎→' : '←✉︎');
const badge = item.kind === 'task' && item.status
? `${esc(item.status)}`
: '';
let act = '';
if (item.kind === 'task') act = 'taskOverview.open';
else if (item.kind === 'event') act = 'events.open';
else if (item.kind === 'email') act = 'emails.open';
return `
${icon}
${esc(item.title)}
${badge}
${esc(item.dateFormatted)}
`;
}
function render(container, actionsEl, contact, activity) {
const name = contact.displayName || contact.display_name;
const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
// Actions bar
if (contact.isImplicit) {
actionsEl.innerHTML = `
`;
} else {
actionsEl.innerHTML = `
`;
}
// Header card
const company = contact.company ? `${esc(contact.company)}` : '';
const title = contact.title ? `${esc(contact.title)}` : '';
const companyTitle = [company, title].filter(Boolean).join(' · ');
const tags = (contact.tags || []).map(t => `${esc(t)}`).join(' ');
let headerHtml = `
`;
// Contact info (email addresses, phones, social handles)
let infoHtml = '';
const infoItems = [];
for (const e of contact.emails || []) {
infoItems.push(`Email: ${esc(e.address)}${e.label ? ' ' + esc(e.label) + '' : ''}`);
}
for (const p of contact.phones || []) {
infoItems.push(`Phone: ${esc(p.number)}${p.label ? ' ' + esc(p.label) + '' : ''}`);
}
for (const s of contact.socialHandles || contact.social_handles || []) {
infoItems.push(`${esc(s.platform)}: ${esc(s.handle)}`);
}
if (infoItems.length > 0) {
infoHtml = ``;
}
// Activity timeline — Rust already merged, sorted (newest-first), capped,
// and pre-formatted every row. JS just maps items to markup.
const timelineItems = activity.items || [];
const totalActivity = (activity.taskCount || 0) + (activity.eventCount || 0) + (activity.emailCount || 0);
let timelineHtml = '';
if (timelineItems.length === 0) {
timelineHtml = 'No interactions yet.
';
} else {
const items = timelineItems.map(renderTimelineItem).join('');
// Rust caps the initial feed; offer to load the rest without a second round of client math.
const showAll = totalActivity > timelineItems.length
? ``
: '';
timelineHtml = `${items}
${showAll}`;
}
// Notes section
const notesHtml = contact.notes
? `Notes
${esc(contact.notes)}
`
: '';
// Linked entities summary — totals from Rust (may exceed the capped feed)
const taskCount = activity.taskCount;
const eventCount = activity.eventCount;
const emailCount = activity.emailCount;
const summaryHtml = `
`;
container.innerHTML = headerHtml + infoHtml + summaryHtml + `
Activity
${timelineHtml}
` + notesHtml;
}
async function promote(contactId) {
try {
await GoingsOn.api.contacts.promoteContact(contactId);
GoingsOn.cache.invalidate('contacts');
GoingsOn.autocomplete.refresh();
GoingsOn.ui.showToast('Contact saved!', 'success');
open(contactId); // re-render
} catch (err) {
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
}
}
/**
* Load and render the full activity feed (no server-side cap), replacing the
* capped list and the "Show all" button in place. Rust still merges/sorts/formats.
*/
async function showAllActivity() {
if (!currentContactId) return;
const timeline = document.getElementById('contact-timeline');
if (!timeline) return;
try {
const activity = await GoingsOn.api.contacts.getActivity(currentContactId);
const items = (activity.items || []).map(renderTimelineItem).join('');
timeline.innerHTML = items;
const btn = timeline.nextElementSibling;
if (btn && btn.tagName === 'BUTTON') btn.remove();
} catch (err) {
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load activity'), 'error');
}
}
GoingsOn.contactDashboard = { open, close, promote, showAllActivity };
})();