A full analytics platform inside NetSuite: six tabs, dozens of visualizations, AI analysis, CSV exports, scenario modelling. All in ES5. All in a single file. All rendered as concatenated strings.
Nobody asked for this. But here we are.
The constraints
NetSuite Suitelets are server-side scripts that return HTML. The runtime is SuiteScript 2.1: ES5 only. No arrow functions, no template literals, no destructuring, no modules beyond what NetSuite provides. Your entire dashboard gets built as string concatenation:
var html = '';
html += '<div class="dashboard">';
html += '<h2>' + esc(project.name) + '</h2>';
html += '<span class="metric">' + formatHours(project.totalHours) + '</span>';
html += '</div>';
String concatenation and prayer. The original JSX.
What it does
The dashboard grew feature by feature from a basic time entry report into a full professional services analytics platform:
- Resource utilisation: monthly hours heatmaps with PTO inline, filterable by team, location, title
- Project profitability: waterfall charts, burn profiles with revenue overlays, margin distribution
- Contractor rate analysis: true cost rates extracted from billing data, benchmarked against loaded labour costs
- Availability planning: Tempo integration for planned vs actual hours, sparkline trends, bench risk signals
- Engagement modelling: scenario builder with SVG stacked bar charts for team composition and margins
- Analysis via N/llm: NetSuite's
N/llmmodule calling LLMs from SuiteScript
The two-phase iframe loader
NetSuite has a gateway timeout that kills long requests. The dashboard loads six saved searches with thousands of rows. The solution: Phase 1 returns a lightweight page with a spinner and an iframe pointing back to the same script:
if (!request.parameters.render) {
var loadingHtml = '';
loadingHtml += '<div class="loading-spinner"></div>';
loadingHtml += '<iframe src="' + scriptUrl + '&render=1" ';
loadingHtml += 'style="width:100%;height:100%;border:none;"></iframe>';
response.write(loadingHtml);
return;
}
// Phase 2: actual dashboard rendering
The iframe loads the real dashboard in the background. The user sees a loading animation. NetSuite doesn't kill the request. Simple enough, once you know NetSuite has a gateway timeout, which is not documented anywhere obvious.
Server-side data, client-side rendering
The server loads all data, processes it into structured objects, then serialises them as inline script tags. Client JavaScript handles filtering, sorting, tab switching, and chart rendering:
// Server: serialize processed data
html += '<script>var tlProjects = ' + JSON.stringify(projectData) + ';</script>';
html += '<script>var tlResources = ' + JSON.stringify(resourceData) + ';</script>';
// Client: render from serialized data
function renderProjectTable(projects, filters) {
var rows = projects.filter(function(p) {
return (!filters.team || p.team === filters.team);
});
// build table HTML from filtered rows
}
Poor man's SSR-to-CSR hydration: JSON.stringify on the server, var declarations on the client.
The vendor name extraction waterfall
To calculate true contractor cost rates, I need to match consulting bills to the people who did the work. Each staffing agency formats their bill memos differently. Some put the name in the main line, some bury it in the memo field, some use brackets, some use dashes. The data is a mess. This is a multi-step extraction cascade:
function extractConsultantName(bill) {
var vendor = cleanVendorName(bill.vendor); // strip GUIDs, trailing dashes
// Step 1: Is it an individual name? (no LLC, Inc, Ltd)
if (isIndividualName(vendor)) return vendor;
// Step 2: Vendor-specific regex patterns
for (var i = 0; i < VENDOR_PATTERNS.length; i++) {
var match = bill.memo.match(VENDOR_PATTERNS[i].regex);
if (match) return normalizeName(match[1]);
}
// Step 3: Fall back to notes field
var notesMatch = extractFromNotes(bill.notes);
if (notesMatch) return notesMatch;
// Step 4: Case-insensitive roster match
var rosterMatch = fuzzyMatchRoster(vendor, employeeRoster);
if (rosterMatch) return rosterMatch;
return { name: null, reason: 'no-match', raw: vendor };
}
Four fallback steps because the data required four fallback steps. It correctly extracts consultant names from ~95% of bills across a dozen different staffing agencies. The other 5% are logged and reviewed manually, which is at least better than not knowing.
The N/llm module
NetSuite's N/llm module lets you call LLMs from SuiteScript. A companion RESTlet receives processed dashboard data, constructs RAG documents, and calls llm.generateText():
var llm = require('N/llm');
var response = llm.generateText({
prompt: preamble + '\n\n' + ragDocuments.join('\n\n') + '\n\nQuestion: ' + question,
modelFamily: llm.ModelFamily.COHERE_COMMAND,
modelParameters: {
maxTokens: 2000,
temperature: 0.3,
topP: 0.9,
frequencyPenalty: 0.3
}
});
The first implementation summarised data into tidy aggregates before sending it: "Total hours: 12,450. Average utilisation: 78%." The model hallucinated constantly. Turns out if you collapse all the dimensionality out of your data before feeding it to an LLM, the LLM fills the gaps with confident-sounding fiction.
The fix was a 4-tier document architecture that gives the LLM raw tabular data:
- Tier 1: Portfolio snapshot with monthly trend tables (
Jan: $120K | Feb: $95K | Mar: $140K) - Tier 2: Per-project documents with monthly hours, revenue, cost tables and full team rosters
- Tier 3: Per-resource documents with monthly actuals, PTO, Tempo planned hours, project allocations
- Tier 4: Workforce composition: FTE vs contractor mix, average rates, bench list
Key insight: pipe-delimited monthly tables work better than JSON arrays for LLMs. Self-contained documents work better than cross-referenced data. And every prompt needs a terminology preamble so the model knows "utilisation rate" means billable hours / total hours.
Six tabs, six data sources, thousands of rows, five CSV exports, SVG charts, AI analysis, engagement profitability modelling. One JavaScript file. var all the way down. Still running in production. Nobody has asked me to refactor it.