/** * 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 = `
${esc(initials)}

${esc(name)}

${companyTitle ? `
${companyTitle}
` : ''} ${tags ? `
${tags}
` : ''}
`; // 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 = `
${infoItems.map(i => `
${i}
`).join('')}
`; } // 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 = `
${taskCount} Tasks
${eventCount} Events
${emailCount} Emails
`; 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 }; })();