Skip to main content

Lead Source Performance — Deluge Build Spec

Developer hand-off for the ACH Lead Performance Dashboard. One workflow function computes everything and returns the full HTML string; the Creator page renders it in an HTML snippet. Template: /mockups/ach-lead-dashboard-nojs.html.

:::note Field link names Field/report link names below are taken from the schema docs and this project's verification sessions — verify each against the live app before coding (e.g. the Lead_Source display field, the exact Hire_Status/Status link names on Claims_Management_Form). :::


1. Page wiring

PieceValue
PageLead_Source_Performance
URL parametersmonth (MM-yyyy, default current month) · source (Lead Source name, default Accident Claims Helpline)
Data functionget_lead_performance_data(p_month, p_source) → returns map — all counts, rows and drill-down links; no HTML. The single source of truth
HTMLLives in the Creator page's HTML snippet — the classic report markup pasted in as the template, with Deluge placeholders where the numbers go
Snippet logicInvokes the data function once, then substitutes map values via <%= %> expressions and for each loops for the daily rows — see §3
Filter behaviourThe function renders the two <details> dropdowns; every option is a link back to #Page:Lead_Source_Performance?month=…&source=…selecting = full page reload with new parameters

2. The data function

One function computes everything and returns a map — separated from presentation so the classic report page, the dashboard page, the monthly email, and the widget (v2, via a Custom API) all consume identical numbers.

/* =====================================================================
* get_lead_performance_data(p_month string, p_source string) : map
* The single source of truth — counts, rows, drill-down links. No HTML.
* Criteria: see "Criteria Definitions" in ach-lead-performance-dashboard
* ===================================================================== */

/* ---------- 1. Parameters & date window ---------- */
monthStr = p_month;
if(monthStr == null || monthStr == "")
{
monthStr = zoho.currentdate.toString("MM-yyyy");
}
sourceName = p_source;
if(sourceName == null || sourceName == "")
{
sourceName = "Accident Claims Helpline";
}
monthStart = ("01-" + monthStr).toDate();
monthEnd = monthStart.addMonth(1).subDay(1);
today = zoho.currentdate;

/* ---------- 2. Base population — CONFIRMED criteria ---------- */
// Lead Source = <source>, Date_Created in month. Adjust the lookup's
// display-field name (Lead_Source1.<display field>) to the live schema.
leadRecs = Claim_Form[Lead_Source1.Lead_Source == sourceName
&& Date_Created >= monthStart && Date_Created <= monthEnd]
sort by Date_Created;

/* ---------- 3. Claim-type grouping ---------- */
// TODO before go-live: confirm Free Rental + Towing/Storage placements
repairOnlyTypes = {"Repair Only","Repair Only IITR/Crash Collab","Towing + Repair"};
repairRentalTypes = {"Repair & Rental","Towing + Repair + Rental","Towing + Repair + Free Rental"};
rentalOnlyTypes = {"Rental Only","Rental Only IITR/Crash Collab","Free Rental","Free Rental Only","Total Loss Rental Only","Towing/Storage + Rental","Towing/Storage + Free Rental","Total Loss + Towing/Storage + Rental"};

/* ---------- 4. Classify every lead, aggregate ---------- */
// per-outcome totals
cntWent = 0; cntBooked = 0; cntPending = 0; cntCancelled = 0;
unmappedTypes = 0;

// dayMap: "dd/MM" -> Map{leads, went, booked, pending, cancelled, ro, rr, rent}
dayMap = Map();
dayKeys = List(); // preserves order (records sorted by Date_Created)

// typeAgg: group -> Map{total, went, booked, pending, cancelled}
typeAgg = Map();
for each g in {"Repair Only","Repair & Rental","Rental Only"}
{
typeAgg.put(g, {"total":0,"went":0,"booked":0,"pending":0,"cancelled":0});
}

bookedRows = List(); // rows for "Upcoming bookings"
pendingRows = List(); // rows for "Pending — needs a decision"
nextBooking = null; // earliest future Booking_Date
oldestWait = 0; // max days waiting among pending

