| 12 |
12 |
|
|
| 13 |
13 |
|
let currentContactId = null;
|
| 14 |
14 |
|
|
|
15 |
+ |
// How many activity rows the dashboard shows before the user expands "Show all".
|
|
16 |
+ |
const INITIAL_ACTIVITY_LIMIT = 20;
|
|
17 |
+ |
|
| 15 |
18 |
|
/**
|
| 16 |
19 |
|
* Open the contact dashboard for a given contact.
|
| 17 |
20 |
|
* @param {string} contactId
|
| 25 |
28 |
|
const actionsEl = document.getElementById('contact-dashboard-actions');
|
| 26 |
29 |
|
|
| 27 |
30 |
|
try {
|
| 28 |
|
- |
const [contact, tasks, events, emails] = await Promise.all([
|
|
31 |
+ |
const [contact, activity] = await Promise.all([
|
| 29 |
32 |
|
GoingsOn.api.contacts.get(contactId),
|
| 30 |
|
- |
GoingsOn.api.contacts.listTasksForContact(contactId),
|
| 31 |
|
- |
GoingsOn.api.contacts.listEventsForContact(contactId),
|
| 32 |
|
- |
GoingsOn.api.contacts.listEmailsForContact(contactId),
|
|
33 |
+ |
GoingsOn.api.contacts.getActivity(contactId, INITIAL_ACTIVITY_LIMIT),
|
| 33 |
34 |
|
]);
|
| 34 |
35 |
|
|
| 35 |
36 |
|
titleEl.textContent = contact.displayName || contact.display_name;
|
| 36 |
|
- |
render(content, actionsEl, contact, tasks, events, emails);
|
|
37 |
+ |
render(content, actionsEl, contact, activity);
|
| 37 |
38 |
|
} catch (err) {
|
| 38 |
39 |
|
content.innerHTML = `<p class="text-danger">Failed to load contact: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
|
| 39 |
40 |
|
}
|
| 44 |
45 |
|
GoingsOn.navigation.switchView('contacts');
|
| 45 |
46 |
|
}
|
| 46 |
47 |
|
|
| 47 |
|
- |
function render(container, actionsEl, contact, tasks, events, emails) {
|
|
48 |
+ |
/**
|
|
49 |
+ |
* Render one pre-computed activity row. All fields (kind, title, date,
|
|
50 |
+ |
* status, direction) are supplied by Rust — the JS only maps them to markup.
|
|
51 |
+ |
* @param {{kind: string, id: string, title: string, dateFormatted: string, status: (string|null), isOutgoing: boolean}} item
|
|
52 |
+ |
*/
|
|
53 |
+ |
function renderTimelineItem(item) {
|
|
54 |
+ |
const icon = item.kind === 'task' ? '☑'
|
|
55 |
+ |
: item.kind === 'event' ? '📅'
|
|
56 |
+ |
: (item.isOutgoing ? '✉︎→' : '←✉︎');
|
|
57 |
+ |
const badge = item.kind === 'task' && item.status
|
|
58 |
+ |
? `<span class="tag status-${(item.status || '').toLowerCase()}">${esc(item.status)}</span>`
|
|
59 |
+ |
: '';
|
|
60 |
+ |
|
|
61 |
+ |
let onclick = '';
|
|
62 |
+ |
if (item.kind === 'task') onclick = `GoingsOn.taskOverview.open('${escAttr(item.id)}')`;
|
|
63 |
+ |
else if (item.kind === 'event') onclick = `GoingsOn.events.open('${escAttr(item.id)}')`;
|
|
64 |
+ |
else if (item.kind === 'email') onclick = `GoingsOn.emails.open('${escAttr(item.id)}')`;
|
|
65 |
+ |
|
|
66 |
+ |
return `
|
|
67 |
+ |
<div class="contact-timeline-item row-flex row-flex-3" onclick="${onclick}" role="button" tabindex="0">
|
|
68 |
+ |
<span class="contact-timeline-icon">${icon}</span>
|
|
69 |
+ |
<span class="contact-timeline-title">${esc(item.title)}</span>
|
|
70 |
+ |
${badge}
|
|
71 |
+ |
<span class="contact-timeline-date">${esc(item.dateFormatted)}</span>
|
|
72 |
+ |
</div>
|
|
73 |
+ |
`;
|
|
74 |
+ |
}
|
|
75 |
+ |
|
|
76 |
+ |
function render(container, actionsEl, contact, activity) {
|
| 48 |
77 |
|
const name = contact.displayName || contact.display_name;
|
| 49 |
78 |
|
const initials = contact.initials || name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
| 50 |
79 |
|
|
| 94 |
123 |
|
infoHtml = `<div class="card card--muted contact-info-section">${infoItems.map(i => `<div class="contact-info-item">${i}</div>`).join('')}</div>`;
|
| 95 |
124 |
|
}
|
| 96 |
125 |
|
|
| 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);
|
|
126 |
+ |
// Activity timeline — Rust already merged, sorted (newest-first), capped,
|
|
127 |
+ |
// and pre-formatted every row. JS just maps items to markup.
|
|
128 |
+ |
const timelineItems = activity.items || [];
|
| 127 |
129 |
|
|
| 128 |
|
- |
const INITIAL_SHOW = 20;
|
| 129 |
|
- |
const totalCount = timeline.length;
|
| 130 |
|
- |
const visibleTimeline = timeline.slice(0, INITIAL_SHOW);
|
|
130 |
+ |
const totalActivity = (activity.taskCount || 0) + (activity.eventCount || 0) + (activity.emailCount || 0);
|
| 131 |
131 |
|
|
| 132 |
132 |
|
let timelineHtml = '';
|
| 133 |
|
- |
if (totalCount === 0) {
|
|
133 |
+ |
if (timelineItems.length === 0) {
|
| 134 |
134 |
|
timelineHtml = '<p class="empty-italic">No interactions yet.</p>';
|
| 135 |
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' ? '☑' : item.type === 'event' ? '📅' : (item.isOutgoing ? '✉︎→' : '←✉︎');
|
| 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>`
|
|
136 |
+ |
const items = timelineItems.map(renderTimelineItem).join('');
|
|
137 |
+ |
// Rust caps the initial feed; offer to load the rest without a second round of client math.
|
|
138 |
+ |
const showAll = totalActivity > timelineItems.length
|
|
139 |
+ |
? `<button class="btn btn-link" onclick="GoingsOn.contactDashboard.showAllActivity()">Show all ${totalActivity} interactions</button>`
|
| 158 |
140 |
|
: '';
|
| 159 |
|
- |
|
| 160 |
|
- |
timelineHtml = `
|
| 161 |
|
- |
<div class="contact-timeline" id="contact-timeline">${items}</div>
|
| 162 |
|
- |
${showAllBtn}
|
| 163 |
|
- |
`;
|
|
141 |
+ |
timelineHtml = `<div class="contact-timeline" id="contact-timeline">${items}</div>${showAll}`;
|
| 164 |
142 |
|
}
|
| 165 |
143 |
|
|
| 166 |
144 |
|
// Notes section
|
| 168 |
146 |
|
? `<div class="settings-section"><h3 class="settings-heading">Notes</h3><p style="white-space: pre-wrap;">${esc(contact.notes)}</p></div>`
|
| 169 |
147 |
|
: '';
|
| 170 |
148 |
|
|
| 171 |
|
- |
// Linked entities summary
|
| 172 |
|
- |
const taskCount = tasks.length;
|
| 173 |
|
- |
const eventCount = events.length;
|
| 174 |
|
- |
const emailCount = emails.length;
|
|
149 |
+ |
// Linked entities summary — totals from Rust (may exceed the capped feed)
|
|
150 |
+ |
const taskCount = activity.taskCount;
|
|
151 |
+ |
const eventCount = activity.eventCount;
|
|
152 |
+ |
const emailCount = activity.emailCount;
|
| 175 |
153 |
|
|
| 176 |
154 |
|
const summaryHtml = `
|
| 177 |
155 |
|
<div class="contact-dashboard-summary">
|
| 196 |
174 |
|
${timelineHtml}
|
| 197 |
175 |
|
</div>
|
| 198 |
176 |
|
` + 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' ? '☑' : item.type === 'event' ? '📅' : (item.isOutgoing ? '✉︎→' : '←✉︎');
|
| 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 |
177 |
|
}
|
| 234 |
178 |
|
|
| 235 |
179 |
|
async function promote(contactId) {
|
| 244 |
188 |
|
}
|
| 245 |
189 |
|
}
|
| 246 |
190 |
|
|
| 247 |
|
- |
GoingsOn.contactDashboard = { open, close, promote, showAllTimeline };
|
|
191 |
+ |
/**
|
|
192 |
+ |
* Load and render the full activity feed (no server-side cap), replacing the
|
|
193 |
+ |
* capped list and the "Show all" button in place. Rust still merges/sorts/formats.
|
|
194 |
+ |
*/
|
|
195 |
+ |
async function showAllActivity() {
|
|
196 |
+ |
if (!currentContactId) return;
|
|
197 |
+ |
const timeline = document.getElementById('contact-timeline');
|
|
198 |
+ |
if (!timeline) return;
|
|
199 |
+ |
try {
|
|
200 |
+ |
const activity = await GoingsOn.api.contacts.getActivity(currentContactId);
|
|
201 |
+ |
const items = (activity.items || []).map(renderTimelineItem).join('');
|
|
202 |
+ |
timeline.innerHTML = items;
|
|
203 |
+ |
const btn = timeline.nextElementSibling;
|
|
204 |
+ |
if (btn && btn.tagName === 'BUTTON') btn.remove();
|
|
205 |
+ |
} catch (err) {
|
|
206 |
+ |
GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to load activity'), 'error');
|
|
207 |
+ |
}
|
|
208 |
+ |
}
|
|
209 |
+ |
|
|
210 |
+ |
GoingsOn.contactDashboard = { open, close, promote, showAllActivity };
|
| 248 |
211 |
|
})();
|