Back to The Times of Claw

Build a Customer Health Dashboard

Build a customer health score dashboard in DenchClaw that calculates health scores from CRM signals like engagement, support tickets, and payment history.

Mark Rachapoom
Mark Rachapoom
·6 min read
Build a Customer Health Dashboard

Build a Customer Health Dashboard

Churn doesn't announce itself. By the time a customer cancels, the warning signs were there weeks earlier — decreased engagement, support tickets piling up, unanswered emails. A customer health dashboard makes those signals visible before it's too late.

This guide builds a customer health dashboard as a Dench App: calculate health scores from CRM signals, display a red/yellow/green status grid, and surface at-risk customers automatically.

What You're Building#

  • Health score calculation from configurable CRM field signals
  • Traffic light status grid (green/yellow/red) across your customer base
  • At-risk customer alerts panel
  • Health trend over time per customer
  • Auto-generate a "save the customer" action plan for at-risk accounts

Step 1: App Setup#

mkdir -p ~/.openclaw-dench/workspace/apps/customer-health.dench.app

.dench.yaml:

name: Customer Health
description: Health scores and at-risk customer alerts
icon: heart
version: 1.0.0
permissions:
  - read:crm
  - write:crm
  - chat:create
display: tab

Step 2: HTML Layout#

index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Customer Health</title>
  <style>
    * { box-sizing: border-box; }
    body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }
    .summary-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; }
    .summary-card { background: #1e293b; border-radius: 12px; padding: 16px; text-align: center; }
    .summary-card .count { font-size: 36px; font-weight: 800; }
    .summary-card .label { font-size: 12px; color: #64748b; text-transform: uppercase; margin-top: 4px; }
    .customer-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 14px; margin-bottom: 24px; }
    .customer-card { background: #1e293b; border-radius: 12px; padding: 14px; border-left: 4px solid; cursor: pointer; transition: transform 0.15s; }
    .customer-card:hover { transform: translateY(-2px); }
    .customer-card.healthy { border-left-color: #10b981; }
    .customer-card.at-risk { border-left-color: #f59e0b; }
    .customer-card.critical { border-left-color: #ef4444; }
    .customer-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; }
    .customer-contact { font-size: 12px; color: #64748b; margin-bottom: 10px; }
    .health-bar-bg { background: #334155; border-radius: 4px; height: 8px; }
    .health-bar-fill { height: 8px; border-radius: 4px; transition: width 0.5s; }
    .score-label { display: flex; justify-content: space-between; font-size: 11px; margin-top: 4px; }
    .signal-row { display: flex; justify-content: space-between; font-size: 11px; color: #64748b; margin-top: 3px; }
    .signal-ok { color: #10b981; }
    .signal-warn { color: #f59e0b; }
    .signal-bad { color: #ef4444; }
    .section { background: #1e293b; border-radius: 12px; padding: 20px; margin-bottom: 20px; }
    .section h2 { font-size: 16px; margin: 0 0 16px; }
    button { padding: 8px 14px; border: none; border-radius: 8px; cursor: pointer; font-size: 12px; }
    .btn-primary { background: #6366f1; color: white; }
    .btn-sm { padding: 5px 10px; font-size: 11px; }
  </style>
</head>
<body>
  <div class="summary-grid">
    <div class="summary-card">
      <div class="count" id="total-count" style="color:#e2e8f0">0</div>
      <div class="label">Total Customers</div>
    </div>
    <div class="summary-card">
      <div class="count" id="healthy-count" style="color:#10b981">0</div>
      <div class="label">Healthy</div>
    </div>
    <div class="summary-card">
      <div class="count" id="at-risk-count" style="color:#f59e0b">0</div>
      <div class="label">At Risk</div>
    </div>
    <div class="summary-card">
      <div class="count" id="critical-count" style="color:#ef4444">0</div>
      <div class="label">Critical</div>
    </div>
  </div>
  <div class="section">
    <h2>Customer Health Overview</h2>
    <div id="customerGrid" class="customer-grid"></div>
  </div>
  <div class="section" id="atRiskSection">
    <h2>🚨 At-Risk Customers — Action Required</h2>
    <div id="atRiskList"></div>
  </div>
  <script src="health.js"></script>
</body>
</html>

Step 3: Health Score Logic#

health.js:

// Health signal weights — customize these to match what matters for your business
const SIGNALS = [
  { name: 'Last Contacted', key: 'Last Contacted', weight: 25, eval: (val) => {
    if (!val) return 0;
    const days = Math.floor((Date.now() - new Date(val)) / 86400000);
    if (days <= 7) return 100;
    if (days <= 14) return 75;
    if (days <= 30) return 40;
    return 0;
  }},
  { name: 'Open Deals', key: '_deal_count', weight: 20, eval: (val) => {
    if (val > 2) return 100;
    if (val > 0) return 60;
    return 20;
  }},
  { name: 'Status', key: 'Status', weight: 30, eval: (val) => {
    const scores = { 'Customer': 100, 'Qualified': 80, 'Nurturing': 60, 'Lead': 30 };
    return scores[val] || 10;
  }},
  { name: 'Has Email', key: 'Email Address', weight: 15, eval: (val) => val ? 100 : 0 },
  { name: 'Has LinkedIn', key: 'LinkedIn URL', weight: 10, eval: (val) => val ? 100 : 20 }
];
 
function calculateHealth(customer) {
  let totalWeight = 0;
  let weightedScore = 0;
  const signalResults = [];
 
  SIGNALS.forEach(signal => {
    const val = customer[signal.key];
    const score = signal.eval(val);
    weightedScore += score * signal.weight;
    totalWeight += signal.weight;
    signalResults.push({ name: signal.name, score, weight: signal.weight });
  });
 
  const healthScore = Math.round(weightedScore / totalWeight);
  return {
    score: healthScore,
    status: healthScore >= 70 ? 'healthy' : healthScore >= 40 ? 'at-risk' : 'critical',
    signals: signalResults
  };
}
 
function healthColor(status) {
  return { healthy: '#10b981', 'at-risk': '#f59e0b', critical: '#ef4444' }[status];
}
 
async function loadCustomers() {
  const customers = await dench.db.query(`
    SELECT p.id, p."Full Name", p."Email Address", p."Company", p."Status",
           p."Last Contacted", p."LinkedIn URL", p."Notes",
           COUNT(d.id) AS _deal_count,
           SUM(CAST(d."Value" AS DOUBLE)) AS _total_value
    FROM v_people p
    LEFT JOIN v_deals d ON d."Contact" = p.id
    WHERE p."Status" IN ('Customer', 'Qualified', 'Nurturing')
    GROUP BY p.id, p."Full Name", p."Email Address", p."Company", p."Status", p."Last Contacted", p."LinkedIn URL", p."Notes"
  `);
 
  const scored = customers.map(c => ({ ...c, ...calculateHealth(c) })).sort((a, b) => a.score - b.score);
 
  // Update summary counts
  document.getElementById('total-count').textContent = scored.length;
  document.getElementById('healthy-count').textContent = scored.filter(c => c.status === 'healthy').length;
  document.getElementById('at-risk-count').textContent = scored.filter(c => c.status === 'at-risk').length;
  document.getElementById('critical-count').textContent = scored.filter(c => c.status === 'critical').length;
 
  // Render customer grid
  document.getElementById('customerGrid').innerHTML = scored.map(c => `
    <div class="customer-card ${c.status}" onclick="openEntry('${c.id}')">
      <div class="customer-name">${c['Full Name'] || 'Unknown'}</div>
      <div class="customer-contact">${c.Company || '—'} · ${c.Status}</div>
      <div class="health-bar-bg">
        <div class="health-bar-fill" style="width:${c.score}%;background:${healthColor(c.status)}"></div>
      </div>
      <div class="score-label">
        <span>Health Score</span>
        <span style="color:${healthColor(c.status)};font-weight:700">${c.score}/100</span>
      </div>
      ${c.signals.map(s => `
        <div class="signal-row">
          <span>${s.name}</span>
          <span class="${s.score >= 70 ? 'signal-ok' : s.score >= 40 ? 'signal-warn' : 'signal-bad'}">
            ${s.score}%
          </span>
        </div>
      `).join('')}
    </div>
  `).join('');
 
  // At-risk section
  const atRisk = scored.filter(c => c.status !== 'healthy');
  if (atRisk.length === 0) {
    document.getElementById('atRiskSection').style.display = 'none';
    return;
  }
  
  document.getElementById('atRiskList').innerHTML = atRisk.map(c => `
    <div style="display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px solid #334155">
      <div>
        <div style="font-weight:600;font-size:13px">${c['Full Name']}</div>
        <div style="font-size:11px;color:#64748b">${c.Company || '—'} · Score: ${c.score}/100</div>
      </div>
      <div style="display:flex;gap:8px">
        <button class="btn-primary btn-sm" onclick="generateActionPlan('${c.id}', '${c['Full Name']}', ${c.score})">Action Plan</button>
        <button style="background:#334155;color:#94a3b8" class="btn-sm" onclick="openEntry('${c.id}')">Open</button>
      </div>
    </div>
  `).join('');
}
 
function openEntry(id) { dench.apps.navigate(`/entry/${id}`); }
 
async function generateActionPlan(customerId, name, score) {
  const session = await dench.chat.createSession();
  const plan = await dench.chat.send(session.id,
    `Generate a concise 3-step action plan to improve the health of a customer relationship.
Customer: ${name}
Health score: ${score}/100
Generate 3 specific, actionable steps to re-engage this customer and improve the relationship. Focus on practical outreach actions.`
  );
  await dench.ui.toast({ message: `Action plan for ${name}: ${plan.substring(0, 200)}...`, type: 'info' });
}
 
dench.events.on('entry:updated', loadCustomers);
loadCustomers();
Mark Rachapoom

Written by

Mark Rachapoom

Building the future of AI CRM software.

Continue reading

DENCH

© 2026 DenchHQ · San Francisco, CA