Calculated Metrics
Every formula the dashboard computes locally instead of pulling from a platform.
Why we calculate locally
Some metrics genuinely don't exist as a single field in any source — Cost/Sale needs ad spend from two platforms divided by GHL won deals. Computing locally guarantees the formula matches across every client and any change to one platform's reporting surface ripples to the others automatically.
Formulas
| Metric | Formula | Where computed |
|---|---|---|
| Total Ad Spend | googleAdsData.adSpend.value + metaAdsData.adSpend.value | PipelineSection.tsx |
| ROAS | (GHL pipelineValue / Total Ad Spend) × 100 | PipelineSection.tsx |
| Avg Ticket Value | GHL pipelineValue / wonDeals.value | PipelineSection.tsx and SummarySection.tsx |
| Cost / Lead | Total Ad Spend / leads | PipelineSection.tsx |
| Cost / Appointment | Total Ad Spend / booked | PipelineSection.tsx, SummarySection.tsx |
| Cost / Sale | Total Ad Spend / won | PipelineSection.tsx, SummarySection.tsx |
| Lead → Appointment Rate | (booked / leads) × 100 | PipelineSection.tsx, SummarySection.tsx |
| Lead → Sale Rate | (won / leads) × 100 | PipelineSection.tsx, SummarySection.tsx |
| Appointment → Sale Rate | (won / booked) × 100 | SummarySection.tsx |
| Book Rate | (booked / leads) × 100 | PipelineRatesChart (per week) |
| Show Rate | (showed / booked) × 100 | PipelineRatesChart |
| Cancel Rate | (cancelled / booked) × 100 | PipelineRatesChart |
| Close Rate | (won / showed) × 100 | PipelineRatesChart |
| Total GBP Actions | gbpMetrics.callClicks + websiteClicks + drivingDirections | /api/metrics/gbp/route.ts |
| vs previous period change | ((current - previous) / previous) × 100 | Each /api/metrics/* route |
Zero handling
Every formula uses x > 0 guards before dividing to prevent divide-by-zero. When a guard fails, we fall back to ext('...') for an n8n-pushed value, then to null:
const costPerLead = adSpend > 0 && leads > 0
? `$${(adSpend / leads).toFixed(2)}`
: ext('cost_per_lead') ?? null0 leads in the date range) renders as 0. A missing value (no ad data, no GHL data) renders as nothing — the row is hidden by MetricRow. We never show - on the dashboard for a disconnected metric. This is the result of the May 2026 cleanup; older screenshots show plenty of - rows.Precision and formatting
| Metric type | Decimals | Example |
|---|---|---|
| Currency totals | 0 decimals | $12,345 |
| Currency per-unit | 2 decimals | $18.50 / lead |
| Percentages | 1 decimal | 24.7% |
| Counts | 0 decimals + thousands separator | 1,234 |
| Rates with parent <100 | 1 decimal | 6.4% |
The shared formatter is toLocaleString('en-US', ...). Avoid building strings manually with + — use the formatted field that comes back from each route.
vs-previous-period change
Every { value, change, formatted } triple has a change field that is the percent delta vs the previous period of equal length. The route does the math:
function pctChange(current: number, previous: number): number {
if (previous === 0) return current === 0 ? 0 : 100
return ((current - previous) / previous) × 100
}The previous period is computed by shifting (end - start) back by the same length. So if the user picks 2026-04-01 to 2026-04-30, the comparison range is 2026-03-02 to 2026-03-31.