Build a Sales Activity Feed App

Build a real-time sales activity feed app in DenchClaw that shows every CRM action — new leads, stage changes, deals closed, and tasks completed — as a live stream.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Sales Activity Feed App

Visibility matters in sales. When your team doesn't know what's happening in the pipeline, things fall through cracks. A sales activity feed — a live stream of every CRM action — gives everyone on the team real-time awareness: who just added a new lead, which deal moved to negotiation, who closed.

This guide builds a real-time activity feed as a Dench App, using DenchClaw's dench.events API to stream CRM changes as they happen.

What You're Building#

  • A real-time activity feed showing CRM events as they occur
  • Event categories: new entries, updates, stage changes, deals closed
  • Icon and color coding per event type
  • Expandable event cards with change details
  • Search and filter by user, object type, or date range

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/activity-feed.dench.app

.dench.yaml:

name: Activity Feed
description: Real-time stream of all CRM activity
icon: activity
version: 1.0.0
permissions:
  - read:crm
display: tab

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Activity Feed</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; display: flex; flex-direction: column; height: 100vh; }
    .topbar { padding: 14px 20px; background: #1e293b; border-bottom: 1px solid #334155; display: flex; gap: 12px; align-items: center; }
    .topbar h2 { margin: 0; font-size: 15px; flex: 1; }
    .live-dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; animation: pulse-dot 2s infinite; }
    @keyframes pulse-dot { 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 #10b98140; } 50% { opacity: 0.7; box-shadow: 0 0 0 5px transparent; } }
    .filters { padding: 10px 20px; background: #0a0f1e; border-bottom: 1px solid #1e293b; display: flex; gap: 10px; }
    .filters input, .filters select { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 6px 12px; border-radius: 8px; font-size: 12px; }
    .feed { flex: 1; overflow-y: auto; padding: 16px 20px; }
    .event { display: flex; gap: 12px; align-items: flex-start; margin-bottom: 12px; animation: slideIn 0.3s ease; }
    @keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
    .event-icon { width: 34px; height: 34px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 16px; flex-shrink: 0; }
    .event-body { flex: 1; background: #1e293b; border-radius: 10px; padding: 10px 14px; }
    .event-title { font-size: 13px; font-weight: 600; margin-bottom: 3px; }
    .event-detail { font-size: 12px; color: #94a3b8; line-height: 1.4; }
    .event-time { font-size: 11px; color: #475569; margin-top: 4px; }
    .event-tag { display: inline-block; padding: 1px 7px; border-radius: 999px; font-size: 10px; margin-right: 4px; }
    .separator { text-align: center; font-size: 11px; color: #475569; margin: 16px 0; display: flex; align-items: center; gap: 10px; }
    .separator::before, .separator::after { content: ''; flex: 1; height: 1px; background: #1e293b; }
    .pause-btn { padding: 6px 14px; border: 1px solid #334155; background: transparent; color: #64748b; border-radius: 8px; cursor: pointer; font-size: 12px; }
    .pause-btn.paused { background: #ef444420; border-color: #ef4444; color: #ef4444; }
  </style>
</head>
<body>
  <div class="topbar">
    <div class="live-dot" id="liveDot"></div>
    <h2>Activity Feed</h2>
    <button class="pause-btn" id="pauseBtn" onclick="togglePause()">Pause</button>
    <span id="eventCount" style="font-size:12px;color:#64748b">0 events</span>
  </div>
  <div class="filters">
    <input type="text" id="searchFilter" placeholder="Search..." oninput="applyFilters()">
    <select id="typeFilter" onchange="applyFilters()">
      <option value="">All types</option>
      <option value="deal">Deals</option>
      <option value="lead">Leads</option>
      <option value="contact">Contacts</option>
    </select>
    <select id="actionFilter" onchange="applyFilters()">
      <option value="">All actions</option>
      <option value="created">Created</option>
      <option value="updated">Updated</option>
      <option value="stage_changed">Stage changed</option>
      <option value="closed">Closed</option>
    </select>
  </div>
  <div class="feed" id="feed"></div>
  <script src="feed.js"></script>
</body>
</html>

Talk to Sales

Tell us about your team and we'll show you how Dench fits your workflow.

By submitting this form you agree to be contacted by our team. See our Privacy Policy and Terms of Service.

Step 3: Activity Feed Logic#

feed.js:

let events = [];
let paused = false;
let totalEvents = 0;
 
const EVENT_CONFIG = {
  created: { icon: '✨', color: '#10b981', bg: '#10b98120' },
  updated: { icon: '📝', color: '#6366f1', bg: '#6366f120' },
  stage_changed: { icon: '🔄', color: '#f59e0b', bg: '#f59e0b20' },
  closed_won: { icon: '🎉', color: '#10b981', bg: '#10b98130' },
  closed_lost: { icon: '😞', color: '#ef4444', bg: '#ef444420' },
  new_lead: { icon: '👤', color: '#8b5cf6', bg: '#8b5cf620' }
};
 
function formatTimeAgo(date) {
  const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000);
  if (seconds < 60) return 'Just now';
  if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
  if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
  return new Date(date).toLocaleDateString();
}
 
function createEventCard(event) {
  const config = EVENT_CONFIG[event.actionType] || EVENT_CONFIG.updated;
  return {
    id: event.id || Date.now() + Math.random(),
    actionType: event.actionType,
    html: `
      <div class="event" id="event-${event.id}">
        <div class="event-icon" style="background:${config.bg}">${config.icon}</div>
        <div class="event-body">
          <div class="event-title">${event.title}</div>
          <div class="event-detail">${event.detail}</div>
          ${event.tags ? `<div style="margin-top:6px">${event.tags.map(t => `<span class="event-tag" style="background:${t.color}20;color:${t.color}">${t.label}</span>`).join('')}</div>` : ''}
          <div class="event-time">${formatTimeAgo(event.timestamp)}</div>
        </div>
      </div>
    `,
    rawEvent: event
  };
}
 
function renderFeed() {
  const search = document.getElementById('searchFilter').value.toLowerCase();
  const typeFilter = document.getElementById('typeFilter').value;
  const actionFilter = document.getElementById('actionFilter').value;
 
  const filtered = events.filter(e => {
    if (search && !e.rawEvent.title?.toLowerCase().includes(search) && !e.rawEvent.detail?.toLowerCase().includes(search)) return false;
    if (typeFilter && e.rawEvent.objectType !== typeFilter) return false;
    if (actionFilter && e.actionType !== actionFilter) return false;
    return true;
  });
 
  document.getElementById('feed').innerHTML = filtered.map(e => e.html).join('');
  document.getElementById('eventCount').textContent = `${totalEvents} events`;
}
 
function addEvent(eventData) {
  if (paused) return;
  const card = createEventCard(eventData);
  events.unshift(card);
  if (events.length > 200) events.pop(); // Keep last 200
  totalEvents++;
  renderFeed();
}
 
// Load historical activity (last 24 hours)
async function loadHistory() {
  const recentUpdates = await dench.db.query(`
    SELECT e.id, e.object_id, e.updated_at, e.created_at,
           o.name as object_name,
           ef_name.value as entry_name
    FROM entries e
    JOIN objects o ON o.id = e.object_id
    LEFT JOIN entry_fields ef_name ON ef_name.entry_id = e.id
      AND ef_name.field_id IN (
        SELECT id FROM fields WHERE name IN ('Full Name', 'Deal Name', 'Company', 'Name') LIMIT 1
      )
    WHERE e.updated_at >= CURRENT_TIMESTAMP - INTERVAL '24 hours'
    ORDER BY e.updated_at DESC
    LIMIT 50
  `);
 
  const separator = `<div class="separator">Last 24 hours</div>`;
  const historyEvents = recentUpdates.map(row => {
    const isNew = Math.abs(new Date(row.created_at) - new Date(row.updated_at)) < 5000;
    return createEventCard({
      id: row.id + '_hist',
      actionType: isNew ? 'created' : 'updated',
      objectType: row.object_name,
      title: isNew ? `New ${row.object_name.slice(0, -1)} added` : `${row.object_name.slice(0, -1)} updated`,
      detail: row.entry_name || row.id,
      timestamp: row.updated_at
    });
  });
 
  events = [...events, ...historyEvents];
  totalEvents += historyEvents.length;
  renderFeed();
}
 
// Listen for real-time events
dench.events.on('entry:created', (data) => {
  addEvent({
    id: Date.now(),
    actionType: 'created',
    title: `New entry created`,
    detail: data?.objectName || 'CRM entry',
    timestamp: new Date().toISOString()
  });
});
 
dench.events.on('entry:updated', (data) => {
  addEvent({
    id: Date.now(),
    actionType: 'updated',
    title: `Entry updated`,
    detail: data?.entryName || 'CRM entry',
    timestamp: new Date().toISOString()
  });
});
 
function togglePause() {
  paused = !paused;
  const btn = document.getElementById('pauseBtn');
  btn.textContent = paused ? 'Resume' : 'Pause';
  btn.classList.toggle('paused', paused);
  document.getElementById('liveDot').style.animationPlayState = paused ? 'paused' : 'running';
}
 
function applyFilters() { renderFeed(); }
 
loadHistory();

Frequently Asked Questions#

How do I detect when a deal stage specifically changes?#

DenchClaw's entry:updated event fires on any field change. To detect stage changes specifically, add a Stage history field to your deals object, and check if the Stage field changed by comparing old and new values in the event data.

Can I add audio notifications for closed deals?#

Yes. In the addEvent() function, check if (eventData.actionType === 'closed_won') and play an audio file using the Web Audio API or a simple new Audio('celebration.mp3').play().

How do I export the activity log?#

Add an export button that converts events to CSV format and triggers a download. Include timestamp, action type, object, and description columns.

Can I embed this as a widget?#

Yes. For widget mode, show just the last 5 events without the filter bar, in a compact vertical list format.

Ready to try DenchClaw? Install in one command: npx denchclaw. Full setup guide →

Related articles

Keep reading

View all