for each rec in leadRecs
{
/* --- outcome: precedence order, first match wins --- */
cmStatus = "";
hireStatus = "";
if(rec.Claims_Management_Form != null)
{
cmStatus = ifnull(rec.Claims_Management_Form.Status, "");
hireStatus = ifnull(rec.Claims_Management_Form.Hire_Status, "");
}
outcome = "";
if(cmStatus == "Cancel" || cmStatus == "Reject" || rec.Is_Claim_Accepted == "Declined")
{
outcome = "cancelled"; cntCancelled = cntCancelled + 1;
}
else if(rec.Job_Taken_On1 != null)
{
outcome = "went"; cntWent = cntWent + 1;
}
else if(hireStatus == "Booked")
{
outcome = "booked"; cntBooked = cntBooked + 1;
bookedRows.add({"file":rec.File_Number, "type":rec.Claim_Type1,
"received":rec.Date_Created, "scheduled":rec.Booking_Date,
"id":rec.ID});
if(rec.Booking_Date != null && (nextBooking == null || rec.Booking_Date < nextBooking))
{
nextBooking = rec.Booking_Date;
}
}
else
{
outcome = "pending"; cntPending = cntPending + 1;
waitDays = daysBetween(rec.Date_Created, today);
if(waitDays > oldestWait) { oldestWait = waitDays; }
pendingRows.add({"file":rec.File_Number, "reason":rec.Is_Claim_Accepted,
"received":rec.Date_Created, "wait":waitDays, "id":rec.ID});
}

/* --- claim-type group --- */
grp = "";
if(repairOnlyTypes.contains(rec.Claim_Type1)) { grp = "Repair Only"; }
else if(repairRentalTypes.contains(rec.Claim_Type1)) { grp = "Repair & Rental"; }
else if(rentalOnlyTypes.contains(rec.Claim_Type1)) { grp = "Rental Only"; }
else { unmappedTypes = unmappedTypes + 1; } // never silently dropped

if(grp != "")
{
gAgg = typeAgg.get(grp);
gAgg.put("total", gAgg.get("total") + 1);
gAgg.put(outcome, gAgg.get(outcome) + 1);
typeAgg.put(grp, gAgg);
}

/* --- daily bucket --- */
dKey = rec.Date_Created.toString("dd/MM");
if(!dayMap.containKey(dKey))
{
dayKeys.add(dKey);
dayMap.put(dKey, {"leads":0,"went":0,"booked":0,"pending":0,"cancelled":0,"ro":0,"rr":0,"rent":0});
}
d = dayMap.get(dKey);
d.put("leads", d.get("leads") + 1);
d.put(outcome, d.get(outcome) + 1);
if(grp == "Repair Only") { d.put("ro", d.get("ro") + 1); }
if(grp == "Repair & Rental") { d.put("rr", d.get("rr") + 1); }
if(grp == "Rental Only") { d.put("rent",d.get("rent") + 1); }
dayMap.put(dKey, d);
}

totalLeads = leadRecs.count();
convPct = 0;
if(totalLeads > 0) { convPct = (cntWent * 100 / totalLeads).round(0); }

/* ---------- 5. Component matrix (derived, no extra tracking) ---------- */
// Repairs <o> = RepairOnly.<o> + R&R.<o> · Rentals <o> = RentalOnly.<o> + R&R.<o>
ro = typeAgg.get("Repair Only"); rr = typeAgg.get("Repair & Rental"); ren = typeAgg.get("Rental Only");
repWent = ro.get("went") + rr.get("went"); renWent = ren.get("went") + rr.get("went");
repBook = ro.get("booked") + rr.get("booked"); renBook = ren.get("booked") + rr.get("booked");
repPend = ro.get("pending") + rr.get("pending"); renPend = ren.get("pending") + rr.get("pending");
repCanc = ro.get("cancelled") + rr.get("cancelled"); renCanc = ren.get("cancelled") + rr.get("cancelled");

/* ---------- 6. Assemble the return map (NO HTML here) ---------- */
// per-day conversion + flatten dayMap into an ordered list for the template
days = List();
for each k in dayKeys
{
d = dayMap.get(k);
d.put("date", k);
dc = 0;
if(d.get("leads") > 0) { dc = (d.get("went") * 100 / d.get("leads")).round(0); }
d.put("conversion", dc);
days.add(d);
}

