Skip to main content

max / goingson

11.1 KB · 249 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.escapeAttr;
12
13 let currentContactId = null;
14
15 /**
16 * Open the contact dashboard for a given contact.
17 * @param {string} contactId
18 */
19 async function open(contactId) {
20 currentContactId = contactId;
21 GoingsOn.navigation.switchView('contact-dashboard');
22
23 const content = document.getElementById('contact-dashboard-content');
24 const titleEl = document.getElementById('contact-dashboard-title');
25 const actionsEl = document.getElementById('contact-dashboard-actions');
26
27 try {
28 const [contact, tasks, events, emails] = await Promise.all([
29 GoingsOn.api.contacts.get(contactId),
30 GoingsOn.api.contacts.listTasksForContact(contactId),
31 GoingsOn.api.contacts.listEventsForContact(contactId),
32 GoingsOn.api.contacts.listEmailsForContact(contactId),
33 ]);
34
35 titleEl.textContent = contact.displayName || contact.display_name;
36 render(content, actionsEl, contact, tasks, events, emails);
37 } catch (err) {
38 content.innerHTML = `<p class="text-danger">Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
39 }
40 }
41
42 function close() {
43 currentContactId = null;
44 GoingsOn.navigation.switchView('contacts');
45 }
46
47 function render(container, actionsEl, contact, tasks, events, emails) {
48 const name = contact.displayName || contact.display_name;
49 const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
50
51 // Actions bar
52 if (contact.isImplicit) {
53 actionsEl.innerHTML = `
54 <button class="btn btn-primary" onclick="GoingsOn.contactDashboard.promote('${escAttr(contact.id)}')">Save as Contact</button>
55 `;
56 } else {
57 actionsEl.innerHTML = `
58 <button class="btn btn-secondary" onclick="GoingsOn.contacts.openEdit('${escAttr(contact.id)}')">Edit</button>
59 <button class="btn btn-secondary text-accent-red" onclick="GoingsOn.contacts.delete('${escAttr(contact.id)}')">Delete</button>
60 `;
61 }
62
63 // Header card
64 const company = contact.company ? `<span class="text-secondary">${esc(contact.company)}</span>` : '';
65 const title = contact.title ? `<span class="text-secondary">${esc(contact.title)}</span>` : '';
66 const companyTitle = [company, title].filter(Boolean).join(' &middot; ');
67
68 const tags = (contact.tags || []).map(t => `<span class="tag">${esc(t)}</span>`).join(' ');
69
70 let headerHtml = `
71 <div class="contact-header-card">
72 <div class="avatar avatar--lg">${esc(initials)}</div>
73 <div class="contact-header-info">
74 <h3 style="margin: 0;">${esc(name)}</h3>
75 ${companyTitle ? `<div style="margin-top: 0.25rem;">${companyTitle}</div>` : ''}
76 ${tags ? `<div style="margin-top: 0.5rem;">${tags}</div>` : ''}
77 </div>
78 </div>
79 `;
80
81 // Contact info (email addresses, phones, social handles)
82 let infoHtml = '';
83 const infoItems = [];
84 for (const e of contact.emails || []) {
85 infoItems.push(`<span class="text-secondary">Email:</span> ${esc(e.address)}${e.label ? ' <span class="tag">' + esc(e.label) + '</span>' : ''}`);
86 }
87 for (const p of contact.phones || []) {
88 infoItems.push(`<span class="text-secondary">Phone:</span> ${esc(p.number)}${p.label ? ' <span class="tag">' + esc(p.label) + '</span>' : ''}`);
89 }
90 for (const s of contact.socialHandles || contact.social_handles || []) {
91 infoItems.push(`<span class="text-secondary">${esc(s.platform)}:</span> ${esc(s.handle)}`);
92 }
93 if (infoItems.length > 0) {
94 infoHtml = `<div class="card card--muted contact-info-section">${infoItems.map(i => `<div class="contact-info-item">${i}</div>`).join('')}</div>`;
95 }
96
97 // Activity timeline — merge all entities, sort by date desc
98 const timeline = [];
99 for (const t of tasks) {
100 timeline.push({
101 type: 'task',
102 date: new Date(t.createdAt || t.created_at),
103 title: t.description,
104 status: t.statusDisplay || t.status,
105 id: t.id,
106 });
107 }
108 for (const e of events) {
109 timeline.push({
110 type: 'event',
111 date: new Date(e.startTime || e.start_time),
112 title: e.displayTitle || e.title,
113 id: e.id,
114 });
115 }
116 for (const e of emails) {
117 timeline.push({
118 type: 'email',
119 date: new Date(e.receivedAt || e.received_at),
120 title: e.subject || '(no subject)',
121 from: e.from,
122 isOutgoing: e.isOutgoing || e.is_outgoing || false,
123 id: e.id,
124 });
125 }
126 timeline.sort((a, b) => b.date - a.date);
127
128 const INITIAL_SHOW = 20;
129 const totalCount = timeline.length;
130 const visibleTimeline = timeline.slice(0, INITIAL_SHOW);
131
132 let timelineHtml = '';
133 if (totalCount === 0) {
134 timelineHtml = '<p class="empty-italic">No interactions yet.</p>';
135 } else {
136 const items = visibleTimeline.map(item => {
137 const dateStr = item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
138 const icon = item.type === 'task' ? '&#x2611;' : item.type === 'event' ? '&#x1F4C5;' : (item.isOutgoing ? '&#x2709;&#xFE0E;&rarr;' : '&larr;&#x2709;&#xFE0E;');
139 const badge = item.type === 'task' && item.status ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>` : '';
140
141 let onclick = '';
142 if (item.type === 'task') onclick = `GoingsOn.taskOverview.open('${escAttr(item.id)}')`;
143 else if (item.type === 'event') onclick = `GoingsOn.events.open('${escAttr(item.id)}')`;
144 else if (item.type === 'email') onclick = `GoingsOn.emails.open('${escAttr(item.id)}')`;
145
146 return `
147 <div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0">
148 <span class="contact-timeline-icon">${icon}</span>
149 <span class="contact-timeline-title">${esc(item.title)}</span>
150 ${badge}
151 <span class="contact-timeline-date">${dateStr}</span>
152 </div>
153 `;
154 }).join('');
155
156 const showAllBtn = totalCount > INITIAL_SHOW
157 ? `<button class="btn btn-secondary btn-sm" id="contact-timeline-show-all" onclick="GoingsOn.contactDashboard.showAllTimeline()">Show all ${totalCount} items</button>`
158 : '';
159
160 timelineHtml = `
161 <div class="contact-timeline" id="contact-timeline">${items}</div>
162 ${showAllBtn}
163 `;
164 }
165
166 // Notes section
167 const notesHtml = contact.notes
168 ? `<div class="settings-section"><h3 class="settings-heading">Notes</h3><p style="white-space: pre-wrap;">${esc(contact.notes)}</p></div>`
169 : '';
170
171 // Linked entities summary
172 const taskCount = tasks.length;
173 const eventCount = events.length;
174 const emailCount = emails.length;
175
176 const summaryHtml = `
177 <div class="contact-dashboard-summary">
178 <div class="contact-summary-stat">
179 <span class="contact-summary-count">${taskCount}</span>
180 <span class="contact-summary-label">Tasks</span>
181 </div>
182 <div class="contact-summary-stat">
183 <span class="contact-summary-count">${eventCount}</span>
184 <span class="contact-summary-label">Events</span>
185 </div>
186 <div class="contact-summary-stat">
187 <span class="contact-summary-count">${emailCount}</span>
188 <span class="contact-summary-label">Emails</span>
189 </div>
190 </div>
191 `;
192
193 container.innerHTML = headerHtml + infoHtml + summaryHtml + `
194 <div class="settings-section">
195 <h3 class="settings-heading">Activity</h3>
196 ${timelineHtml}
197 </div>
198 ` + notesHtml;
199
200 // Store full timeline for "show all"
201 container._fullTimeline = timeline;
202 }
203
204 function showAllTimeline() {
205 const container = document.getElementById('contact-dashboard-content');
206 const timeline = container?._fullTimeline;
207 if (!timeline) return;
208
209 const timelineEl = document.getElementById('contact-timeline');
210 const showAllBtn = document.getElementById('contact-timeline-show-all');
211 if (showAllBtn) showAllBtn.remove();
212
213 // Re-render full timeline
214 timelineEl.innerHTML = timeline.map(item => {
215 const dateStr = item.date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
216 const icon = item.type === 'task' ? '&#x2611;' : item.type === 'event' ? '&#x1F4C5;' : (item.isOutgoing ? '&#x2709;&#xFE0E;&rarr;' : '&larr;&#x2709;&#xFE0E;');
217 const badge = item.type === 'task' && item.status ? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>` : '';
218
219 let onclick = '';
220 if (item.type === 'task') onclick = `GoingsOn.taskOverview.open('${escAttr(item.id)}')`;
221 else if (item.type === 'event') onclick = `GoingsOn.events.open('${escAttr(item.id)}')`;
222 else if (item.type === 'email') onclick = `GoingsOn.emails.open('${escAttr(item.id)}')`;
223
224 return `
225 <div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0">
226 <span class="contact-timeline-icon">${icon}</span>
227 <span class="contact-timeline-title">${esc(item.title)}</span>
228 ${badge}
229 <span class="contact-timeline-date">${dateStr}</span>
230 </div>
231 `;
232 }).join('');
233 }
234
235 async function promote(contactId) {
236 try {
237 await GoingsOn.api.contacts.promoteContact(contactId);
238 GoingsOn.cache.invalidate('contacts');
239 GoingsOn.autocomplete.refresh();
240 GoingsOn.ui.showToast('Contact saved!', 'success');
241 open(contactId); // re-render
242 } catch (err) {
243 GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to save contact'), 'error');
244 }
245 }
246
247 GoingsOn.contactDashboard = { open, close, promote, showAllTimeline };
248 })();
249