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
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();