Skip to main content

max / goingson

9.5 KB · 213 lines History Blame Raw
1 /**
2 * GoingsOn - Contact Dashboard Module
3 *
4 * Full-page dashboard for a contact, showing linked tasks, events, and emails
5 * as a unified activity timeline. Modeled after the task overview page.
6 */
7
8 (function() {
9 'use strict';
10 const esc = GoingsOn.utils.escapeHtml;
11 const escAttr = GoingsOn.utils.escapeAttrValue;
12 const escArg = GoingsOn.utils.escapeHandlerArg;
13
14 let currentContactId = null;
15
16 // How many activity rows the dashboard shows before the user expands "Show all".
17 const INITIAL_ACTIVITY_LIMIT = 20;
18
19 /**
20 * Open the contact dashboard for a given contact.
21 * @param {string} contactId
22 */
23 async function open(contactId) {
24 currentContactId = contactId;
25 GoingsOn.navigation.switchView('contact-dashboard');
26
27 const content = document.getElementById('contact-dashboard-content');
28 const titleEl = document.getElementById('contact-dashboard-title');
29 const actionsEl = document.getElementById('contact-dashboard-actions');
30
31 try {
32 const [contact, activity] = await Promise.all([
33 GoingsOn.api.contacts.get(contactId),
34 GoingsOn.api.contacts.getActivity(contactId, INITIAL_ACTIVITY_LIMIT),
35 ]);
36
37 titleEl.textContent = contact.displayName || contact.display_name;
38 render(content, actionsEl, contact, activity);
39 } catch (err) {
40 content.innerHTML = `<p class="text-danger">Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
41 }
42 }
43
44 function close() {
45 currentContactId = null;
46 GoingsOn.navigation.switchView('contacts');
47 }
48
49 /**
50 * Render one pre-computed activity row. All fields (kind, title, date,
51 * status, direction) are supplied by Rust — the JS only maps them to markup.
52 * @param {{kind: string, id: string, title: string, dateFormatted: string, status: (string|null), isOutgoing: boolean}} item
53 */
54 function renderTimelineItem(item) {
55 const icon = item.kind === 'task' ? '&#x2611;'
56 : item.kind === 'event' ? '&#x1F4C5;'
57 : (item.isOutgoing ? '&#x2709;&#xFE0E;&rarr;' : '&larr;&#x2709;&#xFE0E;');
58 const badge = item.kind === 'task' && item.status
59 ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>`
60 : '';
61
62 let act = '';
63 if (item.kind === 'task') act = 'taskOverview.open';
64 else if (item.kind === 'event') act = 'events.open';
65 else if (item.kind === 'email') act = 'emails.open';
66
67 return `
68 <div class="contact-timeline-item row-flex row-flex-3" ${act ? `data-act="${act}" data-a1="${escAttr(item.id)}"` : ''} role="button" tabindex="0">
69 <span class="contact-timeline-icon">${icon}</span>
70 <span class="contact-timeline-title">${esc(item.title)}</span>
71 ${badge}
72 <span class="contact-timeline-date">${esc(item.dateFormatted)}</span>
73 </div>
74 `;
75 }
76
77 function render(container, actionsEl, contact, activity) {
78 const name = contact.displayName || contact.display_name;
79 const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
80
81 // Actions bar
82 if (contact.isImplicit) {
83 actionsEl.innerHTML = `
84 <button class="btn btn-primary" data-act="contactDashboard.promote" data-a1="${escAttr(contact.id)}">Save as Contact</button>
85 `;
86 } else {
87 actionsEl.innerHTML = `
88 <button class="btn btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(contact.id)}">Edit</button>
89 <button class="btn btn-secondary text-accent-red" data-act="contacts.delete" data-a1="${escAttr(contact.id)}">Delete</button>
90 `;
91 }
92
93 // Header card
94 const company = contact.company ? `<span class="text-secondary">${esc(contact.company)}</span>` : '';
95 const title = contact.title ? `<span class="text-secondary">${esc(contact.title)}</span>` : '';
96 const companyTitle = [company, title].filter(Boolean).join(' &middot; ');
97
98 const tags = (contact.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join(' ');
99
100 let headerHtml = `
101 <div class="contact-header-card">
102 <div class="avatar avatar--lg">${esc(initials)}</div>
103 <div class="contact-header-info">
104 <h3 style="margin: 0;">${esc(name)}</h3>
105 ${companyTitle ? `<div style="margin-top: 0.25rem;">${companyTitle}</div>` : ''}
106 ${tags ? `<div style="margin-top: 0.5rem;">${tags}</div>` : ''}
107 </div>
108 </div>
109 `;
110
111 // Contact info (email addresses, phones, social handles)
112 let infoHtml = '';
113 const infoItems = [];
114 for (const e of contact.emails || []) {
115 infoItems.push(`<span class="text-secondary">Email:</span> ${esc(e.address)}${e.label ? ' <span class="tag">' + esc(e.label) + '</span>' : ''}`);
116 }
117 for (const p of contact.phones || []) {
118 infoItems.push(`<span class="text-secondary">Phone:</span> ${esc(p.number)}${p.label ? ' <span class="tag">' + esc(p.label) + '</span>' : ''}`);
119 }
120 for (const s of contact.socialHandles || contact.social_handles || []) {
121 infoItems.push(`<span class="text-secondary">${esc(s.platform)}:</span> ${esc(s.handle)}`);
122 }
123 if (infoItems.length > 0) {
124 infoHtml = `<div class="card card--muted contact-info-section">${infoItems.map(i => `<div class="contact-info-item">${i}</div>`).join('')}</div>`;
125 }
126
127 // Activity timeline — Rust already merged, sorted (newest-first), capped,
128 // and pre-formatted every row. JS just maps items to markup.
129 const timelineItems = activity.items || [];
130
131 const totalActivity = (activity.taskCount || 0) + (activity.eventCount || 0) + (activity.emailCount || 0);
132
133 let timelineHtml = '';
134 if (timelineItems.length === 0) {
135 timelineHtml = '<p class="empty-italic">No interactions yet.</p>';
136 } else {
137 const items = timelineItems.map(renderTimelineItem).join('');
138 // Rust caps the initial feed; offer to load the rest without a second round of client math.
139 const showAll = totalActivity > timelineItems.length
140 ? `<button class="btn btn-link" data-act="contactDashboard.showAllActivity">Show all ${totalActivity} interactions</button>`
141 : '';
142 timelineHtml = `<div class="contact-timeline" id="contact-timeline">${items}</div>${showAll}`;
143 }
144
145 // Notes section
146 const notesHtml = contact.notes
147 ? `<div class="settings-section"><h3 class="settings-heading">Notes</h3><p style="white-space: pre-wrap;">${esc(contact.notes)}</p></div>`
148 : '';
149
150 // Linked entities summary — totals from Rust (may exceed the capped feed)
151 const taskCount = activity.taskCount;
152 const eventCount = activity.eventCount;
153 const emailCount = activity.emailCount;
154
155 const summaryHtml = `
156 <div class="contact-dashboard-summary">
157 <div class="contact-summary-stat">
158 <span class="contact-summary-count">${taskCount}</span>
159 <span class="contact-summary-label">Tasks</span>
160 </div>
161 <div class="contact-summary-stat">
162 <span class="contact-summary-count">${eventCount}</span>
163 <span class="contact-summary-label">Events</span>
164 </div>
165 <div class="contact-summary-stat">
166 <span class="contact-summary-count">${emailCount}</span>
167 <span class="contact-summary-label">Emails</span>
168 </div>
169 </div>
170 `;
171
172 container.innerHTML = headerHtml + infoHtml + summaryHtml + `
173 <div class="settings-section">
174 <h3 class="settings-heading">Activity</h3>
175 ${timelineHtml}
176 </div>
177 ` + notesHtml;
178 }
179
180 async function promote(contactId) {
181 try {
182 await GoingsOn.api.contacts.promoteContact(contactId);
183 GoingsOn.cache.invalidate('contacts');
184 GoingsOn.autocomplete.refresh();
185 GoingsOn.ui.showToast('Contact saved!', 'success');
186 open(contactId); // re-render
187 } catch (err) {
188 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
189 }
190 }
191
192 /**
193 * Load and render the full activity feed (no server-side cap), replacing the
194 * capped list and the "Show all" button in place. Rust still merges/sorts/formats.
195 */
196 async function showAllActivity() {
197 if (!currentContactId) return;
198 const timeline = document.getElementById('contact-timeline');
199 if (!timeline) return;
200 try {
201 const activity = await GoingsOn.api.contacts.getActivity(currentContactId);
202 const items = (activity.items || []).map(renderTimelineItem).join('');
203 timeline.innerHTML = items;
204 const btn = timeline.nextElementSibling;
205 if (btn && btn.tagName === 'BUTTON') btn.remove();
206 } catch (err) {
207 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load activity'), 'error');
208 }
209 }
210
211 GoingsOn.contactDashboard = { open, close, promote, showAllActivity };
212 })();
213