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
| Piece | Value |
|---|---|
| Page | Lead_Source_Performance |
| URL parameters | month (MM-yyyy, default current month) · source (Lead Source name, default Accident Claims Helpline) |
| Data function | get_lead_performance_data(p_month, p_source) → returns map — all counts, rows and drill-down links; no HTML. The single source of truth |
| HTML | Lives in the Creator page's HTML snippet — the classic report markup pasted in as the template, with Deluge placeholders where the numbers go |
| Snippet logic | Invokes the data function once, then substitutes map values via <%= %> expressions and for each loops for the daily rows — see §3 |
| Filter behaviour | The 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>
<%
}%>
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 element | Deluge source |
|---|---|
| Filter dropdown — month options | Loop last 12 months from today; each <a href="#Page:Lead_Source_Performance?month=MM-yyyy&source=…">; class='cur' when == monthStr |
| Filter dropdown — lead source options | Lead_Source[…] master records; same link pattern with source= |
| Tile: Total leads value / sub | totalLeads · monthStart–min(today, monthEnd) label |
Tile: sparkline points | (dashboard/widget view only) computed in the template from data.get("days") leads values |
| Tile: Went ahead value / sub | cntWent · convPct + "% conversion" |
| Tile: Booked value / sub | cntBooked · "next: " + nextBooking.toString("dd/MM") (omit sub when null) |
| Tile: Pending value / sub | cntPending · "oldest waiting " + oldestWait + " days" |
| Tile: Cancelled value / sub | cntCancelled · (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 rows | Loop 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 matrix | repWent/renWent/repBook/renBook/repPend/renPend/repCanc/renCanc |
| Upcoming bookings rows | bookedRows sorted by scheduled asc; bold + "· today" when scheduled == today |
| Pending rows | pendingRows sorted by wait desc; class='age-hot' when wait > 10 |
| Full data table rows | Loop dayKeys → all 8 counters + per-day conversion; footer = the month totals |
| Footer criteria line | Static 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.
4. Drill-down links
Every tile (and later, chart rows/segments if desired) opens the claims report pre-filtered. Two implementation options — decide once, use everywhere:
| Option | How | Trade-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=30 | Simple; 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 params | OR-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):
| Element | Filter |
|---|---|
| Total leads | Base population (source + month) |
| Went ahead | Base + Job_Taken_On1 not empty + not Cancelled |
| Booked | Base + CM Hire_Status = Booked + not Cancelled/Went |
| Pending | Base + the residual (via saved view mirroring the precedence rules) |
| Cancelled | Base + CM Status in (Cancel, Reject) OR Is_Claim_Accepted = Declined |
| Claim-type row | The outcome filter + that group's Claim_Type1 values |
| File-number links | Direct 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 = 0for 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)