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
·6 min read
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: tabStep 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();