// drill-down links generated once here so every consumer shares them — see §4
linkBase = "#Report:All_Claims?..."; // base population filter
linkWent = "#Report:ACH_Went_Ahead?...";
linkBooked = "#Report:ACH_Booked?...";
linkPending = "#Report:ACH_Pending?...";
linkCancelled = "#Report:ACH_Cancelled?...";

summary = Map();
summary.put("total_leads", {"count": totalLeads, "link": linkBase});
summary.put("went_ahead", {"count": cntWent, "link": linkWent, "conversion": convPct});
summary.put("booked", {"count": cntBooked, "link": linkBooked, "next_booking": nextBooking});
summary.put("pending", {"count": cntPending, "link": linkPending,"oldest_wait": oldestWait});
summary.put("cancelled", {"count": cntCancelled, "link": linkCancelled});

result = Map();
result.put("meta", {"month": monthStr, "source": sourceName, "as_of": today.toString("dd/MM/yyyy")});
result.put("summary", summary);
result.put("days", days); // list of per-day maps
result.put("types", typeAgg); // group -> {total, went, booked, pending, cancelled}
result.put("components", {
"repairs": {"went": repWent, "booked": repBook, "pending": repPend, "cancelled": repCanc},
"rentals": {"went": renWent, "booked": renBook, "pending": renPend, "cancelled": renCanc}
});
result.put("booked_rows", bookedRows);
result.put("pending_rows", pendingRows);
result.put("unmapped_types", unmappedTypes);
return result;

Declare the function's return type as map — nested maps/lists pass to the page snippet as-is. Because the map carries data only (no markup), the same function later powers the monthly email schedule and the widget v2 (published as a Custom API, where Deluge auto-serialises the map to JSON).

3. Page template — invoke once, substitute variables

The page's HTML snippet holds the classic report markup as a template: paste the <style> block and tables, invoke the data function once at the top, and replace every hardcoded number with a placeholder. Static single-value cells use <%= %>; repeating rows (daily table, claim-type rows, booked/pending lists) use for each loops.

<%{
data = thisapp.get_lead_performance_data(input.month, input.source);
s = data.get("summary");
%>

<!-- summary header: values row -->
<table class="summary">
<tr>
<td class="navycell sv"><%=s.get("total_leads").get("count")%></td>
<td class="c-went sv"><%=s.get("went_ahead").get("count")%></td>
<td class="c-hold sv"><%=s.get("pending").get("count")%></td>
<td class="c-booked sv"><%=s.get("booked").get("count")%></td>
<td class="c-canc sv"><%=s.get("cancelled").get("count")%></td>
</tr>
<!-- labels row: static, except "(<%=s.get("went_ahead").get("conversion")%>%)" -->
</table>

<!-- daily breakdown: one loop replaces all hardcoded rows -->
<tbody>
<% for each day in data.get("days") { %>
<tr>
<td class="datecol"><%=day.get("date")%></td>
<td><%=day.get("leads")%></td>
<td><%=day.get("went")%></td>
<td><%=day.get("pending")%></td>
<td><%=day.get("booked")%></td>
<td><%=day.get("cancelled")%></td>
<td><%=day.get("ro")%></td>
<td><%=day.get("rr")%></td>
<td><%=day.get("rent")%></td>
<td><%=day.get("conversion")%>%</td>
</tr>
<% } %>
</tbody>

<!-- drill-down: wrap any figure in its link from the map -->
<a href="<%=s.get("cancelled").get("link")%>"><%=s.get("cancelled").get("count")%></a>

<%
}%>
note

The exact Deluge-in-HTML delimiters (<%{ }%> / <%= %> / <% %>) vary slightly by Creator page type — copy the working pattern from an existing page (e.g. the Management Dashboard) rather than trusting this doc's rendering of them.

Template token map

Every dynamic token in the report and the map value that fills it:

