- No.
- 002
- Filed
- Revised
- Length
- 1,511 words
- Filed under
- Builds, RevOps, Tools
Building Analytics Inside NetSuite With an Iframe Loader and N/llm
A Suitelet that loads six saved searches takes about a minute. Serve a spinner and an iframe first, ship the data as inline JSON, send N/llm monthly values.
A NetSuite Suitelet that loads six saved searches before it renders takes long enough that its loading screen tells users to expect about a minute. The fix is a first response that holds only a spinner and an iframe, with the iframe calling the same script with &render=1. For the AI panel on top, N/llm needs each month as its own value, not a summary.
The loader is the first branch of onRequest, with the markup elided:
var render = params.render;
var exportType = params.export;
if (!render && !exportType) {
var loaderUrl = getScriptUrl() + '&render=1';
context.response.write(
// ... doctype, title, spinner and progress-bar CSS, loader markup
'<iframe style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;z-index:1" src="' + loaderUrl + '" onload="' +
// ... onload hides the spinner, then removes it after 500 ms
'"></iframe></body></html>'
);
return;
}
// ... Phase 2: load the six searches, render the dashboard
Phase 1 returns immediately, and Phase 2 loads the searches inside the iframe. The split exists to avoid gateway timeouts on large data loads.
The dashboard has five tabs (Contribution, Availability Checker, Crew, Projects, Modeling) and a slide-out AI panel. Six saved searches feed them: projects, time entries, employees, revenue, consulting bills and Tempo plans.
Build the page as one ES5 string
SuiteScript 2.1 runs modern JavaScript on the server. This codebase sticks to ES5 anyway: var and function, no arrow functions, no template literals. The page is one long string, with an esc helper for the values:
function esc(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
html += '<button class="tab active" data-group="tl" onclick="showSub(\'tl\',\'tl-contrib\',this)">Contribution</button>';
html += '<button class="tab" data-group="tl" onclick="showSub(\'tl\',\'tl-avail\',this)">Availability Checker</button>';
html += '<button class="tab" data-group="tl" onclick="showSub(\'tl\',\'tl-crew\',this)">Crew</button>';
Let CSV exports skip the loader
The guard is !render && !exportType, not !render. A request with &export=tl_project skips the loader. It still loads all six searches, then exportCSV returns the file. With !render alone, an export request would take the first branch, and a download link would get the spinner page.
Ship the data once, and escape </script>
The server builds every structure and writes it into the page as variables. Filtering, sorting and charts run in the browser:
html += '<script>';
html += 'var tlProjects=' + JSON.stringify(tlProjects).replace(/<\/script>/gi, '<\\/script>') + ';';
html += 'var tlResources=' + JSON.stringify(tlResources).replace(/<\/script>/gi, '<\\/script>') + ';';
html += 'var tlCrew=' + JSON.stringify(tlCrew).replace(/<\/script>/gi, '<\\/script>') + ';';
JSON.stringify leaves </script> alone. The HTML parser closes an inline script at the first </script> it meets, even one inside a JavaScript string. Bill memos are part of the embedded data, so one memo containing that text would end the block early. JavaScript reads <\/script> as </script>, and the HTML parser does not treat it as a closing tag.
Syntax-check the client JavaScript you build as strings
Most of the browser code lives in four generate*JS() functions that return strings, so quotes nest three deep:
js += 'function renderUnmatchedBills(){';
js += 'var el=document.getElementById("crew-unmatched");if(!el)return;';
js += 'var h="";';
js += 'var um=tlBills.unmatched||[];';
js += 'if(um.length===0){el.innerHTML="<p style=\\"color:#80868b;padding:20px\\">No unmatched bills.</p>";return;}';
node --check on the Suitelet parses the outer file only, so a missing brace inside one of those strings passes. The repo's scripts/extract-client-js.js finds each generator, runs it, and checks what it returns:
var funcPattern = /function (generate\w+JS)\s*\(/g;
// ... collect the names, cut each function's text out by counting braces
var wrapped = funcStr + '\nvar __out = ' + funcName + '();\n'
+ 'require("fs").writeFileSync("/tmp/_cc_test_' + funcName + '.js", __out);\n';
// ...
eval(wrapped);
execSync('node --check ' + tmpFile, { stdio: 'pipe' });
Run against the Suitelet as it stood on 13 February:
Found 4 JS generator(s): generateTimeLogProjectJS, generateTimeLogResourceJS, generateCrewJS, generateModelJS
✓ generateTimeLogProjectJS — generated JS is valid
✓ generateTimeLogResourceJS — generated JS is valid
✓ generateCrewJS — generated JS is valid
✓ generateModelJS — generated JS is valid
...
It calls each generator with no arguments and evaluates its text on its own. A generator that dereferences a parameter, or reads an outer variable the script does not have, throws. The script labels any exception SYNTAX ERROR in generated JS. When the generator itself threw, the line below prints the exception's message. Read it before hunting for a brace.
The AI panel's client code is built in htmlFooter(), not in a generator. The extractor never checks it.
Match bills to people with a waterfall, and keep the misses visible
True contractor rates need each consulting bill matched to a person. Staffing agencies put the name in different places: the main line, the memo, brackets, after a dash. The extractor tries the cheap answers first:
function cleanMainLineName(mln) {
if (!mln) return '';
var s = mln.toString();
s = s.replace(/\([0-9a-f]{32}\)/, '');
s = s.replace(/\s*[-\u2013]\s*$/, '');
s = s.replace(/\s+/g, ' ').trim();
return s;
}
function extractConsultantName(cleanedMLN, memo, notes) {
// Step 2: individual contractor — MLN IS the name
if (!isCompanyVendor(cleanedMLN)) {
return cleanedMLN || null;
}
// Step 3: vendor-specific extraction
var vendorLower = cleanedMLN.toLowerCase();
var m;
// ... ten vendor branches, each with its own memo regex
// Step 4: fallback — Notes "Name:" pattern
if (notes) {
m = notes.match(/Name:\s*([A-Za-z][A-Za-z\s\-']+?)(?:\s+(?:Start|Project|Location|Email|Position|Contract|Duration|End|-))/);
if (m) return m[1].trim();
// ...
}
// ... generic memo patterns
return null;
}
isCompanyVendor is one regex of company words (LLC, Inc, Ltd and the like). The extracted name then has to hit the employee list, exactly or lowercased. One agency can name more than one apprentice on a single line. That branch returns an array, and the amount is split evenly between them.
Every bill that fails lands in an Unmatched Bills tab with one of three reasons, verbatim: Company vendor - extraction failed, Empty name, Name extracted but no employee match. The reason column says which step to fix.
Call N/llm from a RESTlet, with documents, not one big prompt
The browser posts its data to a companion RESTlet through restlet.nl, with the numeric script and deployment IDs. The first build passed the customscript_ and customdeploy_ string IDs instead. The fix, 33 minutes later, switched to the numeric IDs and changed nothing else. The RESTlet builds documents with llm.createDocument() and passes them apart from the prompt:
var response = llm.generateText({
preamble: promptConfig.preamble,
prompt: promptConfig.prompt,
documents: documents,
modelFamily: llm.ModelFamily.COHERE_COMMAND_R,
modelParameters: {
maxTokens: 2000,
temperature: 0.3,
topP: 0.9,
frequencyPenalty: 0.3
}
});
The response includes citations, each with the documentIds it drew on. The panel maps those IDs to labels such as "Project Data #4". After each call the RESTlet also reads llm.getRemainingFreeUsage(), and the panel prints it as "AI quota remaining".
Send every month of hours, revenue and cost, zeros included
The first version sent aggregates. Each project's monthly hours became a short trend summary that ended like this:
return trend + ' Peak: ' + peak.toFixed(0) + 'h. Recent avg: ' + avgLast3.toFixed(0) + 'h/mo.';
The summary keeps a trend, a peak and a recent average. It has no figure for any single month for the model to quote. The redesign that evening sends each month as its own value:
// Build a monthly table string: "Jan 2025: 120h | Feb 2025: 95h | ..."
// showZeros=true: show ALL months including 0 (use for hours/revenue/cost so AI sees gaps)
// showZeros=false/omitted: only non-zero months (use for auxiliary data like PTO, billable)
function monthlyTableStr(months, data, unit, showZeros) {
var parts = [];
months.forEach(function (mk) {
var val = data[mk] || 0;
if (val > 0 || showZeros) {
if (unit === '$') parts.push(monthLabel(mk) + ': ' + fmtDollars(val));
else parts.push(monthLabel(mk) + ': ' + fmtHrs(val));
}
});
return parts.length > 0 ? parts.join(' | ') : 'None';
}
showZeros arrived in a later fix that night, along with a trend direction that counts zero months. Hours, revenue and cost pass it, because a month left out of the line shows up only as a missing label, and the model needs those gaps to see a ramp-up or a wind-down. Billable, PTO and planned hours still list only non-zero months. The documents come in four tiers, each meant to be self-contained at about 300 to 500 tokens:
- Portfolio snapshot: KPIs, monthly revenue, hours and cost trends, margin buckets, revenue concentration
- Per project, up to 20: monthly hours, revenue and cost, team composition, the roster with each person's months
- Per resource, up to 30: monthly actual, billable, PTO and Tempo planned hours, cost rates, project allocation, bench signals
- Workforce composition: FTE and contractor mix, average rates, bench and PTO lists, contractor billing
The preamble defines the terms before any data arrives: "Margin means (Revenue minus Estimated Labor Cost) divided by Revenue." Allocated, billable, crew and FTE get the same treatment.