Documentation
Data Mapping

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

MetricFormulaWhere computed
Total Ad SpendgoogleAdsData.adSpend.value + metaAdsData.adSpend.valuePipelineSection.tsx
ROAS(GHL pipelineValue / Total Ad Spend) × 100PipelineSection.tsx
Avg Ticket ValueGHL pipelineValue / wonDeals.valuePipelineSection.tsx and SummarySection.tsx
Cost / LeadTotal Ad Spend / leadsPipelineSection.tsx
Cost / AppointmentTotal Ad Spend / bookedPipelineSection.tsx, SummarySection.tsx
Cost / SaleTotal Ad Spend / wonPipelineSection.tsx, SummarySection.tsx
Lead → Appointment Rate(booked / leads) × 100PipelineSection.tsx, SummarySection.tsx
Lead → Sale Rate(won / leads) × 100PipelineSection.tsx, SummarySection.tsx
Appointment → Sale Rate(won / booked) × 100SummarySection.tsx
Book Rate(booked / leads) × 100PipelineRatesChart (per week)
Show Rate(showed / booked) × 100PipelineRatesChart
Cancel Rate(cancelled / booked) × 100PipelineRatesChart
Close Rate(won / showed) × 100PipelineRatesChart
Total GBP ActionsgbpMetrics.callClicks + websiteClicks + drivingDirections/api/metrics/gbp/route.ts
vs previous period change((current - previous) / previous) × 100Each /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:

ts
const costPerLead = adSpend > 0 && leads > 0
  ? `$${(adSpend / leads).toFixed(2)}`
  : ext('cost_per_lead') ?? null
0 vs - vs hidden
A zero value (e.g. 0 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 typeDecimalsExample
Currency totals0 decimals$12,345
Currency per-unit2 decimals$18.50 / lead
Percentages1 decimal24.7%
Counts0 decimals + thousands separator1,234
Rates with parent <1001 decimal6.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:

ts
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.