@elitedcs/ghl-mcp 3.81.0 → 3.81.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +1 -1
- package/dist/index.js +1014 -41
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.81.1 — the assessment opens from every published cockpit, and the product says its own name
|
|
4
|
+
|
|
5
|
+
**The assessment page was missing from every installed copy of the cockpit since 3.75.0.**
|
|
6
|
+
Click Assessment in the Command OS cockpit on a normal install and it answered "The
|
|
7
|
+
assessment page is not in this install. Update GHL Command and restart the cockpit." The
|
|
8
|
+
page had been left out of the package on purpose on the belief that nothing served it,
|
|
9
|
+
while the cockpit screen that serves it shipped the same day. Every check since ran from
|
|
10
|
+
a development copy, where a spare copy of the page hid the gap. The page now travels
|
|
11
|
+
inside the program itself, so there is no file to find and nothing to leave out, and the
|
|
12
|
+
fix was proven by installing the packed release and opening the assessment from it, which
|
|
13
|
+
is the check that had been missing for seven releases.
|
|
14
|
+
|
|
15
|
+
**What a member reads calls the product GHL Command.** The checkup's status table had
|
|
16
|
+
been labelling its first row "MCP version". That row now says "GHL Command version", and
|
|
17
|
+
the same correction runs through the health check, the version check, the current-account
|
|
18
|
+
readout, setup and the command line. The names you type to run those tools have not
|
|
19
|
+
changed.
|
|
20
|
+
|
|
21
|
+
**The $97 product is unchanged at 250 tools, 51 modules, 112 free.** The assessment is
|
|
22
|
+
behind the Command OS gate; the wording fix reaches everyone.
|
|
23
|
+
|
|
3
24
|
## 3.81.0 — one cockpit, and a launcher that makes itself, for Command OS subscribers
|
|
4
25
|
|
|
5
26
|
The Command OS cockpit had grown into four screens that read as four products: the
|
package/README.md
CHANGED
package/dist/index.js
CHANGED
|
@@ -16683,7 +16683,7 @@ function registerSetupTool(server2, pkgVersion) {
|
|
|
16683
16683
|
if (pkgVersion) setupPkgVersion = pkgVersion;
|
|
16684
16684
|
server2.tool(
|
|
16685
16685
|
"setup_ghl_mcp",
|
|
16686
|
-
"First-run setup AND license upgrades for GHL Command
|
|
16686
|
+
"First-run setup AND license upgrades for GHL Command. Validates your license and GHL credentials, then writes them to a per-user credentials file; restart Claude once after it completes. UPGRADING an existing install (e.g. free key to paid)? Supply ONLY email + license_key \u2014 the GHL credentials and any Workflow Builder unlock already saved on this machine are reused and kept; do not re-enter ghl_api_key or ghl_location_id. No license yet? Paid ($97/mo) at https://ghlcommand.com \u2014 or a FREE read-only key, instantly, at https://ghlcommand.com/free.",
|
|
16687
16687
|
{
|
|
16688
16688
|
email: import_zod48.z.string().email().describe("Email used at purchase."),
|
|
16689
16689
|
license_key: import_zod48.z.string().min(20).describe("License key from your purchase email."),
|
|
@@ -16987,7 +16987,7 @@ function registerFirebaseCaptureScriptTool(server2) {
|
|
|
16987
16987
|
"}",
|
|
16988
16988
|
"```",
|
|
16989
16989
|
"",
|
|
16990
|
-
"**Step 5.** Come back to Claude. Run `setup_ghl_mcp` (first-time install) or `enable_workflow_builder` (already set up) and paste the JSON into the `firebase_paste` field.
|
|
16990
|
+
"**Step 5.** Come back to Claude. Run `setup_ghl_mcp` (first-time install) or `enable_workflow_builder` (already set up) and paste the JSON into the `firebase_paste` field. GHL Command reads it automatically \u2014 you don't need to fill the individual `ghl_user_id`, `ghl_firebase_api_key`, `ghl_firebase_refresh_token` fields.",
|
|
16991
16991
|
"",
|
|
16992
16992
|
"Step-by-step screenshots: https://elitedcs.com/ghl-mcp-firebase"
|
|
16993
16993
|
].join("\n");
|
|
@@ -16998,7 +16998,7 @@ function registerFirebaseCaptureScriptTool(server2) {
|
|
|
16998
16998
|
function registerInteractiveCaptureTool(server2) {
|
|
16999
16999
|
server2.tool(
|
|
17000
17000
|
"capture_firebase_interactive",
|
|
17001
|
-
"One-click Workflow Builder unlock (EASIEST PATH \u2014 try this before auto_capture_firebase_script). Also the unlock for the FREE tier's account auditor (audit_workflows, validate_workflow) \u2014 same one-time login. Opens a Chrome window where you just log into GHL;
|
|
17001
|
+
"One-click Workflow Builder unlock (EASIEST PATH \u2014 try this before auto_capture_firebase_script). Also the unlock for the FREE tier's account auditor (audit_workflows, validate_workflow) \u2014 same one-time login. Opens a Chrome window where you just log into GHL; GHL Command captures the Firebase credentials itself and saves them. No DevTools, no pasting. Re-run the same tool if the first call says it's still waiting for your login. After the first successful run, future re-captures (refresh-token rotations) happen silently with no window at all. Falls back cleanly: if no Chrome/Edge is installed, use auto_capture_firebase_script instead.",
|
|
17002
17002
|
{},
|
|
17003
17003
|
async () => {
|
|
17004
17004
|
const existing = readCredentials();
|
|
@@ -17239,10 +17239,10 @@ function withSwitchLock(fn) {
|
|
|
17239
17239
|
}
|
|
17240
17240
|
function registerLocationSwitcherTools(server2, client, builderClient, registry2, mcpVersion) {
|
|
17241
17241
|
const versionLine = mcpVersion ? `
|
|
17242
|
-
|
|
17242
|
+
GHL Command version: v${mcpVersion}` : "";
|
|
17243
17243
|
server2.tool(
|
|
17244
17244
|
"get_current_location",
|
|
17245
|
-
"Show which GHL sub-account (location) is currently active, including API key status and installed
|
|
17245
|
+
"Show which GHL sub-account (location) is currently active, including API key status and the installed GHL Command version. All tools use this location by default unless you specify a different one.",
|
|
17246
17246
|
{},
|
|
17247
17247
|
async () => {
|
|
17248
17248
|
const locId = client.defaultLocationId || "NOT SET";
|
|
@@ -21314,11 +21314,11 @@ async function collectInstallChecks(installedVersion, client, builderClient, reg
|
|
|
21314
21314
|
]);
|
|
21315
21315
|
let versionCheck;
|
|
21316
21316
|
if (versionStatus.latest === null) {
|
|
21317
|
-
versionCheck = { name: "
|
|
21317
|
+
versionCheck = { name: "GHL Command version", status: "warn", detail: `You're on v${versionStatus.installed}. Could not reach npm to check for a newer version: ${versionStatus.fetchError ?? "unknown"}.` };
|
|
21318
21318
|
} else if (versionStatus.upToDate) {
|
|
21319
|
-
versionCheck = { name: "
|
|
21319
|
+
versionCheck = { name: "GHL Command version", status: "pass", detail: `Up to date at v${versionStatus.installed}.` };
|
|
21320
21320
|
} else {
|
|
21321
|
-
versionCheck = { name: "
|
|
21321
|
+
versionCheck = { name: "GHL Command version", status: "warn", detail: `You're on v${versionStatus.installed}; the latest is v${versionStatus.latest}. Quit Claude (Cmd+Q) and reopen to update.` };
|
|
21322
21322
|
}
|
|
21323
21323
|
const gatingCheck = (() => {
|
|
21324
21324
|
const r = getGatingReport?.();
|
|
@@ -21379,7 +21379,7 @@ function summarizeChecks(checks) {
|
|
|
21379
21379
|
function registerDiagnosticTools(server2, installedVersion, client, builderClient, registry2, getGatingReport, plan = "mcp") {
|
|
21380
21380
|
server2.tool(
|
|
21381
21381
|
"health_check",
|
|
21382
|
-
"Run a full health check of
|
|
21382
|
+
"Run a full health check of your GHL Command install. Reports on: version status, GHL API key validity, default location reachability, Firebase auth status (workflow builder), token registry, and the tool-gating policy (disabled tools/modules, account-admin opt-in, typos in gating env vars). Returns a structured pass/fail/warn list with detail per check. Use this when something feels broken or after credentials change. For a wider report that also probes every GHL capability live and shows what you are not yet using, run `run_checkup`.",
|
|
21383
21383
|
{},
|
|
21384
21384
|
async () => {
|
|
21385
21385
|
const checks = await collectInstallChecks(installedVersion, client, builderClient, registry2, getGatingReport, plan);
|
|
@@ -41315,6 +41315,1004 @@ var init_agency_profile_page = __esm({
|
|
|
41315
41315
|
}
|
|
41316
41316
|
});
|
|
41317
41317
|
|
|
41318
|
+
// src/command-os/assessment/app-html.ts
|
|
41319
|
+
var ASSESSMENT_HTML;
|
|
41320
|
+
var init_app_html = __esm({
|
|
41321
|
+
"src/command-os/assessment/app-html.ts"() {
|
|
41322
|
+
"use strict";
|
|
41323
|
+
ASSESSMENT_HTML = `<!doctype html>
|
|
41324
|
+
<html lang="en">
|
|
41325
|
+
<head>
|
|
41326
|
+
<meta charset="utf-8">
|
|
41327
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
41328
|
+
<!-- The page had neither of the two lines above until 2026-08-30, and it holds
|
|
41329
|
+
~1,600 non-ASCII bytes. Chrome read it as windows-1252 in QUIRKS MODE, so
|
|
41330
|
+
every em-dash in the interview rendered as mojibake \u2014 including the two in
|
|
41331
|
+
the header beside the hours and money figures, which is the state an
|
|
41332
|
+
operator sees the moment they open it. The exported report always had both
|
|
41333
|
+
lines; only the shell that writes it was missing them. Caught by a browser
|
|
41334
|
+
pass, because nothing in the suite had ever loaded the page in a browser. -->
|
|
41335
|
+
<title>Assessment \u2014 Command OS</title>
|
|
41336
|
+
<!--COCKPIT_SHELL_CSS-->
|
|
41337
|
+
<style>
|
|
41338
|
+
:root{
|
|
41339
|
+
/* No webfont, and no palette of its own. This page is opened in a clinic with
|
|
41340
|
+
no wifi and handed to a client's accountant, so it may not fetch a stylesheet
|
|
41341
|
+
from Google \u2014 the cockpit injects the shared shell's tokens above this block
|
|
41342
|
+
with webfont:false, and these names are aliases onto them. Before the hub
|
|
41343
|
+
audit of 2026-09-04 this screen had its own dark-slate palette and read as a
|
|
41344
|
+
different product from the board. --ink, --warn and --shadow are NOT redefined
|
|
41345
|
+
here: they come from the shell under the same names. */
|
|
41346
|
+
--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
|
|
41347
|
+
--serif:var(--font-display);--sans:var(--font-body);
|
|
41348
|
+
--ground:var(--bg);--panel:var(--bg-2);--panel-2:var(--surface-2);--paper:var(--surface);
|
|
41349
|
+
--muted:var(--ink-2);--rule:var(--line);--accent:var(--gold);--soft:var(--gold-soft);
|
|
41350
|
+
--stop:var(--rose);--go:var(--ok)}
|
|
41351
|
+
*{box-sizing:border-box}
|
|
41352
|
+
body{margin:0;background:var(--ground);color:var(--ink);font-family:var(--sans);
|
|
41353
|
+
font-size:1rem;line-height:1.5}
|
|
41354
|
+
/* Sticks BELOW the cockpit header (3rem tall, z-index 40), never over it:
|
|
41355
|
+
at z-index 50 and top:0 it covered the only way back to the board on scroll. */
|
|
41356
|
+
.top{background:var(--paper);border-bottom:1px solid var(--rule);padding:.6rem .9rem;position:sticky;top:3rem;z-index:30}
|
|
41357
|
+
.topin{max-width:60rem;margin:0 auto;display:flex;gap:1rem;align-items:baseline;flex-wrap:wrap}
|
|
41358
|
+
.topin h1{font-family:var(--serif);font-size:1.05rem;margin:0;font-weight:600}
|
|
41359
|
+
.who{color:var(--muted);font-size:var(--fs-sm)}
|
|
41360
|
+
.live{margin-left:auto;display:flex;gap:1rem;font-family:var(--mono);font-size:var(--fs-label)}
|
|
41361
|
+
.live b{display:block;font-size:.92rem;font-weight:500}
|
|
41362
|
+
.live .m{color:var(--stop)}.live .h{color:var(--accent)}.live .p{color:var(--muted)}
|
|
41363
|
+
.tabs{max-width:60rem;margin:0 auto;display:flex;gap:.3rem;padding:.5rem .9rem 0}
|
|
41364
|
+
.tabs button{background:none;border:none;border-bottom:2px solid transparent;padding:.3rem .5rem;
|
|
41365
|
+
cursor:pointer;font:inherit;font-size:var(--fs-sm);color:var(--muted)}
|
|
41366
|
+
.tabs button.on{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
|
|
41367
|
+
.main{max-width:60rem;margin:0 auto;padding:.9rem;display:grid;grid-template-columns:13rem 1fr;gap:1rem}
|
|
41368
|
+
@media(max-width:760px){.main{grid-template-columns:1fr}}
|
|
41369
|
+
.rail{align-self:start;position:sticky;top:4.2rem}
|
|
41370
|
+
@media(max-width:760px){.rail{position:static;display:flex;overflow-x:auto;gap:.3rem}}
|
|
41371
|
+
.rail button{display:block;width:100%;text-align:left;background:none;border:none;cursor:pointer;font:inherit;
|
|
41372
|
+
font-size:var(--fs-sm);color:var(--muted);padding:.32rem .45rem;border-radius:.22rem;line-height:1.3}
|
|
41373
|
+
@media(max-width:760px){.rail button{white-space:nowrap;width:auto}}
|
|
41374
|
+
.rail button.on{background:var(--accent);color:var(--paper);font-weight:600}
|
|
41375
|
+
.rail button:hover:not(.on){background:var(--soft);color:var(--accent)}
|
|
41376
|
+
.rail .c{font-family:var(--mono);font-size:var(--fs-label);opacity:.75;float:right}
|
|
41377
|
+
.intro{color:var(--muted);font-size:var(--fs-sm);font-style:italic;margin:0 0 .8rem;max-width:60ch}
|
|
41378
|
+
h2{font-family:var(--serif);font-size:1.16rem;margin:0 0 .15rem;font-weight:600}
|
|
41379
|
+
.q{background:var(--paper);border:1px solid var(--rule);border-left:3px solid var(--rule);
|
|
41380
|
+
border-radius:.3rem;padding:.6rem .75rem;margin:.5rem 0}
|
|
41381
|
+
.q.done{border-left-color:var(--go)}.q.skip{border-left-color:var(--muted);opacity:.72}
|
|
41382
|
+
.q .ask{font-size:.9rem;font-weight:600;margin:0 0 .2rem}
|
|
41383
|
+
.q .probe{font-size:var(--fs-sm);color:var(--muted);font-style:italic;margin:0 0 .35rem}
|
|
41384
|
+
.q .why{font-size:var(--fs-sm);color:var(--muted);margin:.3rem 0 0;display:none}
|
|
41385
|
+
.q.open .why{display:block}
|
|
41386
|
+
.row{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap}
|
|
41387
|
+
input.a,select.a,textarea.a{background:var(--ground);border:1px solid var(--rule);border-radius:.22rem;
|
|
41388
|
+
padding:.3rem .45rem;font:inherit;font-size:var(--fs-sm);color:var(--ink);max-width:11rem}
|
|
41389
|
+
textarea.a{max-width:100%;width:100%;min-height:2.6rem}
|
|
41390
|
+
input.a:focus,select.a:focus,textarea.a:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
41391
|
+
.unit{font-family:var(--mono);font-size:var(--fs-label);color:var(--muted)}
|
|
41392
|
+
.mini{background:none;border:1px solid var(--rule);border-radius:.2rem;padding:.2rem .45rem;cursor:pointer;
|
|
41393
|
+
font:inherit;font-size:var(--fs-sm);color:var(--muted)}
|
|
41394
|
+
.mini:hover{border-color:var(--accent);color:var(--accent)}
|
|
41395
|
+
.mini.on{background:var(--panel-2);color:var(--ink)}
|
|
41396
|
+
.card{background:var(--paper);border:1px solid var(--rule);border-radius:.35rem;padding:.8rem .9rem;
|
|
41397
|
+
margin:.6rem 0;box-shadow:var(--shadow)}
|
|
41398
|
+
table{border-collapse:collapse;width:100%;font-size:var(--fs-sm)}
|
|
41399
|
+
th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
|
41400
|
+
th{font-size:var(--fs-label);text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600}
|
|
41401
|
+
.num{font-family:var(--mono);font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
|
|
41402
|
+
th.num{text-align:right}
|
|
41403
|
+
.note{background:var(--panel);border-left:3px solid var(--accent);border-radius:.22rem;padding:.5rem .65rem;
|
|
41404
|
+
margin:.5rem 0;font-size:var(--fs-sm)}
|
|
41405
|
+
.note.warn{border-left-color:var(--warn)}.note.go{border-left-color:var(--go)}.note.stop{border-left-color:var(--stop)}
|
|
41406
|
+
.note b{display:block;margin-bottom:.1rem}
|
|
41407
|
+
.btn{background:var(--accent);color:var(--paper);border:none;border-radius:.22rem;padding:.4rem .8rem;
|
|
41408
|
+
font:inherit;font-size:var(--fs-sm);font-weight:600;cursor:pointer}
|
|
41409
|
+
.btn.ghost{background:none;color:var(--accent);border:1px solid var(--rule)}
|
|
41410
|
+
.btnrow{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.9rem}
|
|
41411
|
+
.assume{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:center;padding:.35rem 0;
|
|
41412
|
+
border-bottom:1px solid var(--rule);font-size:var(--fs-sm)}
|
|
41413
|
+
.hide{display:none}
|
|
41414
|
+
</style>
|
|
41415
|
+
</head>
|
|
41416
|
+
<body>
|
|
41417
|
+
<!--COCKPIT_HEADER-->
|
|
41418
|
+
|
|
41419
|
+
<div class="top"><div class="topin">
|
|
41420
|
+
<h1 id="bizname">New assessment</h1>
|
|
41421
|
+
<span class="who" id="bizmeta"></span>
|
|
41422
|
+
<div class="live">
|
|
41423
|
+
<div class="p"><b id="lProg">0/46</b>answered</div>
|
|
41424
|
+
<div class="h"><b id="lHrs">—</b>hrs/week</div>
|
|
41425
|
+
<div class="m"><b id="lMon">—</b>/month</div>
|
|
41426
|
+
</div>
|
|
41427
|
+
</div></div>
|
|
41428
|
+
<div class="tabs">
|
|
41429
|
+
<button data-t="ask" class="on">Ask</button>
|
|
41430
|
+
<button data-t="set">Assumptions</button>
|
|
41431
|
+
<button data-t="find">Findings</button>
|
|
41432
|
+
<button data-t="rep">Report</button>
|
|
41433
|
+
</div>
|
|
41434
|
+
|
|
41435
|
+
<div class="main">
|
|
41436
|
+
<nav class="rail" id="rail"></nav>
|
|
41437
|
+
<div id="body"></div>
|
|
41438
|
+
</div>
|
|
41439
|
+
|
|
41440
|
+
<script>
|
|
41441
|
+
/**
|
|
41442
|
+
* The assessment engine \u2014 the ONE place the questions and the findings maths live.
|
|
41443
|
+
*
|
|
41444
|
+
* Plain JavaScript with a single export block at the end, because it has two
|
|
41445
|
+
* consumers with incompatible needs: the test suite imports it, and build-app.mjs
|
|
41446
|
+
* inlines it into app.html (which must stay a single self-contained file, since
|
|
41447
|
+
* Command OS serves it from the operator's own machine with no build step and no
|
|
41448
|
+
* network). The build strips the export block; nothing else about the file changes.
|
|
41449
|
+
*
|
|
41450
|
+
* It existed as two copies for one day \u2014 once here and once inside app.html \u2014 and
|
|
41451
|
+
* two copies of a rule that decides what a client is told is a defect waiting to
|
|
41452
|
+
* happen, so it is one copy now.
|
|
41453
|
+
*/
|
|
41454
|
+
|
|
41455
|
+
var AREAS = [
|
|
41456
|
+
["money", "What the work is worth", "Before anything else I need two numbers, because everything today gets multiplied by them."],
|
|
41457
|
+
["phone", "The phone", "Let's start where most of it goes."],
|
|
41458
|
+
["speed", "How fast anyone replies", "Now the enquiries that don't come by phone."],
|
|
41459
|
+
["repeat", "The same questions, again", "This one is about your time rather than your money."],
|
|
41460
|
+
["quotes", "After the quote goes out", "Walk me through what happens after you send a price."],
|
|
41461
|
+
["booking", "Getting them in the diary", "How does an appointment actually get made?"],
|
|
41462
|
+
["dormant", "Everyone who went quiet", "Now the cheapest money in the building, and the part nobody works."],
|
|
41463
|
+
["reviews", "What people see about you", "Your reviews, and how they happen."],
|
|
41464
|
+
["website", "What your website does", "What the site is actually for."],
|
|
41465
|
+
["found", "Whether you get found", "And how people arrive in the first place."],
|
|
41466
|
+
["week", "Your own week", "Last part, and it's about you rather than the business."]
|
|
41467
|
+
];
|
|
41468
|
+
|
|
41469
|
+
var Q = [
|
|
41470
|
+
["avg_value","money","When you win a job, what's it worth on average?","Give me the middle of it. Not your best month, not the tiny ones.","currency","Every finding today gets multiplied by this. Without it I can't put a number on anything."],
|
|
41471
|
+
["close_rate","money","When you actually get someone talking properly, how often does that turn into work?","Out of ten real conversations, how many buy?","rate","Turns a lost enquiry into a lost job rather than a lost phone call."],
|
|
41472
|
+
["margin_basis","money","Do you want me to work in what the job brings in, or what you keep?","","text","The report says which one it used, every time."],
|
|
41473
|
+
["capacity","money","If I found you thirty percent more work tomorrow, could you do it?","What breaks first, crews or trucks or you?","text","If they can't deliver more, the whole report points at margin and time instead of volume."],
|
|
41474
|
+
|
|
41475
|
+
["calls_month","phone","Roughly how many calls come in on a normal month?","Think about a busy Tuesday and work out from there.","count","The base for everything else on the phone."],
|
|
41476
|
+
["calls_missed","phone","How many of those go to voicemail or just ring out?","What happens when you're up a ladder and it rings?","count","The most common leak, and the one owners underestimate most."],
|
|
41477
|
+
["missed_never_return","phone","Of the ones you miss, how many never call back?","Half? More?","rate","This is what stops us overclaiming. We only count the ones you genuinely lose."],
|
|
41478
|
+
["who_answers","phone","Who answers the phone when it rings?","And what were they doing before it rang?","text","If it's you, this is a time finding as well as a money one."],
|
|
41479
|
+
["after_hours_calls","phone","What share of your calls come in outside working hours?","Evenings and weekends. Storm season?","rate","Separates a staffing problem from an automation one."],
|
|
41480
|
+
["voicemail_fate","phone","When someone does leave a voicemail, what happens to it?","","text","'I get to them at night' is the after-hours finding in their own words."],
|
|
41481
|
+
|
|
41482
|
+
["nonphone_month","speed","How many enquiries a month come in some other way? Form, text, Facebook.","","count","Counted separately from calls so nobody gets counted twice."],
|
|
41483
|
+
["first_response","speed","From one of those landing to a real person replying, how long is that usually?","Same hour? Same day? Next morning?","minutes","The gap between arriving and being answered is where enquiries go cold."],
|
|
41484
|
+
["waited_month","speed","How many a month sit overnight before anyone replies?","","count","The slow-response finding is priced on this count, not on your average."],
|
|
41485
|
+
["reply_channel","speed","When you do reply, do you call them or write back?","","text","Shapes what we'd build, not what we'd charge."],
|
|
41486
|
+
|
|
41487
|
+
["repeat_questions","repeat","What are the three questions you answer over and over every week?","The ones where you know the answer before they finish asking.","text","Every one of these is a job a machine does perfectly and you do resentfully."],
|
|
41488
|
+
["repeat_time","repeat","How much of your week goes on answering those?","","hours","Straight into the hours number."],
|
|
41489
|
+
["who_fields","repeat","Who fields them, you or someone else?","","text","If it's a person on payroll, this finding has a salary attached."],
|
|
41490
|
+
|
|
41491
|
+
["quotes_month","quotes","How many quotes or estimates go out in a month?","","count","These are your warmest people. They already talked to you."],
|
|
41492
|
+
["quotes_won","quotes","How many of those turn into work?","","count","The gap between sent and won is the follow-up finding."],
|
|
41493
|
+
["quote_followup","quotes","What happens to a quote after you send it?","Be honest. Does anything chase it, or is it on them to call you?","text","'Nothing' is the most common answer in this whole assessment."],
|
|
41494
|
+
["quote_time","quotes","How many hours a week go on putting quotes together?","","hours","Quoting is often the biggest single block of hours we find."],
|
|
41495
|
+
["quote_delay","quotes","How long between the site visit and them getting the price?","","text","A quote that arrives four days later competes with three that arrived first."],
|
|
41496
|
+
|
|
41497
|
+
["booked_month","booking","How many appointments or site visits get booked in a month?","","count","The base for the no-show finding."],
|
|
41498
|
+
["show_rate","booking","Of those, how many actually happen?","Nobody home, cancelled last minute, forgot.","rate","A no-show is a job you paid to get twice."],
|
|
41499
|
+
["reminders","booking","Does anything remind them before the appointment?","","text","If the answer is 'I text them myself', that's both a time and a money finding."],
|
|
41500
|
+
["noshow_fate","booking","When someone no-shows, what happens next?","","text","'Nothing' means the rebooking finding is real."],
|
|
41501
|
+
|
|
41502
|
+
["dormant_count","dormant","How many people are sitting in your system who enquired once and never went anywhere?","Old quotes, old customers, people who called in 2023.","count","These people already raised their hand once. The cheapest money you have."],
|
|
41503
|
+
["dormant_worked","dormant","Has anyone ever gone back to that list?","","text","If nothing has ever been sent, the whole list is untouched revenue."],
|
|
41504
|
+
["past_customers","dormant","How many are past customers rather than people who never bought?","","count","Past customers convert far better, and they need a different message."],
|
|
41505
|
+
["repeat_cycle","dormant","How often would a past customer normally need you again?","","text","Decides whether reactivation is a one-off campaign or a standing programme."],
|
|
41506
|
+
|
|
41507
|
+
["review_count","reviews","How many Google reviews do you have?","","count","The number a new customer sees before they call you."],
|
|
41508
|
+
["review_ask","reviews","How do you ask for a review at the moment?","Who remembers to do it, and when?","text","'When I remember' is why the newest one is months old."],
|
|
41509
|
+
["review_reply","reviews","Do you reply to them?","","text","An unanswered bad review is the first thing a new customer reads."],
|
|
41510
|
+
["review_time","reviews","How many hours a week go on chasing reviews?","","hours","Small on its own. Adds up with the rest."],
|
|
41511
|
+
|
|
41512
|
+
["site_job","website","What do you want someone to do when they land on your site?","Call, book, fill something in?","text","Most sites do not do the thing the owner assumes they do."],
|
|
41513
|
+
["site_capture","website","Can someone book you from the website without speaking to anyone?","","text","If not, every after-hours visitor is gone."],
|
|
41514
|
+
["site_leads","website","How many enquiries a month come from the site itself?","","count","Against traffic this gives the conversion finding, scoped to your trade."],
|
|
41515
|
+
["site_age","website","When was the site last touched?","","text","Decides rebuild versus repair."],
|
|
41516
|
+
|
|
41517
|
+
["lead_sources","found","Where does your work actually come from? Rank them.","Word of mouth, Google, Facebook, repeat, trucks?","text","Tells us which leak matters most before we price any of them."],
|
|
41518
|
+
["paid_spend","found","Are you spending anything on advertising each month?","","currency","If they are, every leak we find is also wasted ad spend."],
|
|
41519
|
+
["gbp_status","found","Do you manage your Google listing yourself?","","text","The cheapest visibility fix there is, and usually neglected."],
|
|
41520
|
+
|
|
41521
|
+
["admin_hours","week","How many hours a week go on paperwork and admin rather than actual work?","Including the bits you do at night.","hours","The number the whole hours promise is measured against."],
|
|
41522
|
+
["evening_work","week","How much of that happens after dinner?","","hours","The answer that makes owners sit up. Part of the number above, not extra."],
|
|
41523
|
+
["stop_doing","week","If you could stop doing one thing tomorrow and never do it again, what is it?","","text","Their priority in their words. It leads the roadmap even if it isn't the biggest number."],
|
|
41524
|
+
["team_size","week","Who else is in the business?","","count","Decides whether a fix is automation or delegation."],
|
|
41525
|
+
["growth_goal","week","What would a good twelve months look like?","A number, if you have one.","text","The report closes against their goal, not ours."]
|
|
41526
|
+
];
|
|
41527
|
+
|
|
41528
|
+
var ASSUMPTIONS = [
|
|
41529
|
+
["recovery","How much of the leak we actually stop",0.5,"Assumes we recover half of what is leaking. Your number, and the most important one here."],
|
|
41530
|
+
["quote_recovery","Chasing an unclosed quote recovers",0.10,"Assumes chasing recovers 1 in 10. Your number, not ours."],
|
|
41531
|
+
["response_penalty","Fall-off once an enquiry has waited",0.25,"Assumes a 25% fall-off once they have waited. Your number."],
|
|
41532
|
+
["noshow_recovery","A no-show that gets rebooked",0.50,"Assumes half could be rebooked. Your number."],
|
|
41533
|
+
["reactivation","Dormant contacts who respond",0.02,"Assumes 2% come back. Your number. One-off, not annual."]
|
|
41534
|
+
];
|
|
41535
|
+
|
|
41536
|
+
/**
|
|
41537
|
+
* Hours questions that feed the weekly total. evening_work is deliberately NOT
|
|
41538
|
+
* here: it asks how much of admin_hours happens after dinner, so adding it would
|
|
41539
|
+
* count the same hours twice. It is a headline, not an addend.
|
|
41540
|
+
*/
|
|
41541
|
+
var HOUR_KEYS = [
|
|
41542
|
+
["repeat_time", "Answering the same questions"],
|
|
41543
|
+
["quote_time", "Putting quotes together"],
|
|
41544
|
+
["review_time", "Chasing reviews"],
|
|
41545
|
+
["admin_hours", "Paperwork and admin"]
|
|
41546
|
+
];
|
|
41547
|
+
|
|
41548
|
+
/** The two answers without which nothing at all can be priced. */
|
|
41549
|
+
var PRICING_CORE = ["avg_value", "close_rate"];
|
|
41550
|
+
|
|
41551
|
+
/**
|
|
41552
|
+
* What a missing input is called in the PROSPECT'S document.
|
|
41553
|
+
*
|
|
41554
|
+
* The report printed the raw question id \u2014 "close_rate" \u2014 into a page going out
|
|
41555
|
+
* on the operator's letterhead. These are the same measurements written as
|
|
41556
|
+
* instructions, because "what to measure first" is a list of things to go and
|
|
41557
|
+
* count, not a list of fields.
|
|
41558
|
+
*
|
|
41559
|
+
* Every id that can reach O[].missing must have an entry. engine.test.ts asserts
|
|
41560
|
+
* that coverage, so a new priced finding cannot ship a raw id into a client
|
|
41561
|
+
* document by forgetting this table.
|
|
41562
|
+
*/
|
|
41563
|
+
var MEASURE = {
|
|
41564
|
+
avg_value: "What one won job is worth to you, on average",
|
|
41565
|
+
close_rate: "Out of ten real conversations, how many buy",
|
|
41566
|
+
calls_missed: "Calls a month that go to voicemail or ring out",
|
|
41567
|
+
missed_never_return: "How many of those callers never try again",
|
|
41568
|
+
waited_month: "Enquiries a month that sit overnight before anyone replies",
|
|
41569
|
+
quotes_month: "Quotes sent in a month",
|
|
41570
|
+
quotes_won: "How many of those quotes turned into work",
|
|
41571
|
+
booked_month: "Appointments booked in a month",
|
|
41572
|
+
show_rate: "How many booked appointments actually happen"
|
|
41573
|
+
};
|
|
41574
|
+
|
|
41575
|
+
/**
|
|
41576
|
+
* Which of the two load-bearing numbers are not usable yet.
|
|
41577
|
+
*
|
|
41578
|
+
* Module-level and exported because the INTERVIEW needs this, not only the
|
|
41579
|
+
* report. Every finding multiplies by both, so one blank here voids the whole
|
|
41580
|
+
* document \u2014 and the app used to let an operator answer all 46 questions before
|
|
41581
|
+
* anything said so. compute() derives \`core\` from this same function rather than
|
|
41582
|
+
* repeating the rule, because two copies of a rule that decides what a client is
|
|
41583
|
+
* told is a defect waiting to happen.
|
|
41584
|
+
*/
|
|
41585
|
+
function coreMissing(ans, skip) {
|
|
41586
|
+
ans = ans || {}; skip = skip || {};
|
|
41587
|
+
var out = [];
|
|
41588
|
+
PRICING_CORE.forEach(function (id) {
|
|
41589
|
+
if (skip[id]) { out.push(id); return; }
|
|
41590
|
+
var v = ans[id];
|
|
41591
|
+
if (v === undefined || v === null || String(v).trim() === "") { out.push(id); return; }
|
|
41592
|
+
var n = parseTyped(v, UNIT[id]);
|
|
41593
|
+
if (!isFinite(n) || n <= 0) { out.push(id); return; }
|
|
41594
|
+
// A share is only a share inside 0..1 once scaled; 400% is not a close rate.
|
|
41595
|
+
if (UNIT[id] === "rate") { var r = n > 1 ? n / 100 : n; if (!(r > 0 && r <= 1)) out.push(id); }
|
|
41596
|
+
});
|
|
41597
|
+
return out;
|
|
41598
|
+
}
|
|
41599
|
+
|
|
41600
|
+
/** id -> the measurement in words. Never returns the id: an unlabelled id is a bug, not a fallback. */
|
|
41601
|
+
function measureLabel(id) {
|
|
41602
|
+
return MEASURE[id] || null;
|
|
41603
|
+
}
|
|
41604
|
+
|
|
41605
|
+
/** Ids -> labels, dropping any we cannot name rather than printing machine text at a stranger. */
|
|
41606
|
+
function measureLabels(ids) {
|
|
41607
|
+
var out = [];
|
|
41608
|
+
for (var i = 0; i < ids.length; i++) {
|
|
41609
|
+
var l = measureLabel(ids[i]);
|
|
41610
|
+
if (l && out.indexOf(l) === -1) out.push(l);
|
|
41611
|
+
}
|
|
41612
|
+
return out;
|
|
41613
|
+
}
|
|
41614
|
+
|
|
41615
|
+
/** id -> unit, so number parsing can be unit-aware. Built from Q, never hand-listed. */
|
|
41616
|
+
var UNIT = {};
|
|
41617
|
+
Q.forEach(function (q) { UNIT[q[0]] = q[4]; });
|
|
41618
|
+
|
|
41619
|
+
/**
|
|
41620
|
+
* Parse a number the way the person typing it meant it.
|
|
41621
|
+
*
|
|
41622
|
+
* The trap this exists for: an owner outside the US types 6.800 meaning six
|
|
41623
|
+
* thousand eight hundred. Read naively that is 6.8, and the report tells them
|
|
41624
|
+
* their business leaks $24 a month instead of $24,412 \u2014 plausible, silent, and
|
|
41625
|
+
* completely wrong, on their own letterhead. So a dot used as a THOUSANDS
|
|
41626
|
+
* separator (digits in exact groups of three) is stripped, and only for units
|
|
41627
|
+
* where that grouping is possible. A rate is never grouped, so 0.500 stays 0.5.
|
|
41628
|
+
*/
|
|
41629
|
+
function parseTyped(text, unit) {
|
|
41630
|
+
var t = String(text).trim().replace(/[^0-9.,\\-]/g, "");
|
|
41631
|
+
if (t === "" || t === "-") return NaN;
|
|
41632
|
+
var grouped = unit !== "rate";
|
|
41633
|
+
if (grouped && /^-?\\d{1,3}(\\.\\d{3})+$/.test(t)) t = t.replace(/\\./g, ""); // 6.800 -> 6800
|
|
41634
|
+
if (grouped && /^-?\\d{1,3}(,\\d{3})+(\\.\\d+)?$/.test(t)) t = t.replace(/,/g, ""); // 6,800.50 -> 6800.50
|
|
41635
|
+
t = t.replace(/,/g, "."); // 6,5 -> 6.5
|
|
41636
|
+
var parts = t.split(".");
|
|
41637
|
+
if (parts.length > 2) return NaN; // 6.800.500 is not a number we will guess at
|
|
41638
|
+
return parseFloat(t);
|
|
41639
|
+
}
|
|
41640
|
+
|
|
41641
|
+
/**
|
|
41642
|
+
* Read an assumption the operator typed, in the same way an answer is read.
|
|
41643
|
+
*
|
|
41644
|
+
* These went through a bare parseFloat until 2026-08-30, which meant an operator
|
|
41645
|
+
* typing 0,5 got 0 \u2014 and an assumption of zero DROPS its finding. So the report
|
|
41646
|
+
* quietly lost a finding because of a comma, and said nothing about it. Same
|
|
41647
|
+
* class as the 6.800 bug, one screen over.
|
|
41648
|
+
*/
|
|
41649
|
+
function parseRate(text) {
|
|
41650
|
+
var n = parseTyped(text, "rate");
|
|
41651
|
+
if (!isFinite(n) || n < 0) return 0;
|
|
41652
|
+
return n > 1 ? n / 100 : n;
|
|
41653
|
+
}
|
|
41654
|
+
|
|
41655
|
+
/**
|
|
41656
|
+
* Compute findings from raw answers.
|
|
41657
|
+
* ans { id: value as typed }
|
|
41658
|
+
* skip { id: true } when the operator marked it unknown
|
|
41659
|
+
* asm { id: rate as a decimal }
|
|
41660
|
+
* A value is only usable when it is present, not skipped, and finite. Anything
|
|
41661
|
+
* else is treated as unknown, and an unknown OMITS its finding rather than
|
|
41662
|
+
* being defaulted from the industry.
|
|
41663
|
+
*/
|
|
41664
|
+
function compute(ans, skip, asm) {
|
|
41665
|
+
ans = ans || {}; skip = skip || {}; asm = asm || {};
|
|
41666
|
+
|
|
41667
|
+
function raw(id) {
|
|
41668
|
+
if (skip[id]) return null;
|
|
41669
|
+
var v = ans[id];
|
|
41670
|
+
if (v === undefined || v === null || String(v).trim() === "") return null;
|
|
41671
|
+
var n = parseTyped(v, UNIT[id]);
|
|
41672
|
+
return isFinite(n) ? n : null;
|
|
41673
|
+
}
|
|
41674
|
+
/** A share typed as either 28 or 0.28. Anything outside 0..1 after scaling is not a share. */
|
|
41675
|
+
function share(id) {
|
|
41676
|
+
var n = raw(id);
|
|
41677
|
+
if (n === null) return null;
|
|
41678
|
+
var r = n > 1 ? n / 100 : n;
|
|
41679
|
+
return r >= 0 && r <= 1 ? r : null;
|
|
41680
|
+
}
|
|
41681
|
+
function a(k) { var n = Number(asm[k]); return isFinite(n) && n > 0 ? n : 0; }
|
|
41682
|
+
|
|
41683
|
+
var v = raw("avg_value"), c = share("close_rate");
|
|
41684
|
+
var coreGaps = coreMissing(ans, skip);
|
|
41685
|
+
var core = coreGaps.length === 0;
|
|
41686
|
+
var F = [], O = [];
|
|
41687
|
+
|
|
41688
|
+
/**
|
|
41689
|
+
* The ONE place an omission is recorded. Every push goes through here so a new
|
|
41690
|
+
* finding cannot ship a bare id list: the labels are attached where the ids
|
|
41691
|
+
* are, not at the render site that happens to remember.
|
|
41692
|
+
*/
|
|
41693
|
+
function omit(o) {
|
|
41694
|
+
o.missing = o.missing || [];
|
|
41695
|
+
o.missingLabels = measureLabels(o.missing);
|
|
41696
|
+
O.push(o);
|
|
41697
|
+
return o;
|
|
41698
|
+
}
|
|
41699
|
+
|
|
41700
|
+
function add(id, title, needs, assumption, fn, fix) {
|
|
41701
|
+
var missing = needs.filter(function (n) {
|
|
41702
|
+
return (n.share ? share(n.id) : raw(n.id)) === null;
|
|
41703
|
+
}).map(function (n) { return n.id; });
|
|
41704
|
+
if (!core) { omit({ id: id, title: title, missing: coreGaps }); return; }
|
|
41705
|
+
if (missing.length) { omit({ id: id, title: title, missing: missing }); return; }
|
|
41706
|
+
if (assumption && a(assumption) === 0) { omit({ id: id, title: title, missing: [], declined: assumption }); return; }
|
|
41707
|
+
var m = fn();
|
|
41708
|
+
if (!isFinite(m) || m < 0) { omit({ id: id, title: title, missing: needs.map(function (n) { return n.id; }) }); return; }
|
|
41709
|
+
// Zero is not a finding. Listing "Quotes nobody chases \u2014 $0" pads a client
|
|
41710
|
+
// document with things that are not happening.
|
|
41711
|
+
if (m === 0) { omit({ id: id, title: title, missing: [], reason: "nothing to price here on your numbers" }); return; }
|
|
41712
|
+
F.push({ id: id, title: title, monthly: m, assumption: assumption || null, fix: fix });
|
|
41713
|
+
}
|
|
41714
|
+
|
|
41715
|
+
add("missed", "Calls you never get back to",
|
|
41716
|
+
[{ id: "calls_missed" }, { id: "missed_never_return", share: true }], null,
|
|
41717
|
+
function () { return raw("calls_missed") * share("missed_never_return") * c * v; },
|
|
41718
|
+
"Call answering, day and night");
|
|
41719
|
+
|
|
41720
|
+
add("waited", "Enquiries that waited",
|
|
41721
|
+
[{ id: "waited_month" }], "response_penalty",
|
|
41722
|
+
function () { return raw("waited_month") * a("response_penalty") * c * v; },
|
|
41723
|
+
"Instant reply on every channel");
|
|
41724
|
+
|
|
41725
|
+
// quotes_won > quotes_month means the two answers disagree. Clamping to zero
|
|
41726
|
+
// would hide a contradiction the operator needs to go back and resolve.
|
|
41727
|
+
if (core && raw("quotes_month") !== null && raw("quotes_won") !== null && raw("quotes_won") > raw("quotes_month")) {
|
|
41728
|
+
omit({ id: "quotes", title: "Quotes nobody chases", missing: [],
|
|
41729
|
+
reason: "you told us more quotes were won than were sent \u2014 worth checking before this is priced" });
|
|
41730
|
+
} else {
|
|
41731
|
+
add("quotes", "Quotes nobody chases",
|
|
41732
|
+
[{ id: "quotes_month" }, { id: "quotes_won" }], "quote_recovery",
|
|
41733
|
+
function () { return (raw("quotes_month") - raw("quotes_won")) * a("quote_recovery") * v; },
|
|
41734
|
+
"Follow-up that runs itself");
|
|
41735
|
+
}
|
|
41736
|
+
|
|
41737
|
+
add("noshow", "No-shows nobody rebooks",
|
|
41738
|
+
[{ id: "booked_month" }, { id: "show_rate", share: true }], "noshow_recovery",
|
|
41739
|
+
function () { return raw("booked_month") * (1 - share("show_rate")) * a("noshow_recovery") * c * v; },
|
|
41740
|
+
"Reminders and rebooking");
|
|
41741
|
+
|
|
41742
|
+
var monthly = F.reduce(function (s, f) { return s + f.monthly; }, 0);
|
|
41743
|
+
var stop = monthly * a("recovery");
|
|
41744
|
+
|
|
41745
|
+
var oneoff = null;
|
|
41746
|
+
if (core && raw("dormant_count") !== null && a("reactivation") > 0) {
|
|
41747
|
+
oneoff = raw("dormant_count") * a("reactivation") * c * v;
|
|
41748
|
+
}
|
|
41749
|
+
|
|
41750
|
+
var hrs = [];
|
|
41751
|
+
HOUR_KEYS.forEach(function (p) { var n = raw(p[0]); if (n !== null && n >= 0) hrs.push({ id: p[0], t: p[1], h: n }); });
|
|
41752
|
+
var hrsWk = hrs.reduce(function (s, x) { return s + x.h; }, 0);
|
|
41753
|
+
|
|
41754
|
+
var verdict, line;
|
|
41755
|
+
if (!core) {
|
|
41756
|
+
verdict = "insufficient";
|
|
41757
|
+
// Names the gap. "The figures are not known" made a one-number gap sound
|
|
41758
|
+
// like the whole interview had failed, and gave nobody anything to go and do.
|
|
41759
|
+
line = coreGaps.length === 1
|
|
41760
|
+
? "We could not put a number on anything yet. Every finding is multiplied by one figure we do not have, and that is the only thing standing in the way."
|
|
41761
|
+
: "We could not put a number on anything yet, because the two figures everything else is priced against are not known.";
|
|
41762
|
+
} else if (!F.length) {
|
|
41763
|
+
verdict = "insufficient";
|
|
41764
|
+
line = "We could not price a single leak from the numbers available.";
|
|
41765
|
+
} else if (monthly < v) {
|
|
41766
|
+
// Thin is measured against THEIR average job: everything found together is
|
|
41767
|
+
// worth less than one new customer a month. Never a benchmark, never a fee.
|
|
41768
|
+
verdict = "thin";
|
|
41769
|
+
line = "Everything we found together is worth less than one new customer a month to you. That is worth saying plainly rather than dressing up.";
|
|
41770
|
+
} else {
|
|
41771
|
+
verdict = "findings";
|
|
41772
|
+
line = "We found " + F.length + " thing" + (F.length === 1 ? "" : "s") + " worth fixing.";
|
|
41773
|
+
}
|
|
41774
|
+
|
|
41775
|
+
return {
|
|
41776
|
+
F: F, O: O, core: core,
|
|
41777
|
+
monthly: monthly, annual: monthly * 12,
|
|
41778
|
+
stop: stop, annualStop: stop * 12,
|
|
41779
|
+
oneoff: oneoff, hrs: hrs, hrsWk: hrsWk,
|
|
41780
|
+
verdict: verdict, line: line
|
|
41781
|
+
};
|
|
41782
|
+
}
|
|
41783
|
+
|
|
41784
|
+
/**
|
|
41785
|
+
* The report \u2014 the document the agency hands their prospect.
|
|
41786
|
+
*
|
|
41787
|
+
* This is the only thing here a stranger ever reads, and it goes out on the
|
|
41788
|
+
* agency's letterhead, not ours. Three rules are enforced by construction:
|
|
41789
|
+
*
|
|
41790
|
+
* 1. NOTHING OF OURS APPEARS. No product name, no tool name, no "generated by".
|
|
41791
|
+
* Paul's client is buying from Paul.
|
|
41792
|
+
* 2. EVERY FIGURE CARRIES ITS SOURCE. A number the owner gave, a number the
|
|
41793
|
+
* operator assumed, and a number nobody knew are visibly different things,
|
|
41794
|
+
* and the assumption prints beside the figure it moved rather than in a
|
|
41795
|
+
* footnote nobody reads.
|
|
41796
|
+
* 3. EVERY PIECE OF TEXT IS ESCAPED. The report is published to a real domain
|
|
41797
|
+
* and is full of words a prospect typed into a form. Escaping is a
|
|
41798
|
+
* correctness requirement here, not hygiene.
|
|
41799
|
+
*
|
|
41800
|
+
* Pure: takes data, returns an HTML string. No DOM, no network, no clock beyond
|
|
41801
|
+
* the date it is handed. That is what makes it testable, and it is inlined into
|
|
41802
|
+
* the assessment page by build-app.mjs like the engine.
|
|
41803
|
+
*/
|
|
41804
|
+
|
|
41805
|
+
function esc(s) {
|
|
41806
|
+
return String(s === undefined || s === null ? "" : s)
|
|
41807
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
41808
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
41809
|
+
}
|
|
41810
|
+
|
|
41811
|
+
/** Does this value look like a figure rather than a sentence? Fallback only. */
|
|
41812
|
+
function isFigure(v) {
|
|
41813
|
+
return /^[\\s$\xA3\u20AC]*[-+]?[\\d][\\d.,]*\\s*(%|hrs?|hours?|mins?|minutes?|days?)?\\s*$/i.test(String(v === undefined || v === null ? "" : v));
|
|
41814
|
+
}
|
|
41815
|
+
|
|
41816
|
+
function money(n) {
|
|
41817
|
+
return "$" + Math.round(Number(n) || 0).toLocaleString("en-US");
|
|
41818
|
+
}
|
|
41819
|
+
|
|
41820
|
+
/** A colour the operator typed is going straight into a style attribute. */
|
|
41821
|
+
function safeColour(c) {
|
|
41822
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(String(c || "")) ? String(c) : "#1F4E79";
|
|
41823
|
+
}
|
|
41824
|
+
|
|
41825
|
+
/**
|
|
41826
|
+
* agency { name, accent?, contact? } \u2014 from Layer 1. Never invented.
|
|
41827
|
+
* services[] { name, price?, covers:[findingId] } \u2014 THEIR menu at THEIR prices.
|
|
41828
|
+
* A finding with no matching service still appears; it just has no price beside
|
|
41829
|
+
* it, because recommending something the agency does not sell is worse than a
|
|
41830
|
+
* gap in a table.
|
|
41831
|
+
*/
|
|
41832
|
+
function renderReport(opts) {
|
|
41833
|
+
var state = opts.state || {};
|
|
41834
|
+
var r = opts.computed || {};
|
|
41835
|
+
var agency = opts.agency || {};
|
|
41836
|
+
var services = Array.isArray(opts.services) ? opts.services : [];
|
|
41837
|
+
var assumptionLabels = opts.assumptionLabels || {};
|
|
41838
|
+
var date = opts.date || "";
|
|
41839
|
+
var accent = safeColour(agency.accent);
|
|
41840
|
+
|
|
41841
|
+
var biz = esc(state.biz || "your business");
|
|
41842
|
+
var F = Array.isArray(r.F) ? r.F : [];
|
|
41843
|
+
var O = Array.isArray(r.O) ? r.O : [];
|
|
41844
|
+
var hrs = Array.isArray(r.hrs) ? r.hrs : [];
|
|
41845
|
+
|
|
41846
|
+
var out = [];
|
|
41847
|
+
out.push('<!doctype html><html lang="en"><head><meta charset="utf-8">');
|
|
41848
|
+
out.push('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
41849
|
+
// This document carries another business's revenue, close rate and list size.
|
|
41850
|
+
// Wherever it ends up, it must never be indexed.
|
|
41851
|
+
out.push('<meta name="robots" content="noindex,nofollow,noarchive">');
|
|
41852
|
+
out.push("<title>Where " + biz + " is leaking</title>");
|
|
41853
|
+
out.push("<style>" + css(accent) + "</style></head><body><div class=wrap>");
|
|
41854
|
+
|
|
41855
|
+
// \u2500\u2500 masthead: theirs, not ours \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41856
|
+
out.push("<header>");
|
|
41857
|
+
if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
|
|
41858
|
+
out.push("<h1>Where " + biz + " is leaking</h1>");
|
|
41859
|
+
if (date) out.push('<p class="when">' + esc(date) + "</p>");
|
|
41860
|
+
out.push("</header>");
|
|
41861
|
+
|
|
41862
|
+
// \u2500\u2500 the three verdicts are three different documents \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41863
|
+
if (r.verdict === "insufficient") {
|
|
41864
|
+
out.push('<div class="verdict stop"><b>We could not put a number on this yet</b>'
|
|
41865
|
+
+ esc(r.line || "") + "</div>");
|
|
41866
|
+
out.push(measureFirst(O));
|
|
41867
|
+
out.push(footer(agency));
|
|
41868
|
+
return out.join("\\n") + "</div></body></html>";
|
|
41869
|
+
}
|
|
41870
|
+
|
|
41871
|
+
if (r.verdict === "thin") {
|
|
41872
|
+
out.push('<div class="verdict warn"><b>Honestly, there is not much here</b>'
|
|
41873
|
+
+ esc(r.line || "") + "</div>");
|
|
41874
|
+
}
|
|
41875
|
+
|
|
41876
|
+
// \u2500\u2500 headline numbers \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41877
|
+
out.push('<div class="hgrid">');
|
|
41878
|
+
if (r.hrsWk > 0) out.push(head(r.hrsWk.toFixed(1), "hours a week going into work a system should do"));
|
|
41879
|
+
if (r.annual > 0) out.push(head(money(r.annual), "a year leaking, on your own numbers"));
|
|
41880
|
+
if (r.annualStop > 0) out.push(head(money(r.annualStop), "a year we expect to actually stop"));
|
|
41881
|
+
out.push("</div>");
|
|
41882
|
+
|
|
41883
|
+
// \u2500\u2500 what you told us \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41884
|
+
if (opts.answered && opts.answered.length) {
|
|
41885
|
+
out.push("<h2>What we started from, in your words</h2><table class=\\"qa\\">");
|
|
41886
|
+
for (var a = 0; a < opts.answered.length; a++) {
|
|
41887
|
+
var q = opts.answered[a];
|
|
41888
|
+
// \`.num\` is right-aligned AND nowrap, which is right for a figure and
|
|
41889
|
+
// ruinous for a sentence: one typed answer like "I get to them at night,
|
|
41890
|
+
// usually" held the value column open and crushed the question column to
|
|
41891
|
+
// 113px of a 926px page, wrapping every question over five lines. The
|
|
41892
|
+
// unit says which kind of answer this is; the shape of the value is the
|
|
41893
|
+
// fallback for a caller that does not send one.
|
|
41894
|
+
var numeric = q.unit !== undefined ? q.unit !== "text" : isFigure(q.value);
|
|
41895
|
+
out.push("<tr><td>" + esc(q.ask) + '</td><td class="' + (numeric ? "num" : "said") + '">'
|
|
41896
|
+
+ (q.unknown ? '<span class="unk">you did not know</span>' : esc(q.value)) + "</td></tr>");
|
|
41897
|
+
}
|
|
41898
|
+
out.push("</table>");
|
|
41899
|
+
}
|
|
41900
|
+
|
|
41901
|
+
// \u2500\u2500 the findings \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41902
|
+
if (F.length) {
|
|
41903
|
+
out.push("<h2>Where the money goes</h2><table>");
|
|
41904
|
+
out.push('<tr><th>What is happening</th><th class="num">A month</th></tr>');
|
|
41905
|
+
for (var i = 0; i < F.length; i++) {
|
|
41906
|
+
var f = F[i];
|
|
41907
|
+
var note = f.assumption
|
|
41908
|
+
? '<span class="assume">' + esc(assumptionLabels[f.assumption] || "This is your assumption, not a measurement.") + "</span>"
|
|
41909
|
+
: "";
|
|
41910
|
+
out.push("<tr><td><strong>" + esc(f.title) + "</strong>" + note + '</td><td class="num">'
|
|
41911
|
+
+ money(f.monthly) + "</td></tr>");
|
|
41912
|
+
}
|
|
41913
|
+
out.push('<tr class="tot"><td><strong>Leaking, every month</strong></td><td class="num"><strong>'
|
|
41914
|
+
+ money(r.monthly) + "</strong></td></tr>");
|
|
41915
|
+
if (r.stop > 0) {
|
|
41916
|
+
out.push('<tr><td><strong>What we expect to actually stop</strong><span class="assume">'
|
|
41917
|
+
+ esc(assumptionLabels.recovery || "No system catches everything. This is your figure.")
|
|
41918
|
+
+ '</span></td><td class="num"><strong>' + money(r.stop) + "</strong></td></tr>");
|
|
41919
|
+
}
|
|
41920
|
+
if (r.oneoff !== null && r.oneoff !== undefined && r.oneoff > 0) {
|
|
41921
|
+
out.push('<tr><td>Your dormant list, worked once<span class="assume">'
|
|
41922
|
+
+ esc(assumptionLabels.reactivation || "Your assumption, not a measurement.")
|
|
41923
|
+
+ " This is a one-off and is left out of every annual figure above."
|
|
41924
|
+
+ '</span></td><td class="num">' + money(r.oneoff) + "</td></tr>");
|
|
41925
|
+
}
|
|
41926
|
+
out.push("</table>");
|
|
41927
|
+
}
|
|
41928
|
+
|
|
41929
|
+
// \u2500\u2500 named, deliberately not priced \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41930
|
+
var named = O.filter(function (o) { return o.reason || o.declined; });
|
|
41931
|
+
if (named.length) {
|
|
41932
|
+
out.push("<h2>Named, and deliberately not priced</h2><ul>");
|
|
41933
|
+
for (var j = 0; j < named.length; j++) {
|
|
41934
|
+
var o = named[j];
|
|
41935
|
+
out.push("<li><strong>" + esc(o.title) + "</strong> — "
|
|
41936
|
+
+ esc(o.declined ? "priced only on an assumption you chose not to make." : o.reason) + "</li>");
|
|
41937
|
+
}
|
|
41938
|
+
out.push("</ul><p class=small>We can see these matter and we are not going to invent a number for them.</p>");
|
|
41939
|
+
}
|
|
41940
|
+
|
|
41941
|
+
// \u2500\u2500 hours \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41942
|
+
if (hrs.length) {
|
|
41943
|
+
out.push("<h2>Where the time goes</h2><table>");
|
|
41944
|
+
for (var h = 0; h < hrs.length; h++) {
|
|
41945
|
+
out.push("<tr><td>" + esc(hrs[h].t) + '</td><td class="num">' + Number(hrs[h].h).toFixed(1) + " hrs</td></tr>");
|
|
41946
|
+
}
|
|
41947
|
+
out.push('<tr class="tot"><td><strong>A week</strong></td><td class="num"><strong>'
|
|
41948
|
+
+ Number(r.hrsWk).toFixed(1) + "</strong></td></tr></table>");
|
|
41949
|
+
}
|
|
41950
|
+
|
|
41951
|
+
// \u2500\u2500 the plan, from THEIR menu \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41952
|
+
if (F.length) {
|
|
41953
|
+
out.push("<h2>What we would do, in order</h2><table>");
|
|
41954
|
+
out.push('<tr><th>Fixes</th><th>What it is</th></tr>');
|
|
41955
|
+
for (var k = 0; k < F.length; k++) {
|
|
41956
|
+
var fx = F[k];
|
|
41957
|
+
var svc = services.find(function (s) {
|
|
41958
|
+
return Array.isArray(s.covers) && s.covers.indexOf(fx.id) !== -1;
|
|
41959
|
+
});
|
|
41960
|
+
out.push("<tr><td>" + esc(fx.title) + "</td><td>" + esc(svc ? svc.name : fx.fix || "") + "</td></tr>");
|
|
41961
|
+
}
|
|
41962
|
+
out.push("</table>");
|
|
41963
|
+
if (!services.length) {
|
|
41964
|
+
out.push('<p class="small">Service names above are descriptions, not a quote. Add your own offers so this reads in your language and at your prices.</p>');
|
|
41965
|
+
}
|
|
41966
|
+
}
|
|
41967
|
+
|
|
41968
|
+
// \u2500\u2500 investment, only when they have priced it \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
41969
|
+
var priced = services.filter(function (s) { return typeof s.price === "number" && s.price > 0; });
|
|
41970
|
+
if (priced.length && r.annualStop > 0) {
|
|
41971
|
+
var total = priced.reduce(function (s, x) { return s + x.price; }, 0);
|
|
41972
|
+
out.push("<h2>The investment</h2><table>");
|
|
41973
|
+
for (var p = 0; p < priced.length; p++) {
|
|
41974
|
+
out.push("<tr><td>" + esc(priced[p].name) + '</td><td class="num">' + money(priced[p].price) + "</td></tr>");
|
|
41975
|
+
}
|
|
41976
|
+
out.push('<tr class="tot"><td><strong>Total</strong></td><td class="num"><strong>' + money(total) + "</strong></td></tr>");
|
|
41977
|
+
out.push('<tr><td>Against what we expect to stop, first year</td><td class="num">' + money(r.annualStop) + "</td></tr>");
|
|
41978
|
+
if (total > 0) {
|
|
41979
|
+
var ratio = r.annualStop / total;
|
|
41980
|
+
out.push('<tr class="tot"><td><strong>You are spending $1 to stop $' + ratio.toFixed(2)
|
|
41981
|
+
+ "</strong></td><td class=\\"num\\"><strong>" + ratio.toFixed(1) + "×</strong></td></tr>");
|
|
41982
|
+
}
|
|
41983
|
+
out.push("</table>");
|
|
41984
|
+
}
|
|
41985
|
+
|
|
41986
|
+
out.push(sourceLine(state, r, assumptionLabels));
|
|
41987
|
+
out.push(footer(agency));
|
|
41988
|
+
return out.join("\\n") + "</div></body></html>";
|
|
41989
|
+
}
|
|
41990
|
+
|
|
41991
|
+
function head(n, label) {
|
|
41992
|
+
return '<div><span class="hn">' + esc(n) + '</span><span class="hl">' + esc(label) + "</span></div>";
|
|
41993
|
+
}
|
|
41994
|
+
|
|
41995
|
+
/**
|
|
41996
|
+
* The list of things to go and count.
|
|
41997
|
+
*
|
|
41998
|
+
* Reads the labels the ENGINE attached beside the ids, never the ids. This
|
|
41999
|
+
* printed "close_rate" at a prospect until 2026-08-30 \u2014 machine text in a
|
|
42000
|
+
* document going out on the operator's letterhead. An id with no label is
|
|
42001
|
+
* dropped rather than printed, because a stranger reading a field name is a
|
|
42002
|
+
* worse failure than a short list.
|
|
42003
|
+
*/
|
|
42004
|
+
function measureFirst(O) {
|
|
42005
|
+
var seen = [];
|
|
42006
|
+
for (var i = 0; i < O.length; i++) {
|
|
42007
|
+
var m = O[i].missingLabels || [];
|
|
42008
|
+
for (var j = 0; j < m.length; j++) if (seen.indexOf(m[j]) === -1) seen.push(m[j]);
|
|
42009
|
+
}
|
|
42010
|
+
if (!seen.length) return "";
|
|
42011
|
+
return "<h2>What to measure first</h2><p>These are the numbers that would let us price the rest. "
|
|
42012
|
+
+ "None of them need a system — a week of writing them down is enough.</p><ul><li>"
|
|
42013
|
+
+ seen.map(esc).join("</li><li>") + "</li></ul>";
|
|
42014
|
+
}
|
|
42015
|
+
|
|
42016
|
+
/** The sentence the whole document rests on. */
|
|
42017
|
+
function sourceLine(state, r, labels) {
|
|
42018
|
+
var used = [];
|
|
42019
|
+
var F = r.F || [];
|
|
42020
|
+
for (var i = 0; i < F.length; i++) if (F[i].assumption && used.indexOf(F[i].assumption) === -1) used.push(F[i].assumption);
|
|
42021
|
+
var n = used.length + (r.stop > 0 ? 1 : 0);
|
|
42022
|
+
return '<p class="src">Every figure here came from what you told us'
|
|
42023
|
+
+ (state.trade ? ", or from your own " + esc(state.trade) + " business" : "")
|
|
42024
|
+
+ ". " + (n > 0
|
|
42025
|
+
? n + " of them " + (n === 1 ? "is an estimate" : "are estimates") + " we chose together, each marked where it is used. "
|
|
42026
|
+
: "")
|
|
42027
|
+
+ "Nothing here is an industry average applied to your business.</p>";
|
|
42028
|
+
}
|
|
42029
|
+
|
|
42030
|
+
function footer(agency) {
|
|
42031
|
+
if (!agency.name) return "";
|
|
42032
|
+
return '<footer>' + esc(agency.name) + (agency.contact ? " · " + esc(agency.contact) : "") + "</footer>";
|
|
42033
|
+
}
|
|
42034
|
+
|
|
42035
|
+
function css(accent) {
|
|
42036
|
+
return [
|
|
42037
|
+
":root{--a:" + accent + ";--ink:#14171F;--mut:#4A5163;--rule:#D8DDE5;--pan:#F4F6F8}",
|
|
42038
|
+
"*{box-sizing:border-box}",
|
|
42039
|
+
"body{margin:0;background:#fff;color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}",
|
|
42040
|
+
".wrap{max-width:44rem;margin:0 auto;padding:2rem 1.1rem 4rem}",
|
|
42041
|
+
"header{border-bottom:3px solid var(--a);padding-bottom:.9rem;margin-bottom:1.4rem}",
|
|
42042
|
+
".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
|
|
42043
|
+
"h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
|
|
42044
|
+
".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
|
|
42045
|
+
"h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
|
|
42046
|
+
"table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
|
|
42047
|
+
"th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
|
|
42048
|
+
"th{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:2px solid var(--rule)}",
|
|
42049
|
+
".num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
42050
|
+
// A typed sentence wraps and stays left. It is prose, not a figure.
|
|
42051
|
+
".said{text-align:left;white-space:normal}",
|
|
42052
|
+
// The question is the column that must stay readable, so it gets the space
|
|
42053
|
+
// and the answer takes what it needs.
|
|
42054
|
+
"table.qa td:first-child{width:62%}",
|
|
42055
|
+
"tr.tot td{background:var(--pan)}",
|
|
42056
|
+
".assume{display:block;font-size:.76rem;color:var(--mut);font-style:italic;margin-top:.2rem}",
|
|
42057
|
+
".unk{color:var(--mut);font-style:italic}",
|
|
42058
|
+
".hgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:1rem;margin:1.2rem 0}",
|
|
42059
|
+
".hn{display:block;font-size:1.7rem;font-weight:700;color:var(--a);line-height:1.1}",
|
|
42060
|
+
".hl{display:block;font-size:.78rem;color:var(--mut);margin-top:.15rem}",
|
|
42061
|
+
".verdict{border-left:4px solid var(--a);background:var(--pan);padding:.75rem .9rem;margin:1rem 0;font-size:.94rem}",
|
|
42062
|
+
".verdict.warn{border-left-color:#8A5300}.verdict.stop{border-left-color:#9A3324}",
|
|
42063
|
+
".verdict b{display:block;margin-bottom:.15rem}",
|
|
42064
|
+
".src{margin-top:1.6rem;padding-top:.8rem;border-top:1px solid var(--rule);font-size:.82rem;color:var(--mut)}",
|
|
42065
|
+
".small{font-size:.82rem;color:var(--mut)}",
|
|
42066
|
+
"ul{padding-left:1.1rem}li{margin-bottom:.3rem}",
|
|
42067
|
+
"footer{margin-top:2rem;padding-top:.8rem;border-top:3px solid var(--a);font-size:.8rem;color:var(--mut)}",
|
|
42068
|
+
"@media print{.wrap{max-width:none}body{font-size:11pt}}",
|
|
42069
|
+
].join("");
|
|
42070
|
+
}
|
|
42071
|
+
|
|
42072
|
+
/* \u2500\u2500 state (persistence only; all maths lives in engine.js) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
42073
|
+
var K="cmdos-assessment-v1";
|
|
42074
|
+
var S={biz:"",trade:"",ans:{},skip:{},asm:{},tab:"ask",area:"money"};
|
|
42075
|
+
ASSUMPTIONS.forEach(function(a){S.asm[a[0]]=a[2]});
|
|
42076
|
+
try{var raw=localStorage.getItem(K);if(raw)S=Object.assign(S,JSON.parse(raw))}catch(e){}
|
|
42077
|
+
function save(){try{localStorage.setItem(K,JSON.stringify(S))}catch(e){}}
|
|
42078
|
+
function txt(id){return S.skip[id]?null:(S.ans[id]||null)}
|
|
42079
|
+
function money(n){return "$"+Math.round(n).toLocaleString()}
|
|
42080
|
+
function R(){return compute(S.ans,S.skip,S.asm)}
|
|
42081
|
+
/* \u2500\u2500 render \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
42082
|
+
function progress(){var d=0;Q.forEach(function(q){if(S.skip[q[0]]||(S.ans[q[0]]!==undefined&&S.ans[q[0]]!==""))d++});return d}
|
|
42083
|
+
function areaCount(a){var t=0,d=0;Q.forEach(function(q){if(q[1]===a){t++;if(S.skip[q[0]]||(S.ans[q[0]]!==undefined&&S.ans[q[0]]!==""))d++}});return d+"/"+t}
|
|
42084
|
+
|
|
42085
|
+
function head(){
|
|
42086
|
+
var r=R();
|
|
42087
|
+
document.getElementById("lProg").textContent=progress()+"/"+Q.length;
|
|
42088
|
+
document.getElementById("lHrs").textContent=r.hrsWk?r.hrsWk.toFixed(1):"\u2014";
|
|
42089
|
+
document.getElementById("lMon").textContent=r.monthly?money(r.monthly):"\u2014";
|
|
42090
|
+
document.getElementById("bizname").textContent=S.biz||"New assessment";
|
|
42091
|
+
document.getElementById("bizmeta").textContent=S.trade||"";
|
|
42092
|
+
}
|
|
42093
|
+
|
|
42094
|
+
/**
|
|
42095
|
+
* The banner that stops an operator answering 46 questions for nothing.
|
|
42096
|
+
*
|
|
42097
|
+
* Every priced finding multiplies by the average job value AND the close rate,
|
|
42098
|
+
* so one blank there produces a report that can price nothing at all. It used to
|
|
42099
|
+
* say so only at the very end, on the report itself. Now it says so on every
|
|
42100
|
+
* tab, names which number is missing in words, and links straight to it.
|
|
42101
|
+
*/
|
|
42102
|
+
function coreWarnHtml(){
|
|
42103
|
+
var gaps=coreMissing(S.ans,S.skip);
|
|
42104
|
+
if(!gaps.length)return "";
|
|
42105
|
+
var names=measureLabels(gaps);
|
|
42106
|
+
var one=gaps.length===1;
|
|
42107
|
+
return '<div class="note stop"><b>'+(one?"One number is missing, and nothing can be priced without it"
|
|
42108
|
+
:"Two numbers are missing, and nothing can be priced without them")+'</b>'
|
|
42109
|
+
+'Every finding in this assessment is multiplied by '+(one?"it":"them")+', so the report will say it could not put a number on anything until '+(one?"it is":"they are")+' filled in.'
|
|
42110
|
+
+'<ul style="margin:.4rem 0 .3rem;padding-left:1.1rem">'
|
|
42111
|
+
+names.map(function(n){return "<li>"+n+"</li>"}).join("")+'</ul>'
|
|
42112
|
+
+'<button class="mini" onclick="goCore()">Take me to '+(one?"it":"them")+'</button></div>';
|
|
42113
|
+
}
|
|
42114
|
+
/**
|
|
42115
|
+
* The report is addressed to somebody, and renders nothing without a name \u2014 a
|
|
42116
|
+
* third blocker that announced itself only on the Report tab, after all 46
|
|
42117
|
+
* questions were done. Same failure as the two numbers (command-center found it
|
|
42118
|
+
* in the browser pass), so it gets the same treatment rather than a second
|
|
42119
|
+
* style of warning living somewhere else.
|
|
42120
|
+
*/
|
|
42121
|
+
function bizWarnHtml(){
|
|
42122
|
+
if(S.biz&&S.biz.trim())return "";
|
|
42123
|
+
return '<div class="note warn"><b>No business name yet</b>'
|
|
42124
|
+
+'The report below is addressed to “your business” until you give it one. It renders either way, and that is the problem: it looks finished and is not sendable.'
|
|
42125
|
+
+' <button class="mini" onclick="goBiz()">Take me to it</button></div>';
|
|
42126
|
+
}
|
|
42127
|
+
function goBiz(){S.tab="set";save();draw();
|
|
42128
|
+
var el=document.getElementById("f_biz");
|
|
42129
|
+
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});el.focus()}}
|
|
42130
|
+
/** Every standing warning, in one place, so all three tabs show the same set. */
|
|
42131
|
+
function warnsHtml(){return coreWarnHtml()+bizWarnHtml()}
|
|
42132
|
+
|
|
42133
|
+
function goCore(){S.tab="ask";S.area="money";save();draw();
|
|
42134
|
+
var g=coreMissing(S.ans,S.skip);
|
|
42135
|
+
if(g.length){var el=document.getElementById("q_"+g[0]);
|
|
42136
|
+
if(el){el.scrollIntoView({behavior:"smooth",block:"center"});var i=el.querySelector("input");if(i)i.focus()}}}
|
|
42137
|
+
|
|
42138
|
+
function railHtml(){
|
|
42139
|
+
if(S.tab!=="ask")return "";
|
|
42140
|
+
return AREAS.map(function(a){return '<button class="'+(S.area===a[0]?"on":"")+'" onclick="goArea(\\''+a[0]+'\\')">'
|
|
42141
|
+
+a[1]+'<span class="c">'+areaCount(a[0])+'</span></button>'}).join("")}
|
|
42142
|
+
|
|
42143
|
+
function inputFor(q){
|
|
42144
|
+
var id=q[0],u=q[4],val=S.ans[id]===undefined?"":S.ans[id],sk=!!S.skip[id];
|
|
42145
|
+
var unit={currency:"$",rate:"% or 0-1",count:"number",minutes:"minutes",hours:"hrs/week",text:""}[u]||"";
|
|
42146
|
+
var el= u==="text"
|
|
42147
|
+
? '<textarea class="a" oninput="setA(\\''+id+'\\',this.value)" '+(sk?"disabled":"")+'>'+String(val).replace(/</g,"<")+'</textarea>'
|
|
42148
|
+
: '<input class="a" inputmode="decimal" value="'+String(val).replace(/"/g,""")+'" oninput="setA(\\''+id+'\\',this.value)" '+(sk?"disabled":"")+'>';
|
|
42149
|
+
return '<div class="row">'+el+'<span class="unit">'+unit+'</span>'
|
|
42150
|
+
+'<button class="mini '+(sk?"on":"")+'" onclick="skip(\\''+id+'\\')">'+(sk?"marked don\\'t know":"don\\'t know")+'</button>'
|
|
42151
|
+
+'<button class="mini" onclick="why(\\''+id+'\\')">why</button></div>'}
|
|
42152
|
+
|
|
42153
|
+
function askHtml(){
|
|
42154
|
+
var a=AREAS.filter(function(x){return x[0]===S.area})[0];
|
|
42155
|
+
var qs=Q.filter(function(q){return q[1]===S.area});
|
|
42156
|
+
return warnsHtml()+'<h2>'+a[1]+'</h2><p class="intro">“'+a[2]+'”</p>'
|
|
42157
|
+
+qs.map(function(q){
|
|
42158
|
+
var done=S.ans[q[0]]!==undefined&&S.ans[q[0]]!=="",sk=!!S.skip[q[0]];
|
|
42159
|
+
return '<div class="q '+(sk?"skip":done?"done":"")+'" id="q_'+q[0]+'">'
|
|
42160
|
+
+'<p class="ask">'+q[2]+'</p>'
|
|
42161
|
+
+(q[3]?'<p class="probe">If they go vague: “'+q[3]+'”</p>':'')
|
|
42162
|
+
+inputFor(q)+'<p class="why">'+q[5]+'</p></div>'}).join("")
|
|
42163
|
+
+'<div class="btnrow"><button class="btn" onclick="nextArea()">Next area</button>'
|
|
42164
|
+
+'<button class="btn ghost" onclick="tab(\\'find\\')">See findings</button></div>'}
|
|
42165
|
+
|
|
42166
|
+
function setHtml(){
|
|
42167
|
+
return '<h2>Your assumptions</h2><p class="intro">Every one of these prints in the report next to the figure it moved. Set one to zero and its finding disappears rather than falling back to an industry number.</p><div class="card">'
|
|
42168
|
+
+ASSUMPTIONS.map(function(a){
|
|
42169
|
+
return '<div class="assume"><div><b>'+a[1]+'</b><br><span class="unit">'+a[3]+'</span></div>'
|
|
42170
|
+
+'<input class="a" style="max-width:5rem" value="'+(S.asm[a[0]])+'" oninput="setAsm(\\''+a[0]+'\\',this.value)"></div>'}).join("")
|
|
42171
|
+
+'</div><div class="card"><h2 style="font-size:1rem">Who is this for?</h2>'
|
|
42172
|
+
+'<div class="row"><input class="a" id="f_biz" placeholder="Business name" value="'+(S.biz||"")+'" oninput="S.biz=this.value;save();head()">'
|
|
42173
|
+
+'<input class="a" placeholder="Trade" value="'+(S.trade||"")+'" oninput="S.trade=this.value;save();head()"></div></div>'}
|
|
42174
|
+
|
|
42175
|
+
function findHtml(){
|
|
42176
|
+
var r=R();
|
|
42177
|
+
var vb={findings:"note go",thin:"note warn",insufficient:"note stop"}[r.verdict];
|
|
42178
|
+
var h=warnsHtml()+'<h2>What it found</h2><div class="'+vb+'"><b>Verdict: '+r.verdict+'</b>'+r.line+'</div>';
|
|
42179
|
+
if(r.F.length){
|
|
42180
|
+
h+='<div class="card"><table><thead><tr><th>Finding</th><th class="num">A month</th></tr></thead><tbody>'
|
|
42181
|
+
+r.F.map(function(f){return '<tr><td>'+f.title+(f.asm?' <span class="unit">(uses your '+f.asm.replace(/_/g," ")+')</span>':'')+'</td><td class="num">'+money(f.monthly)+'</td></tr>'}).join("")
|
|
42182
|
+
+'<tr><td><b>Leaking, monthly</b></td><td class="num"><b>'+money(r.monthly)+'</b></td></tr>'
|
|
42183
|
+
+'<tr><td><b>Expected to stop, at your '+Math.round((S.asm.recovery||0)*100)+'%</b></td><td class="num"><b>'+money(r.stop)+'</b></td></tr>'
|
|
42184
|
+
+(r.oneoff!==null?'<tr><td>Dormant list, worked once <span class="unit">(one-off, not in the annual)</span></td><td class="num">'+money(r.oneoff)+'</td></tr>':'')
|
|
42185
|
+
+'</tbody></table></div>'}
|
|
42186
|
+
if(r.hrs.length){
|
|
42187
|
+
h+='<div class="card"><table><thead><tr><th>Where the time goes</th><th class="num">Hrs/week</th></tr></thead><tbody>'
|
|
42188
|
+
+r.hrs.map(function(x){return '<tr><td>'+x.t+'</td><td class="num">'+x.h.toFixed(1)+'</td></tr>'}).join("")
|
|
42189
|
+
+'<tr><td><b>Total</b></td><td class="num"><b>'+r.hrsWk.toFixed(1)+'</b></td></tr></tbody></table></div>'}
|
|
42190
|
+
if(r.O.length){
|
|
42191
|
+
h+='<div class="note warn"><b>Not priced, and deliberately not guessed</b>'
|
|
42192
|
+
+r.O.map(function(o){return o.declined?(o.title+" — you set "+o.declined.replace(/_/g," ")+" to zero")
|
|
42193
|
+
:(o.title+" — missing: "+o.miss.join(", "))}).join("<br>")+'</div>'}
|
|
42194
|
+
h+='<div class="btnrow"><button class="btn" onclick="tab(\\'rep\\')">Build the report</button></div>';
|
|
42195
|
+
return h}
|
|
42196
|
+
|
|
42197
|
+
function reportHtml(){
|
|
42198
|
+
var r=R();
|
|
42199
|
+
var answered=[];
|
|
42200
|
+
Q.forEach(function(q){
|
|
42201
|
+
var id=q[0];
|
|
42202
|
+
if(S.skip[id]) answered.push({ask:q[2],unknown:true,unit:q[4]});
|
|
42203
|
+
else if(S.ans[id]!==undefined&&String(S.ans[id]).trim()!=="") answered.push({ask:q[2],value:S.ans[id],unit:q[4]});
|
|
42204
|
+
});
|
|
42205
|
+
var labels={};
|
|
42206
|
+
ASSUMPTIONS.forEach(function(a){labels[a[0]]=a[3]});
|
|
42207
|
+
return renderReport({
|
|
42208
|
+
state:{biz:S.biz,trade:S.trade,ans:S.ans},
|
|
42209
|
+
computed:r, agency:S.agency||{}, services:S.services||[],
|
|
42210
|
+
assumptionLabels:labels,
|
|
42211
|
+
date:new Date().toLocaleDateString(undefined,{day:"numeric",month:"long",year:"numeric"}),
|
|
42212
|
+
answered:answered
|
|
42213
|
+
});
|
|
42214
|
+
}
|
|
42215
|
+
function repHtml(){
|
|
42216
|
+
var r=R();
|
|
42217
|
+
var doc=reportHtml();
|
|
42218
|
+
return '<h2>The report</h2><p class="intro">This is the document, exactly as your prospect receives it. Your branding, your prices, every estimate marked where it is used.</p>'
|
|
42219
|
+
+warnsHtml()
|
|
42220
|
+
+'<div class="card" style="padding:0;overflow:hidden"><iframe id="rpv" style="width:100%;height:34rem;border:0;background:#fff" title="Report preview"></iframe></div>'
|
|
42221
|
+
+'<div class="btnrow"><button class="btn" onclick="openReport()">Open it full size</button>'
|
|
42222
|
+
+'<button class="btn ghost" onclick="showSource()">Save the report</button>'
|
|
42223
|
+
+'<button class="btn ghost" onclick="dl()">Save answers</button></div>'
|
|
42224
|
+
+'<div class="note"><b>Publishing it</b>Open it full size and print to PDF, or hand the file to your own site. Putting it on your domain automatically is the next piece, and it is not built yet.</div>';
|
|
42225
|
+
}
|
|
42226
|
+
function paintReport(){
|
|
42227
|
+
var f=document.getElementById("rpv");
|
|
42228
|
+
if(f) f.srcdoc=reportHtml();
|
|
42229
|
+
}
|
|
42230
|
+
function openReport(){
|
|
42231
|
+
var w=window.open("","_blank");
|
|
42232
|
+
if(w){w.document.open();w.document.write(reportHtml());w.document.close();}
|
|
42233
|
+
}
|
|
42234
|
+
function showSource(){
|
|
42235
|
+
var text=reportHtml(), name=slug(S.biz)+"-report.html";
|
|
42236
|
+
dump(savedMsg(saveFile(name,text,"text/html"),name), text);
|
|
42237
|
+
}
|
|
42238
|
+
function draw(){
|
|
42239
|
+
head();
|
|
42240
|
+
document.getElementById("rail").innerHTML=railHtml();
|
|
42241
|
+
document.getElementById("body").innerHTML=
|
|
42242
|
+
S.tab==="ask"?askHtml():S.tab==="set"?setHtml():S.tab==="find"?findHtml():repHtml();
|
|
42243
|
+
if(S.tab==="rep") paintReport();
|
|
42244
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
42245
|
+
b.className=b.dataset.t===S.tab?"on":""})}
|
|
42246
|
+
|
|
42247
|
+
function setA(id,v){S.ans[id]=v;if(v!=="")S.skip[id]=false;save();head();
|
|
42248
|
+
var el=document.getElementById("q_"+id);if(el)el.className="q "+(v!==""?"done":"")}
|
|
42249
|
+
function skip(id){S.skip[id]=!S.skip[id];if(S.skip[id])S.ans[id]="";save();draw()}
|
|
42250
|
+
function why(id){var el=document.getElementById("q_"+id);if(el)el.classList.toggle("open")}
|
|
42251
|
+
function setAsm(k,v){S.asm[k]=parseRate(v);save();head()}
|
|
42252
|
+
function goArea(a){S.area=a;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
42253
|
+
function nextArea(){var i=AREAS.map(function(x){return x[0]}).indexOf(S.area);
|
|
42254
|
+
if(i<AREAS.length-1)goArea(AREAS[i+1][0]);else tab("find")}
|
|
42255
|
+
function tab(t){S.tab=t;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
42256
|
+
/** One copy box, two callers. */
|
|
42257
|
+
function dump(title,text){
|
|
42258
|
+
var d=document.getElementById("dump"); if(d)d.remove();
|
|
42259
|
+
var w=document.createElement("div");w.id="dump";w.className="card";
|
|
42260
|
+
w.innerHTML='<b style="font-size:var(--fs-sm)"></b>'
|
|
42261
|
+
+'<textarea class="a" style="min-height:12rem;font-family:var(--mono);font-size:var(--fs-sm)"></textarea>';
|
|
42262
|
+
w.querySelector("b").textContent=title;
|
|
42263
|
+
document.getElementById("body").appendChild(w);
|
|
42264
|
+
var t=w.querySelector("textarea");t.value=text;t.focus();t.select();
|
|
42265
|
+
w.scrollIntoView({behavior:"smooth",block:"nearest"});
|
|
42266
|
+
}
|
|
42267
|
+
function slug(s){return String(s||"assessment").toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-+|-+$/g,"")||"assessment"}
|
|
42268
|
+
/**
|
|
42269
|
+
* Write a real file to the operator's downloads.
|
|
42270
|
+
*
|
|
42271
|
+
* The button said "Save answers" and saved nothing \u2014 it opened a textarea and
|
|
42272
|
+
* expected a human to select and copy it. Now it downloads, and the copy box
|
|
42273
|
+
* stays as the fallback for the browsers that refuse a download from file://.
|
|
42274
|
+
*/
|
|
42275
|
+
function saveFile(name,text,type){
|
|
42276
|
+
try{
|
|
42277
|
+
var b=new Blob([text],{type:type});
|
|
42278
|
+
var u=URL.createObjectURL(b);
|
|
42279
|
+
var a=document.createElement("a");a.href=u;a.download=name;a.style.display="none";
|
|
42280
|
+
document.body.appendChild(a);a.click();document.body.removeChild(a);
|
|
42281
|
+
setTimeout(function(){URL.revokeObjectURL(u)},4000);
|
|
42282
|
+
return true;
|
|
42283
|
+
}catch(e){return false}
|
|
42284
|
+
}
|
|
42285
|
+
/**
|
|
42286
|
+
* What to say about a download we cannot confirm.
|
|
42287
|
+
*
|
|
42288
|
+
* \`a.click()\` throws when the API is missing, and stays silent when the browser
|
|
42289
|
+
* simply refuses \u2014 which is exactly what some Chrome configurations do to a page
|
|
42290
|
+
* opened from file://, the way this one ships. So a true return means "asked",
|
|
42291
|
+
* never "saved", so announcing a completed save on the strength of it would be
|
|
42292
|
+
* a confident claim about something unobserved. The copy box is shown every
|
|
42293
|
+
* time and the wording is true under both outcomes.
|
|
42294
|
+
*/
|
|
42295
|
+
function savedMsg(ok,name){
|
|
42296
|
+
return ok
|
|
42297
|
+
? "Asked your browser to save it as "+name+". If it did not appear in your downloads, it was blocked \u2014 the same text is here, so select all, copy, and save it as "+name+"."
|
|
42298
|
+
: "Your browser would not save the file. Select all, copy, and save it as "+name+".";
|
|
42299
|
+
}
|
|
42300
|
+
function dl(){
|
|
42301
|
+
var text=JSON.stringify(S,null,2), name=slug(S.biz)+"-answers.json";
|
|
42302
|
+
dump(savedMsg(saveFile(name,text,"application/json"),name), text);
|
|
42303
|
+
}
|
|
42304
|
+
function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.tab="ask";S.area="money";save();draw()}
|
|
42305
|
+
|
|
42306
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
42307
|
+
b.onclick=function(){tab(b.dataset.t)}});
|
|
42308
|
+
draw();
|
|
42309
|
+
</script>
|
|
42310
|
+
</body>
|
|
42311
|
+
</html>
|
|
42312
|
+
`;
|
|
42313
|
+
}
|
|
42314
|
+
});
|
|
42315
|
+
|
|
41318
42316
|
// src/command-os/design/brand-page.ts
|
|
41319
42317
|
function head2(title2) {
|
|
41320
42318
|
return `${cockpitHeadHtml(`${esc4(title2)} \u2014 Command OS`, { extraCss: STYLE })}<body>
|
|
@@ -42562,7 +43560,6 @@ __export(dashboard_exports, {
|
|
|
42562
43560
|
SEAT_TOKEN_HEADER: () => SEAT_TOKEN_HEADER,
|
|
42563
43561
|
STAGES: () => STAGES2,
|
|
42564
43562
|
accountTypeOf: () => accountTypeOf,
|
|
42565
|
-
assessmentPagePath: () => assessmentPagePath,
|
|
42566
43563
|
checkMutationGuard: () => checkMutationGuard,
|
|
42567
43564
|
checkSeatToken: () => checkSeatToken,
|
|
42568
43565
|
clearPending: () => clearPending,
|
|
@@ -42596,25 +43593,6 @@ function recoverStuckStages(state) {
|
|
|
42596
43593
|
}));
|
|
42597
43594
|
return { state: { ...state, clients }, recovered };
|
|
42598
43595
|
}
|
|
42599
|
-
function assessmentPagePath(dirname14 = __dirname) {
|
|
42600
|
-
const candidates = [
|
|
42601
|
-
path31.join(dirname14, "assessment.html"),
|
|
42602
|
-
// dist/, as published
|
|
42603
|
-
path31.join(dirname14, "..", "dist", "assessment.html"),
|
|
42604
|
-
// running from src/
|
|
42605
|
-
path31.join(dirname14, "..", "src", "command-os", "assessment", "app.html"),
|
|
42606
|
-
// dist/ in a dev checkout
|
|
42607
|
-
path31.join(dirname14, "command-os", "assessment", "app.html")
|
|
42608
|
-
// src/
|
|
42609
|
-
];
|
|
42610
|
-
for (const c of candidates) {
|
|
42611
|
-
try {
|
|
42612
|
-
if (fs32.existsSync(c) && fs32.statSync(c).isFile()) return c;
|
|
42613
|
-
} catch {
|
|
42614
|
-
}
|
|
42615
|
-
}
|
|
42616
|
-
return null;
|
|
42617
|
-
}
|
|
42618
43596
|
function cockpitStatePath() {
|
|
42619
43597
|
return path31.join(appDataDir(), "cockpit-state.json");
|
|
42620
43598
|
}
|
|
@@ -43906,12 +44884,6 @@ async function runDashboard(argv) {
|
|
|
43906
44884
|
return;
|
|
43907
44885
|
}
|
|
43908
44886
|
if ((req.method === "GET" || req.method === "HEAD") && (url === "/assessment" || url === "/assessment.html")) {
|
|
43909
|
-
const page = assessmentPagePath();
|
|
43910
|
-
if (!page) {
|
|
43911
|
-
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
|
43912
|
-
res.end("The assessment page is not in this install. Update GHL Command and restart the cockpit.");
|
|
43913
|
-
return;
|
|
43914
|
-
}
|
|
43915
44887
|
res.writeHead(200, {
|
|
43916
44888
|
"Content-Type": "text/html; charset=utf-8",
|
|
43917
44889
|
// It holds another business's revenue and close rate the moment it is
|
|
@@ -43919,7 +44891,7 @@ async function runDashboard(argv) {
|
|
|
43919
44891
|
"Cache-Control": "no-store",
|
|
43920
44892
|
"X-Robots-Tag": "noindex, nofollow"
|
|
43921
44893
|
});
|
|
43922
|
-
res.end(req.method === "HEAD" ? void 0 : withCockpitShell(
|
|
44894
|
+
res.end(req.method === "HEAD" ? void 0 : withCockpitShell(ASSESSMENT_HTML, "assessment"));
|
|
43923
44895
|
return;
|
|
43924
44896
|
}
|
|
43925
44897
|
if (req.method === "GET" && url === "/api/snapshots") {
|
|
@@ -44839,6 +45811,7 @@ var init_dashboard = __esm({
|
|
|
44839
45811
|
init_agency_profile_page();
|
|
44840
45812
|
init_cockpit_shell();
|
|
44841
45813
|
init_anthropic_key();
|
|
45814
|
+
init_app_html();
|
|
44842
45815
|
init_cli();
|
|
44843
45816
|
init_brand_page();
|
|
44844
45817
|
init_store();
|
|
@@ -45432,7 +46405,7 @@ function preflightWritable() {
|
|
|
45432
46405
|
}
|
|
45433
46406
|
}
|
|
45434
46407
|
function restartReminder() {
|
|
45435
|
-
errLine("Note: if
|
|
46408
|
+
errLine("Note: if GHL Command is currently running, restart it to pick up this change");
|
|
45436
46409
|
errLine("(registry writes are last-writer-wins across processes \u2014 seed stopped, or restart after).");
|
|
45437
46410
|
}
|
|
45438
46411
|
function confirmSaved(registry2) {
|
|
@@ -45741,7 +46714,7 @@ Subcommands:
|
|
|
45741
46714
|
list-locations Print registered locations / companies (never prints keys)
|
|
45742
46715
|
|
|
45743
46716
|
Exit codes: 0 ok, 2 usage, 3 validation failed, 4 filesystem write failed.
|
|
45744
|
-
Seed while
|
|
46717
|
+
Seed while GHL Command is stopped, or restart it afterwards.`;
|
|
45745
46718
|
}
|
|
45746
46719
|
});
|
|
45747
46720
|
|
|
@@ -45750,7 +46723,7 @@ var require_package = __commonJS({
|
|
|
45750
46723
|
"package.json"(exports2, module2) {
|
|
45751
46724
|
module2.exports = {
|
|
45752
46725
|
name: "@elitedcs/ghl-mcp",
|
|
45753
|
-
version: "3.81.
|
|
46726
|
+
version: "3.81.1",
|
|
45754
46727
|
mcpName: "io.github.drjerryrelth/ghl-command",
|
|
45755
46728
|
description: "GoHighLevel MCP Server for Claude. 250 tools \u2014 full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
45756
46729
|
main: "dist/index.js",
|
|
@@ -45774,7 +46747,7 @@ var require_package = __commonJS({
|
|
|
45774
46747
|
"!skills/blueprint/presets/clinic-launch-a2p.md"
|
|
45775
46748
|
],
|
|
45776
46749
|
scripts: {
|
|
45777
|
-
build: "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external
|
|
46750
|
+
build: "node src/command-os/assessment/build-app.mjs && esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external",
|
|
45778
46751
|
setup: "node setup-wizard.mjs",
|
|
45779
46752
|
catalogue: "node scripts/export-command-os-catalogue.mjs",
|
|
45780
46753
|
"manifest:write": "node scripts/write-package-manifest.mjs",
|
|
@@ -46241,7 +47214,7 @@ init_version_check();
|
|
|
46241
47214
|
function registerMetaTools(server2, installedVersion) {
|
|
46242
47215
|
server2.tool(
|
|
46243
47216
|
"get_mcp_version",
|
|
46244
|
-
"Check the installed version of GHL Command
|
|
47217
|
+
"Check the installed version of GHL Command against the latest version published to npm. Use this to confirm an upgrade landed after restarting Claude. Returns installed version, latest version, whether up-to-date, and the one-line restart instruction if not.",
|
|
46245
47218
|
{},
|
|
46246
47219
|
async () => {
|
|
46247
47220
|
const status = await getVersionStatus(installedVersion);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elitedcs/ghl-mcp",
|
|
3
|
-
"version": "3.81.
|
|
3
|
+
"version": "3.81.1",
|
|
4
4
|
"mcpName": "io.github.drjerryrelth/ghl-command",
|
|
5
5
|
"description": "GoHighLevel MCP Server for Claude. 250 tools — full CRM, automation, marketing control, account-wide workflow audit, live funnel-capture verification, and the only programmatic GHL workflow builder, now multi-tenant across client accounts.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"!skills/blueprint/presets/clinic-launch-a2p.md"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"build": "esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external
|
|
27
|
+
"build": "node src/command-os/assessment/build-app.mjs && esbuild src/index.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/index.js --packages=external && esbuild src/capture-helper.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/capture-helper.js --packages=external",
|
|
28
28
|
"setup": "node setup-wizard.mjs",
|
|
29
29
|
"catalogue": "node scripts/export-command-os-catalogue.mjs",
|
|
30
30
|
"manifest:write": "node scripts/write-package-manifest.mjs",
|