The Profitability Console is a single-page HTML dashboard built for MSP leadership and account managers who need to answer one core question at a glance: which clients are making money and which ones are eroding it?
It renders per-client gross margins, effective hourly rates, cost breakdowns, upsell opportunities, and trend data from a unified data layer. In demo mode, all data is generated client-side and auto-simulated every 5 seconds. In production, data flows from ConnectWise PSA (ticket volume, labor hours, cost categories) and QuickBooks (vendor costs, billing actuals).
MSP owners, CEOs, vCIOs, and account managers conducting monthly business reviews, QBRs, pricing audits, or identifying clients at risk of churn due to service delivery cost overruns.
Which clients need a price increase conversation. Which ones have upsell gaps worth pursuing. Which service lines are dragging portfolio margin. Where labor hours are misaligned to contract value.
The dashboard is a single scrolling page with four logical zones navigated via the sticky topbar and the right-side dot navigation. There are no tabs — all content is always rendered.
| Zone | ID | Purpose |
|---|---|---|
| Hero + KPI Strip | #hero |
Title, description, feature chips. KPI strip renders below the hero inside its own padded wrapper. Always visible at load. |
| Filter Bar | .filter-bar |
Sticky bar below the topbar with Period, Margin tier, Service type dropdowns and a client search. Drives the client grid in real time. |
| Client Portfolio | #s-portfolio |
Responsive card grid of all clients. Each card is clickable and expands a deep-dive panel inline below the grid. |
| Aggregate Insights | #s-insights |
Five Chart.js charts covering service contribution margins, 90-day margin forecast, client distribution, 12-month MRR vs cost trend, and EHR by client. |
| Erosion Alerts | #s-alerts |
Static alert feed of 8 pre-defined alerts with high / medium / low severity, client name, description, and detection source metadata. |
#sidenav) activates via scroll position using offsetTop comparisons. The topbar links scroll to named section IDs. Both are driven by the same navTo(id) function and updateSideNav() scroll listener. The dots are hidden on viewports below 900px.
The KPI strip is a horizontal row of seven tiles rendered by renderKPIs(). It re-renders every 5 seconds as part of the live simulation. Each tile has a color-coded top accent bar, a headline value, a delta indicator, and a mini sparkline generated from random bar heights.
| KPI | Calculation | Color | Delta Label |
|---|---|---|---|
| Total MRR | Sum of client.mrr across all clients | blue | Hardcoded +3.2% MoM |
| Overall Gross Margin | Average of client.margin | green | Hardcoded +1.1% MoM |
| Net Profit MTD | Sum of (mrr - costs) across all clients | green | Hardcoded +8.4% YoY |
| Avg EHR | Average of client.ehr | yellow | Hardcoded -2.1% MoM |
| Upsell Pipeline | Total upsell flag count across all clients | blue | Hardcoded $42K potential |
| Erosion Alerts | Count of alertsData items with sev === 'high' | red | Total alert count |
| Forecast 90 Days | avgMargin + 2 | green | Hardcoded +2% projected |
Each client is rendered as a card in a responsive auto-fill grid with a minimum column width of 300px. Cards are color-coded by margin tier, show six metrics at a glance, include a margin progress bar, and surface the first upsell flag if one exists.
Margin Tier System
Card Metrics
| Field | Source | Color Logic |
|---|---|---|
| MRR | client.mrr | Always cyan — revenue is neutral |
| Costs | client.costs | Always muted — informational |
| Gross Margin | client.margin + client.delta | Green / yellow / red matching tier. Delta colored green if positive, red if negative. |
| Net Profit | mrr - costs | Always green — assumes positive; no negative state in demo |
| EHR | client.ehr | Green ≥ $160/hr, yellow ≥ $130/hr, red below $130/hr |
| Tier | client.tier | Muted label — enterprise / mid-market / smb |
Margin Bar
The thin bar at the bottom of each card is a visual representation of client.margin as a percentage width. It uses the same green/yellow/red color as the margin tier. At 100% margin the bar would be full-width; at 20% it is narrow. This gives an instant visual density read across the grid without reading numbers.
Upsell Flag Preview
If a client has one or more upsell opportunities, a yellow banner shows the count and the first flag title truncated at the dash character. If none exist, a muted "No upsell flags" line appears instead. Clicking the card opens the full upsell list in the Deep Dive panel.
Clicking any client card triggers toggleDeepDive(id), which renders a full-width panel directly below the client grid (#deepDive). Clicking the same card again, or the × button, calls hideDeepDive(). Only one deep dive can be open at a time — opening a second one closes the first.
setTimeout(..., 50ms) delay to allow the panel to become visible in the DOM before Chart.js reads canvas dimensions. When the panel is closed, all three chart instances are explicitly destroyed via chartInstances[id].destroy() to prevent memory leaks and canvas reuse conflicts.
Deep Dive Components
| Component | Chart Type | What It Shows |
|---|---|---|
| EHR Trend · 6 Months | Line (Chart.js) | Simulated 6-month EHR history ending at client.ehr, with a dashed green target line at $160/hr. Includes current EHR, target range, and delta vs. target in a stat list below. |
| Service Contribution | Doughnut (Chart.js) | Percentage breakdown of revenue by service line using client.breakdown (security / backup / support / other or hardware). Legend positioned to the right. |
| Cost vs Revenue · Monthly | Bar waterfall (Chart.js) | Decomposition of MRR into labor (55% of costs), vendor (30% of costs), overhead (remainder), and net profit. Negative bars shown in red and orange. Stat list shows MRR, Costs, Net Profit, Gross Margin. |
| Upsell Opportunities | HTML list (no chart) | All upsell flags for the client with title and estimated MRR uplift detail. If none exist, a "well-covered" placeholder is shown. |
[start, end] data arrays.
The Insights section renders five Chart.js charts in a 2-column grid via renderInsightCharts(), called once on page load. These charts use portfolio-level demo data — they do not re-render on the 5-second simulation tick or respond to the filter bar.
| Chart | ID | Type | Data Source |
|---|---|---|---|
| Service Contribution Margins | #chartService |
Grouped bar | Hardcoded: Revenue % and Margin % across 6 service lines. Replace with a live query aggregating PSA billing line items by category. |
| Margin Forecast · Next 90 Days | #chartForecast |
Dual line | Hardcoded actuals Apr–Sep, projected Oct–Dec. The gap/bridge between the two series is intentional — spanGaps: false creates the visual break. Replace projected series with a regression or pipeline model. |
| Client Margin Distribution | #chartDist |
Bar histogram | Computed live from clients array using 7 margin buckets (<20 through 70+). Colors shift red → yellow → cyan → green across buckets. This is the one insight chart that reflects the live client data. |
| MRR vs Cost Trend · 12 Months | #chartMRR |
Dual line with fill | Random walk starting at $265K MRR baseline with costs at ~44% of MRR. Replace with 12-month actuals from QuickBooks. The visual gap between the two lines is gross profit. |
| EHR by Client | #chartEHR |
Bar + line overlay | All clients sorted descending by EHR. Bars colored green ≥ $160, yellow ≥ $130, red below. Dashed target line at $160/hr. This chart is always sorted at render time and does not update on simulation ticks. |
Chart Configuration
All Chart.js instances share three helper functions that enforce the design system:
responsive: true, disables aspect ratio lock, sets animation to 600ms, and applies the dark tooltip style (dark background, JetBrains Mono, muted text colors).var(--dim) at 8px JetBrains Mono. Merged via spread into scales.y.scales.x.All chart instances are stored in the chartInstances object keyed by canvas ID. Deep-dive charts use this same store and are destroyed on panel close. The portfolio-level insight charts are never destroyed — they persist for the page lifetime.
The Erosion Alerts section is a static feed rendered once by renderAlerts() at page load. The alertsData array is hardcoded — alerts do not update during the live simulation. In production, replace the array with API calls to your PSA's cost variance reports and billing anomaly endpoints.
| Severity | Visual | Intended Use |
|---|---|---|
| HIGH | Red badge + red border on hover + 🔴 icon | Immediate financial risk — margin at or below critical threshold, active cost spike, labor ratio collapse |
| MEDIUM | Yellow badge + 🟡 icon | Trend-based warnings — 3-month declining deltas, overrun storage tiers, misaligned engineer hours |
| LOW | Cyan badge + 🔵 icon | Opportunity alerts — seat utilization approaching cap, supplier cost increases not re-quoted, upsell windows |
Alert Data Shape
{ client: 'Zeta Health', msg: 'Support hours +38% MoM — EHR dropped from $142 to $98.', sev: 'high', // 'high' | 'med' | 'low' meta: 'Detected via PSA ticket volume · 2h ago' }
The meta field is intended to communicate where the alert came from — PSA ticket analysis, QuickBooks cost sync, vendor billing API, or trend analysis. In production this field should be populated programmatically with the detection source and timestamp.
The filter bar sits between the KPI strip and the Client Portfolio section. It controls the renderClientGrid() output in real time. All four controls share a single getFiltered() function that applies each condition sequentially.
| Control | ID | Type | Filter Logic |
|---|---|---|---|
| Period | #dateFilter | Select | Cosmetic only in demo mode. Options: MTD, QTD, YTD. Wire to a date-range query parameter in production to re-fetch data for the selected window. |
| Margin | #marginFilter | Select | Filters by client.color: green = margin > 40%, yellow = 20–40%, red = < 20%. The all option shows all clients. |
| Service | #serviceFilter | Select | Filters by exact match on client.service: security, backup, breakfix, hardware. |
| Client Search | #clientSearch | Text input | Case-insensitive substring match on client.name.toLowerCase(). Filters on every input event — no submit required. |
addEventListener('change', applyFilters). The search input uses addEventListener('input', applyFilters). If you add new filter controls, follow this same pattern — getFiltered() is the single function to modify, and applyFilters() just calls renderClientGrid(getFiltered()).
The "Showing N clients" count is updated inside renderClientGrid() by setting document.getElementById('clientCount').textContent. This count reflects the filtered list, not the total client count.
All client data lives in a single const clients array at the top of the script block. Each object represents one managed client and carries all fields needed for every component — the grid, filters, deep dive, and aggregate charts.
{ id: 1, name: 'Acme Corp', mrr: 8500, // Monthly recurring revenue ($) costs: 3400, // Monthly delivery costs ($) margin: 60, // Gross margin % — (mrr-costs)/mrr * 100 delta: 2, // Margin change MoM (%). Mutated by simulation. ehr: 185, // Effective hourly rate ($/hr). Mutated by simulation. tier: 'enterprise',// 'enterprise' | 'mid-market' | 'smb' service: 'security', // Primary service: 'security'|'backup'|'breakfix'|'hardware' color: 'green', // Margin tier: 'green'|'yellow'|'red' upsells: [ // Array of upsell opportunity objects (can be empty) { title: 'EDR Gap — 42 unprotected endpoints', detail: 'Potential MRR uplift: +$4,200/mo · CrowdStrike Falcon Go' } ], breakdown: { // Service contribution % for doughnut chart (must sum to 100) security: 62, backup: 18, support: 12, other: 8 // OR 'hardware' for hardware-primary clients } }
margin field and the color field must be consistent. The color is not computed from the margin — it is a separate field. If you update a client's margin via live data and the color does not update to match, the card will show the wrong tier color. In production, derive color programmatically: margin > 40 ? 'green' : margin >= 20 ? 'yellow' : 'red'.
Alert Data
Alerts live in a separate const alertsData array. They reference clients by name string (not by ID) — this is a loose coupling that is fine for demo purposes but should be tightened to use client ID in production to avoid name-change mismatches.
The dashboard is wired for two primary integrations. The topbar labels them: PSA + Billing API. In the demo, both are replaced by the static clients and alertsData arrays.
Provides ticket volume, time entries, labor hours per client, and ticket type breakdown (managed vs. break-fix). Used to calculate EHR and labor cost contribution.
Key endpoints: GET /service/tickets, GET /time/entries, GET /service/tickets/count
Provides vendor costs, license spend, and billing actuals per client. Used to compute true costs and validate MRR figures against invoiced amounts.
Key endpoints: QuickBooks Online REST API — GET /query?query=SELECT * FROM Invoice WHERE CustomerRef
Replacing Demo Data with Live Feeds
- 01Fetch CW time entries per client — billable only, admin excluded. Query
/time/entries?conditions=dateEntered>=[period_start] AND billableOption="Billable" AND workType/name NOT IN ("Admin","Internal","Meeting","Training"), group by company, sum hours, multiply by internal rate to get labor cost. This replaces the hardcodedcostsfield (currently costs = ~44% of MRR). Admin time entries must be excluded before this calculation — including them inflates the labor cost figure, compresses the margin, and makes healthy clients appear unprofitable. Check your CW Setup Tables → Work Types to confirm the exact names used in your instance. - 02Pull MRR from QuickBooks invoices. Query all recurring invoices in the billing period, group by customer reference. This replaces the hardcoded
mrrfield. - 03Compute margin server-side. Calculate
margin = Math.round((mrr - costs) / mrr * 100)and assigncolorprogrammatically. Never trust a hardcoded color field in production. - 04Derive EHR from CW time entries — client-facing hours only. EHR =
mrr / totalHoursThisPeriod.totalHoursThisPeriodmust use the same admin-excluded, billable-only time entry set from Step 01. If admin hours are included, EHR is artificially deflated — a tech who spent 4 hours in internal meetings against a client's timesheet will make that client look less profitable than they are. Normalize to hours worked per month, not hours billed. - 05Generate upsell flags from coverage gaps. Query CW configuration items and asset inventory to identify clients missing EDR, SIEM, backup tiers, or M365 licensing. Construct the upsell objects programmatically and inject into each client record.
- 06Generate alerts from anomaly detection. Compare current-period EHR, margin, and ticket type ratios against trailing averages. Flag where delta exceeds defined thresholds and build the
alertsDataarray dynamically.
The dashboard simulates live data movement using a setInterval that fires every 5,000ms. This is purely cosmetic for demo environments — it shows stakeholders that the dashboard would feel alive with real API data.
setInterval(() => { clients.forEach(c => { // Random walk delta: ±1 with 60% probability of moving c.delta = Math.max(-15, Math.min(10, c.delta + (Math.random() > .6 ? 1 : -1) )); // Random walk EHR: ±1, clamped $80–$230 c.ehr = Math.max(80, Math.min(230, c.ehr + (Math.random() > .5 ? 1 : -1) )); }); renderKPIs(); // Re-renders KPI strip + sparklines applyFilters(); // Re-renders client grid with current filters }, 5000);
client.delta and client.ehr directly on the data objects. In production, replace this interval with a polling function that re-fetches live API data and rebuilds the clients array. Do not ship the simulation interval in a production deployment — it will overwrite real data with noise on every tick.
Note that applyFilters() calls renderClientGrid(getFiltered()) — so the simulation respects whatever filters are currently active. The insight charts and alert feed are not re-rendered by the simulation tick.
Go-Live Checklist
- 01Remove the live simulation interval. Delete the entire
setInterval()block at the bottom of the script. Replace with arefreshData()function that re-fetches from your APIs on a sensible polling cadence (5–15 minutes for financial data). - 02Replace the clients array with an API fetch. Build a data pipeline that queries CW and QuickBooks, normalizes the results into the client object shape documented in Section 09, and populates the array on load.
- 03Derive margin and color programmatically. Never hardcode the
marginorcolorfields. Compute them from live MRR and costs at fetch time. - 04Replace hardcoded KPI deltas. The
+3.2% MoMand similar strings inrenderKPIs()need to be computed from period-over-period comparisons. At minimum, pull the previous period's total MRR and compute the real delta. - 05Address the Chart.js CDN dependency. If this dashboard will be served in an environment without reliable internet access, host Chart.js locally or bundle it. The file currently loads from
cdnjs.cloudflare.com— if that request fails, the entire Insights section and all deep-dive charts will be blank. - 06Update nav.js path. The file references
<script src="../nav.js" defer></script>. Ensure this resolves correctly relative to where the file is deployed in your folder structure, or replace with an absolute path. - 07Verify Chart.js canvas sizing at target resolutions. The insight charts use
height: 200pxorheight: 240pxon their wrapper divs. Test at your target screen resolution — the full-width EHR bar chart may need a taller wrapper at high client counts. - 08Review mobile breakpoint. Below 900px, the grid collapses to single column, the side navigation dots are hidden, and section padding reduces. Test on the devices your team uses for in-meeting presentations.
Adding a New Client
Push a new object to the clients array matching the full shape in Section 09. The client card grid, all filters, the margin distribution chart, and the EHR by client chart will all pick it up automatically on next render. No HTML changes required.
Adding a New KPI Tile
Add a new object to the kpis array inside renderKPIs(). Each tile takes four fields: label, value, delta, and cls (one of c-green, c-yellow, c-red, c-blue). The flexbox strip will wrap automatically — for clean grid alignment, keep the total count a multiple of 3 or 4.
Adding a New Insight Chart
- 01Add an
.insight-carddiv inside#s-insights .insights-gridwith a canvas element and a unique ID. Use.insight-card.fullfor a full-width chart. - 02Add the Chart.js instantiation inside
renderInsightCharts(), storing the instance aschartInstances['yourChartId']. - 03Use
...chartDefaults()spread,...yAxis(), and...xAxis()for design system consistency.
Adding a New Upsell Flag Type
Add a new object to a client's upsells array. The deep dive panel renders all upsells for the selected client — there is no maximum count, though more than 4–5 will require scrolling in the panel. The card preview only ever shows the first flag's title (truncated at the dash).
Adding a New Service Type to Filters
Add a new <option value="myservice"> to the #serviceFilter select. Set the matching client.service string on the relevant client objects. No JavaScript changes needed — getFiltered() does an exact string match against the select value.