Mockup elementDeluge source
Filter dropdown — month optionsLoop last 12 months from today; each <a href="#Page:Lead_Source_Performance?month=MM-yyyy&source=…">; class='cur' when == monthStr
Filter dropdown — lead source optionsLead_Source[…] master records; same link pattern with source=
Tile: Total leads value / subtotalLeads · monthStartmin(today, monthEnd) label
Tile: sparkline points(dashboard/widget view only) computed in the template from data.get("days") leads values
Tile: Went ahead value / subcntWent · convPct + "% conversion"
Tile: Booked value / subcntBooked · "next: " + nextBooking.toString("dd/MM") (omit sub when null)
Tile: Pending value / subcntPending · "oldest waiting " + oldestWait + " days"
Tile: Cancelled value / subcntCancelled · (cntCancelled×100/totalLeads).round(0) + "% of leads"
Daily table rows (classic report)for each day in data.get("days") — see §3 template
Daily chart columns/gridlines(dashboard view only) computed in the template: maxY = busiest day rounded up to a multiple of 3; segment height = count/maxY × 180px minus 2px per gap
Claim-type rowsLoop typeAgg in fixed order; bar width = total/maxGroupTotal×100%; segments flex-grow = count; inline label only when count ≥ 3; row end total · conv%
"Unmapped types this month"unmappedTypes — if > 0, render an extra warning row so nothing is silently dropped
Components matrixrepWent/renWent/repBook/renBook/repPend/renPend/repCanc/renCanc
Upcoming bookings rowsbookedRows sorted by scheduled asc; bold + "· today" when scheduled == today
Pending rowspendingRows sorted by wait desc; class='age-hot' when wait > 10
Full data table rowsLoop dayKeys → all 8 counters + per-day conversion; footer = the month totals
Footer criteria lineStatic text (update if criteria ever change)

Escaping: any value that can contain user text (lead source names, claim types, Is_Claim_Accepted values) must be HTML-escaped before concatenation (at minimum replace &, <, >, "). File numbers and counts are numeric and safe.

Every tile (and later, chart rows/segments if desired) opens the claims report pre-filtered. Two implementation options — decide once, use everywhere:

OptionHowTrade-off
A. URL criteria params (pattern already used by the Management Dashboard, e.g. #Report:Active_Recoveries_CC?Recovery_Firm2.ID_op=30)Link to e.g. #Report:All_Claims?Lead_Source1.Lead_Source=…&Job_Taken_On1_op=30Simple; but OR-criteria (Cancel or Reject or Declined) and date-ranges are hard to express — confirm the op-codes actually cover each bucket
B. Saved report views per bucket (recommended)Create filtered views of the claims report — ACH_Went_Ahead, ACH_Booked, ACH_Pending, ACH_Cancelled — each with the bucket's full criteria built into the report filter; the link passes only lead source + month paramsOR-logic and cross-form criteria live in the report definition where they're expressible; links stay trivial

Per-element targets (whatever the option, the filtered set must match the tile's number exactly):

ElementFilter
Total leadsBase population (source + month)
Went aheadBase + Job_Taken_On1 not empty + not Cancelled
BookedBase + CM Hire_Status = Booked + not Cancelled/Went
PendingBase + the residual (via saved view mirroring the precedence rules)
CancelledBase + CM Status in (Cancel, Reject) OR Is_Claim_Accepted = Declined
Claim-type rowThe outcome filter + that group's Claim_Type1 values
File-number linksDirect record view: #Report:All_Claims?ID=<rec.ID> (or the record's detail URL)

5. Acceptance checklist

  • July 2026 + Accident Claims Helpline returns exactly the 55 reference file numbers (list in the dashboard doc)
  • Tiles sum: Went + Booked + Pending + Cancelled = Total leads (every month, every source)
  • Tile numbers = daily-table footer = claim-type row sums (single dataset — must agree by construction)
  • Each drill-down opens a report whose record count equals the number on the element clicked
  • unmappedTypes = 0 for July 2026 ACH; if a new claim type appears later, the warning row shows instead of dropping records
  • Page defaults sensibly with no URL parameters (current month, ACH)
  • A month with zero leads renders an empty state, not a broken chart (guard the maxY / division logic)