Skip to main content

max / goingson

Frontend cold spots R2: contact activity feed to Rust - get_contact_activity command + merge_activity core helper: merges the contact's tasks/events/emails, sorts newest-first, pre-formats dates server-side - optional limit param (None = full feed); dashboard loads 20 with a Show-all expander that refetches uncapped (preserves the old expand-to-all UX) - contact-dashboard.js drops the 3-list merge, new Date parsing, and client-side sort - +3 core tests (merge ordering, cap, empty) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-04 18:35 UTC
Commit: 797a0452c644224853dbbd57fd854695bd11ebc8
Parent: c173daa
6 files changed, +265 insertions, -109 deletions
@@ -198,6 +198,51 @@ pub struct NewContactCustomField {
198 198 pub url: Option<String>,
199 199 }
200 200
201 + // ============ Activity Feed ============
202 +
203 + /// Which kind of entity an activity item represents.
204 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205 + #[serde(rename_all = "lowercase")]
206 + pub enum ActivityKind {
207 + Task,
208 + Event,
209 + Email,
210 + }
211 +
212 + /// A single entry in a contact's unified activity timeline.
213 + ///
214 + /// Carries fully pre-computed display fields so the frontend renders it
215 + /// directly: no date parsing, sorting, or merging on the JS side. Tasks,
216 + /// events, and emails all collapse into this one shape.
217 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
218 + #[serde(rename_all = "camelCase")]
219 + pub struct ActivityItem {
220 + /// Entity kind, drives icon and click-through route on the frontend.
221 + pub kind: ActivityKind,
222 + /// Target entity id (as a string) for click-through navigation.
223 + pub id: String,
224 + /// Row title (task description, event title, or email subject).
225 + pub title: String,
226 + /// Sort key / ISO timestamp: task created_at, event start_time, email received_at.
227 + pub timestamp: DateTime<Utc>,
228 + /// Pre-formatted local date for display (e.g. "Jul 4, 2026").
229 + pub date_formatted: String,
230 + /// Task status badge label, if this item is a task.
231 + pub status: Option<String>,
232 + /// Whether an email item was outgoing (drives the direction icon).
233 + pub is_outgoing: bool,
234 + }
235 +
236 + /// Merges activity items into a single feed sorted newest-first and capped.
237 + ///
238 + /// Sources are already-built [`ActivityItem`]s (from tasks, events, emails);
239 + /// this only orders them by `timestamp` descending and truncates to `cap`.
240 + pub fn merge_activity(mut items: Vec<ActivityItem>, cap: usize) -> Vec<ActivityItem> {
241 + items.sort_by_key(|i| std::cmp::Reverse(i.timestamp));
242 + items.truncate(cap);
243 + items
244 + }
245 +
201 246 #[cfg(test)]
202 247 mod tests {
203 248 use super::*;
@@ -285,4 +330,45 @@ mod tests {
285 330 c.company = Some(String::new());
286 331 assert!(!c.has_company());
287 332 }
333 +
334 + fn activity(kind: ActivityKind, secs: i64) -> ActivityItem {
335 + ActivityItem {
336 + kind,
337 + id: format!("{secs}"),
338 + title: format!("item {secs}"),
339 + timestamp: DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp"),
340 + date_formatted: String::new(),
341 + status: None,
342 + is_outgoing: false,
343 + }
344 + }
345 +
346 + #[test]
347 + fn merge_activity_empty_is_empty() {
348 + assert!(merge_activity(vec![], 20).is_empty());
349 + }
350 +
351 + #[test]
352 + fn merge_activity_sorts_newest_first_across_sources() {
353 + let items = vec![
354 + activity(ActivityKind::Task, 100),
355 + activity(ActivityKind::Email, 300),
356 + activity(ActivityKind::Event, 200),
357 + ];
358 + let merged = merge_activity(items, 20);
359 + let order: Vec<i64> = merged.iter().map(|i| i.timestamp.timestamp()).collect();
360 + assert_eq!(order, vec![300, 200, 100]);
361 + assert_eq!(merged[0].kind, ActivityKind::Email);
362 + assert_eq!(merged[2].kind, ActivityKind::Task);
363 + }
364 +
365 + #[test]
366 + fn merge_activity_respects_cap() {
367 + let items = (0..50).map(|s| activity(ActivityKind::Task, s)).collect();
368 + let merged = merge_activity(items, 20);
369 + assert_eq!(merged.len(), 20);
370 + // Newest-first: the highest timestamps survive the cap.
371 + assert_eq!(merged.first().unwrap().timestamp.timestamp(), 49);
372 + assert_eq!(merged.last().unwrap().timestamp.timestamp(), 30);
373 + }
288 374 }
@@ -53,8 +53,9 @@ pub mod validation;
53 53 pub mod weekly_review;
54 54
55 55 pub use contact::{
56 - Contact, ContactCustomField, ContactEmail, ContactEmailEntry, ContactPhone, NewContact, NewContactCustomField,
57 - NewContactEmail, NewContactPhone, NewSocialHandle, SocialHandle, UpdateContact,
56 + ActivityItem, ActivityKind, Contact, ContactCustomField, ContactEmail, ContactEmailEntry,
57 + ContactPhone, NewContact, NewContactCustomField, NewContactEmail, NewContactPhone,
58 + NewSocialHandle, SocialHandle, UpdateContact, merge_activity,
58 59 };
59 60 pub use error::CoreError;
60 61 pub use id_types::{
@@ -202,6 +202,7 @@ const api = {
202 202 listTasksForContact: (contactId) => invoke('list_tasks_for_contact', { contactId }),
203 203 listEventsForContact: (contactId) => invoke('list_events_for_contact', { contactId }),
204 204 listEmailsForContact: (contactId) => invoke('list_emails_for_contact', { contactId }),
205 + getActivity: (contactId, limit) => invoke('get_contact_activity', { contactId, limit }), // Pre-merged, pre-sorted, pre-formatted timeline + counts (Rust does the math). Omit limit to fetch the full feed.
205 206 listFiltered: (search, tag, includeImplicit) => invoke('list_contacts_filtered', { search: search || null, tag: tag || null, includeImplicit: includeImplicit ?? false }),
206 207 },
207 208
@@ -12,6 +12,9 @@
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,15 +28,13 @@
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,7 +45,35 @@
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' ? '&#x2611;'
55 + : item.kind === 'event' ? '&#x1F4C5;'
56 + : (item.isOutgoing ? '&#x2709;&#xFE0E;&rarr;' : '&larr;&#x2709;&#xFE0E;');
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,73 +123,22 @@
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' ? '&#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>`
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,10 +146,10 @@
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,40 +174,6 @@
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' ? '&#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 177 }
234 178
235 179 async function promote(contactId) {
@@ -244,5 +188,24 @@
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 })();
@@ -3,7 +3,7 @@
3 3 //! Provides CRUD operations for contacts and their sub-collections
4 4 //! (emails, phones, social handles).
5 5
6 - use chrono::NaiveDate;
6 + use chrono::{DateTime, Local, NaiveDate, Utc};
7 7 use serde::{Deserialize, Serialize};
8 8 use std::sync::Arc;
9 9 use tauri::State;
@@ -548,3 +548,107 @@ pub async fn list_emails_for_contact(
548 548 let emails = state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?;
549 549 Ok(emails.into_iter().map(super::EmailResponse::from).collect())
550 550 }
551 +
552 + /// Unified, pre-sorted, pre-formatted activity feed for a contact.
553 + #[derive(Debug, Serialize)]
554 + #[serde(rename_all = "camelCase")]
555 + pub struct ContactActivityResponse {
556 + /// Newest-first, capped merge of the contact's tasks, events, and emails.
557 + pub items: Vec<goingson_core::ActivityItem>,
558 + /// Total linked task count (may exceed the number of task rows in `items`).
559 + pub task_count: usize,
560 + /// Total linked event count.
561 + pub event_count: usize,
562 + /// Total linked email count.
563 + pub email_count: usize,
564 + }
565 +
566 + /// Builds the unified activity timeline (tasks + events + emails) for a contact.
567 + ///
568 + /// Merges the same three sources the dashboard previously stitched together in
569 + /// JS, sorts newest-first, pre-formats each row's display date in local time,
570 + /// and caps the feed server-side so the frontend renders it verbatim.
571 + #[tauri::command]
572 + #[instrument(skip_all)]
573 + pub async fn get_contact_activity(
574 + state: State<'_, Arc<AppState>>,
575 + contact_id: ContactId,
576 + limit: Option<u32>,
577 + ) -> Result<ContactActivityResponse, ApiError> {
578 + use goingson_core::{ActivityItem, ActivityKind, merge_activity};
579 +
580 + let contact = state.contacts.get_by_id(contact_id, DESKTOP_USER_ID).await?
581 + .or_not_found("contact", contact_id)?;
582 +
583 + let tasks = state.tasks.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
584 + let events = state.events.list_by_contact(DESKTOP_USER_ID, contact_id).await?;
585 +
586 + let addresses: Vec<String> = contact.emails.iter().map(|e| e.address.clone()).collect();
587 + let emails = if addresses.is_empty() {
588 + Vec::new()
589 + } else {
590 + let addr_refs: Vec<&str> = addresses.iter().map(|s| s.as_str()).collect();
591 + state.emails.list_by_addresses(DESKTOP_USER_ID, &addr_refs).await?
592 + };
593 +
594 + let task_count = tasks.len();
595 + let event_count = events.len();
596 + let email_count = emails.len();
597 +
598 + // Pre-format the display date in local time (matches the old toLocaleDateString).
599 + fn fmt_date(ts: DateTime<Utc>) -> String {
600 + ts.with_timezone(&Local).format("%b %-d, %Y").to_string()
601 + }
602 +
603 + let mut items: Vec<ActivityItem> =
604 + Vec::with_capacity(task_count + event_count + email_count);
605 +
606 + for t in tasks {
607 + items.push(ActivityItem {
608 + kind: ActivityKind::Task,
609 + id: t.id.to_string(),
610 + title: t.description,
611 + date_formatted: fmt_date(t.created_at),
612 + timestamp: t.created_at,
613 + status: Some(t.status.as_str().to_string()),
614 + is_outgoing: false,
615 + });
616 + }
617 +
618 + for e in events {
619 + items.push(ActivityItem {
620 + kind: ActivityKind::Event,
621 + id: e.id.to_string(),
622 + title: e.title,
623 + date_formatted: fmt_date(e.start_time),
624 + timestamp: e.start_time,
625 + status: None,
626 + is_outgoing: false,
627 + });
628 + }
629 +
630 + for e in emails {
631 + let title = if e.subject.trim().is_empty() {
632 + "(no subject)".to_string()
633 + } else {
634 + e.subject
635 + };
636 + items.push(ActivityItem {
637 + kind: ActivityKind::Email,
638 + id: e.id.to_string(),
639 + title,
640 + date_formatted: fmt_date(e.received_at),
641 + timestamp: e.received_at,
642 + status: None,
643 + is_outgoing: e.is_outgoing,
644 + });
645 + }
646 +
647 + let cap = limit.map(|n| n as usize).unwrap_or(usize::MAX);
648 + Ok(ContactActivityResponse {
649 + items: merge_activity(items, cap),
650 + task_count,
651 + event_count,
652 + email_count,
653 + })
654 + }
@@ -162,6 +162,7 @@ macro_rules! all_commands {
162 162 $crate::commands::list_tasks_for_contact,
163 163 $crate::commands::list_events_for_contact,
164 164 $crate::commands::list_emails_for_contact,
165 + $crate::commands::get_contact_activity,
165 166 $crate::commands::list_contacts_filtered,
166 167 $crate::commands::list_contact_email_directory,
167 168 $crate::commands::bulk_delete_contacts,