@elitedcs/ghl-mcp 3.72.2 → 3.73.0
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 +63 -0
- package/dist/assessment.html +789 -0
- package/dist/index.js +1587 -485
- package/guide/guide.html +1 -1
- package/package.json +6 -4
- package/templates/action-schemas.json +2 -2
- package/templates/clinic-medspa.json +1 -1
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
<title>Assessment</title>
|
|
2
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Spectral:wght@600&family=Public+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap">
|
|
3
|
+
<style>
|
|
4
|
+
:root{--ground:#F7F8FA;--panel:#EDEFF3;--panel-2:#E4E8EE;--paper:#FFF;--ink:#14171F;--muted:#4A5163;
|
|
5
|
+
--rule:#D8DDE5;--accent:#1F4E79;--soft:#1f4e7914;--stop:#9A3324;--go:#1D6B4A;--warn:#8A5300;
|
|
6
|
+
--shadow:0 1px 2px #14171f0d,0 8px 24px #14171f0a}
|
|
7
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme=light]){--ground:#0E1116;--panel:#161B22;
|
|
8
|
+
--panel-2:#1D242E;--paper:#12171E;--ink:#E9ECF1;--muted:#9AA4B4;--rule:#262E39;--accent:#79B0E8;
|
|
9
|
+
--soft:#79b0e81f;--stop:#E8867A;--go:#6FCB9F;--warn:#E0A64A;--shadow:0 1px 2px #0007,0 8px 24px #0005}}
|
|
10
|
+
:root[data-theme=dark]{--ground:#0E1116;--panel:#161B22;--panel-2:#1D242E;--paper:#12171E;--ink:#E9ECF1;
|
|
11
|
+
--muted:#9AA4B4;--rule:#262E39;--accent:#79B0E8;--soft:#79b0e81f;--stop:#E8867A;--go:#6FCB9F;
|
|
12
|
+
--warn:#E0A64A;--shadow:0 1px 2px #0007,0 8px 24px #0005}
|
|
13
|
+
html{font-size:21px}*{box-sizing:border-box}
|
|
14
|
+
body{margin:0;background:var(--ground);color:var(--ink);font-family:"Public Sans",-apple-system,sans-serif;
|
|
15
|
+
font-size:1rem;line-height:1.5}
|
|
16
|
+
.tsz{position:fixed;top:.4rem;right:.4rem;z-index:70;display:flex;gap:.2rem;background:var(--panel);
|
|
17
|
+
border:1px solid var(--rule);border-radius:.3rem;padding:.14rem;box-shadow:var(--shadow)}
|
|
18
|
+
.tsz button{font-family:"JetBrains Mono",monospace;font-size:.6rem;min-width:1.7rem;padding:.28rem .16rem;
|
|
19
|
+
cursor:pointer;color:var(--ink);background:var(--ground);border:1px solid var(--rule);border-radius:.2rem}
|
|
20
|
+
.top{background:var(--paper);border-bottom:1px solid var(--rule);padding:.6rem .9rem;position:sticky;top:0;z-index:50}
|
|
21
|
+
.topin{max-width:60rem;margin:0 auto;display:flex;gap:1rem;align-items:baseline;flex-wrap:wrap}
|
|
22
|
+
.topin h1{font-family:Spectral,Georgia,serif;font-size:1.05rem;margin:0;font-weight:600}
|
|
23
|
+
.who{color:var(--muted);font-size:.78rem}
|
|
24
|
+
.live{margin-left:auto;display:flex;gap:1rem;font-family:"JetBrains Mono",monospace;font-size:.72rem}
|
|
25
|
+
.live b{display:block;font-size:.92rem;font-weight:500}
|
|
26
|
+
.live .m{color:var(--stop)}.live .h{color:var(--accent)}.live .p{color:var(--muted)}
|
|
27
|
+
.tabs{max-width:60rem;margin:0 auto;display:flex;gap:.3rem;padding:.5rem .9rem 0}
|
|
28
|
+
.tabs button{background:none;border:none;border-bottom:2px solid transparent;padding:.3rem .5rem;
|
|
29
|
+
cursor:pointer;font:inherit;font-size:.82rem;color:var(--muted)}
|
|
30
|
+
.tabs button.on{color:var(--accent);border-bottom-color:var(--accent);font-weight:600}
|
|
31
|
+
.main{max-width:60rem;margin:0 auto;padding:.9rem;display:grid;grid-template-columns:13rem 1fr;gap:1rem}
|
|
32
|
+
@media(max-width:760px){.main{grid-template-columns:1fr}}
|
|
33
|
+
.rail{align-self:start;position:sticky;top:4.2rem}
|
|
34
|
+
@media(max-width:760px){.rail{position:static;display:flex;overflow-x:auto;gap:.3rem}}
|
|
35
|
+
.rail button{display:block;width:100%;text-align:left;background:none;border:none;cursor:pointer;font:inherit;
|
|
36
|
+
font-size:.76rem;color:var(--muted);padding:.32rem .45rem;border-radius:.22rem;line-height:1.3}
|
|
37
|
+
@media(max-width:760px){.rail button{white-space:nowrap;width:auto}}
|
|
38
|
+
.rail button.on{background:var(--accent);color:var(--paper);font-weight:600}
|
|
39
|
+
.rail button:hover:not(.on){background:var(--soft);color:var(--accent)}
|
|
40
|
+
.rail .c{font-family:"JetBrains Mono",monospace;font-size:.6rem;opacity:.75;float:right}
|
|
41
|
+
.intro{color:var(--muted);font-size:.85rem;font-style:italic;margin:0 0 .8rem;max-width:60ch}
|
|
42
|
+
h2{font-family:Spectral,Georgia,serif;font-size:1.16rem;margin:0 0 .15rem;font-weight:600}
|
|
43
|
+
.q{background:var(--paper);border:1px solid var(--rule);border-left:3px solid var(--rule);
|
|
44
|
+
border-radius:.3rem;padding:.6rem .75rem;margin:.5rem 0}
|
|
45
|
+
.q.done{border-left-color:var(--go)}.q.skip{border-left-color:var(--muted);opacity:.72}
|
|
46
|
+
.q .ask{font-size:.9rem;font-weight:600;margin:0 0 .2rem}
|
|
47
|
+
.q .probe{font-size:.76rem;color:var(--muted);font-style:italic;margin:0 0 .35rem}
|
|
48
|
+
.q .why{font-size:.73rem;color:var(--muted);margin:.3rem 0 0;display:none}
|
|
49
|
+
.q.open .why{display:block}
|
|
50
|
+
.row{display:flex;gap:.4rem;align-items:center;flex-wrap:wrap}
|
|
51
|
+
input.a,select.a,textarea.a{background:var(--ground);border:1px solid var(--rule);border-radius:.22rem;
|
|
52
|
+
padding:.3rem .45rem;font:inherit;font-size:.84rem;color:var(--ink);max-width:11rem}
|
|
53
|
+
textarea.a{max-width:100%;width:100%;min-height:2.6rem}
|
|
54
|
+
input.a:focus,select.a:focus,textarea.a:focus{outline:2px solid var(--accent);outline-offset:1px}
|
|
55
|
+
.unit{font-family:"JetBrains Mono",monospace;font-size:.68rem;color:var(--muted)}
|
|
56
|
+
.mini{background:none;border:1px solid var(--rule);border-radius:.2rem;padding:.2rem .45rem;cursor:pointer;
|
|
57
|
+
font:inherit;font-size:.7rem;color:var(--muted)}
|
|
58
|
+
.mini:hover{border-color:var(--accent);color:var(--accent)}
|
|
59
|
+
.mini.on{background:var(--panel-2);color:var(--ink)}
|
|
60
|
+
.card{background:var(--paper);border:1px solid var(--rule);border-radius:.35rem;padding:.8rem .9rem;
|
|
61
|
+
margin:.6rem 0;box-shadow:var(--shadow)}
|
|
62
|
+
table{border-collapse:collapse;width:100%;font-size:.8rem}
|
|
63
|
+
th,td{text-align:left;padding:.35rem .5rem;border-bottom:1px solid var(--rule);vertical-align:top}
|
|
64
|
+
th{font-size:.6rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);font-weight:600}
|
|
65
|
+
.num{font-family:"JetBrains Mono",monospace;font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap}
|
|
66
|
+
th.num{text-align:right}
|
|
67
|
+
.note{background:var(--panel);border-left:3px solid var(--accent);border-radius:.22rem;padding:.5rem .65rem;
|
|
68
|
+
margin:.5rem 0;font-size:.79rem}
|
|
69
|
+
.note.warn{border-left-color:var(--warn)}.note.go{border-left-color:var(--go)}.note.stop{border-left-color:var(--stop)}
|
|
70
|
+
.note b{display:block;margin-bottom:.1rem}
|
|
71
|
+
.btn{background:var(--accent);color:var(--paper);border:none;border-radius:.22rem;padding:.4rem .8rem;
|
|
72
|
+
font:inherit;font-size:.8rem;font-weight:600;cursor:pointer}
|
|
73
|
+
.btn.ghost{background:none;color:var(--accent);border:1px solid var(--rule)}
|
|
74
|
+
.btnrow{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.9rem}
|
|
75
|
+
.assume{display:grid;grid-template-columns:1fr auto;gap:.5rem;align-items:center;padding:.35rem 0;
|
|
76
|
+
border-bottom:1px solid var(--rule);font-size:.82rem}
|
|
77
|
+
.hide{display:none}
|
|
78
|
+
</style>
|
|
79
|
+
|
|
80
|
+
<div class="tsz"><button id="td">A−</button><button id="tu">A+</button></div>
|
|
81
|
+
|
|
82
|
+
<div class="top"><div class="topin">
|
|
83
|
+
<h1 id="bizname">New assessment</h1>
|
|
84
|
+
<span class="who" id="bizmeta"></span>
|
|
85
|
+
<div class="live">
|
|
86
|
+
<div class="p"><b id="lProg">0/46</b>answered</div>
|
|
87
|
+
<div class="h"><b id="lHrs">—</b>hrs/week</div>
|
|
88
|
+
<div class="m"><b id="lMon">—</b>/month</div>
|
|
89
|
+
</div>
|
|
90
|
+
</div></div>
|
|
91
|
+
<div class="tabs">
|
|
92
|
+
<button data-t="ask" class="on">Ask</button>
|
|
93
|
+
<button data-t="set">Assumptions</button>
|
|
94
|
+
<button data-t="find">Findings</button>
|
|
95
|
+
<button data-t="rep">Report</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="main">
|
|
99
|
+
<nav class="rail" id="rail"></nav>
|
|
100
|
+
<div id="body"></div>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<script>
|
|
104
|
+
/**
|
|
105
|
+
* The assessment engine — the ONE place the questions and the findings maths live.
|
|
106
|
+
*
|
|
107
|
+
* Plain JavaScript with a single export block at the end, because it has two
|
|
108
|
+
* consumers with incompatible needs: the test suite imports it, and build-app.mjs
|
|
109
|
+
* inlines it into app.html (which must stay a single self-contained file, since
|
|
110
|
+
* Command OS serves it from the operator's own machine with no build step and no
|
|
111
|
+
* network). The build strips the export block; nothing else about the file changes.
|
|
112
|
+
*
|
|
113
|
+
* It existed as two copies for one day — once here and once inside app.html — and
|
|
114
|
+
* two copies of a rule that decides what a client is told is a defect waiting to
|
|
115
|
+
* happen, so it is one copy now.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
var AREAS = [
|
|
119
|
+
["money", "What the work is worth", "Before anything else I need two numbers, because everything today gets multiplied by them."],
|
|
120
|
+
["phone", "The phone", "Let's start where most of it goes."],
|
|
121
|
+
["speed", "How fast anyone replies", "Now the enquiries that don't come by phone."],
|
|
122
|
+
["repeat", "The same questions, again", "This one is about your time rather than your money."],
|
|
123
|
+
["quotes", "After the quote goes out", "Walk me through what happens after you send a price."],
|
|
124
|
+
["booking", "Getting them in the diary", "How does an appointment actually get made?"],
|
|
125
|
+
["dormant", "Everyone who went quiet", "Now the cheapest money in the building, and the part nobody works."],
|
|
126
|
+
["reviews", "What people see about you", "Your reviews, and how they happen."],
|
|
127
|
+
["website", "What your website does", "What the site is actually for."],
|
|
128
|
+
["found", "Whether you get found", "And how people arrive in the first place."],
|
|
129
|
+
["week", "Your own week", "Last part, and it's about you rather than the business."]
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
var Q = [
|
|
133
|
+
["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."],
|
|
134
|
+
["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."],
|
|
135
|
+
["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."],
|
|
136
|
+
["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."],
|
|
137
|
+
|
|
138
|
+
["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."],
|
|
139
|
+
["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."],
|
|
140
|
+
["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."],
|
|
141
|
+
["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."],
|
|
142
|
+
["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."],
|
|
143
|
+
["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."],
|
|
144
|
+
|
|
145
|
+
["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."],
|
|
146
|
+
["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."],
|
|
147
|
+
["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."],
|
|
148
|
+
["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."],
|
|
149
|
+
|
|
150
|
+
["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."],
|
|
151
|
+
["repeat_time","repeat","How much of your week goes on answering those?","","hours","Straight into the hours number."],
|
|
152
|
+
["who_fields","repeat","Who fields them, you or someone else?","","text","If it's a person on payroll, this finding has a salary attached."],
|
|
153
|
+
|
|
154
|
+
["quotes_month","quotes","How many quotes or estimates go out in a month?","","count","These are your warmest people. They already talked to you."],
|
|
155
|
+
["quotes_won","quotes","How many of those turn into work?","","count","The gap between sent and won is the follow-up finding."],
|
|
156
|
+
["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."],
|
|
157
|
+
["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."],
|
|
158
|
+
["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."],
|
|
159
|
+
|
|
160
|
+
["booked_month","booking","How many appointments or site visits get booked in a month?","","count","The base for the no-show finding."],
|
|
161
|
+
["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."],
|
|
162
|
+
["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."],
|
|
163
|
+
["noshow_fate","booking","When someone no-shows, what happens next?","","text","'Nothing' means the rebooking finding is real."],
|
|
164
|
+
|
|
165
|
+
["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."],
|
|
166
|
+
["dormant_worked","dormant","Has anyone ever gone back to that list?","","text","If nothing has ever been sent, the whole list is untouched revenue."],
|
|
167
|
+
["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."],
|
|
168
|
+
["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."],
|
|
169
|
+
|
|
170
|
+
["review_count","reviews","How many Google reviews do you have?","","count","The number a new customer sees before they call you."],
|
|
171
|
+
["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."],
|
|
172
|
+
["review_reply","reviews","Do you reply to them?","","text","An unanswered bad review is the first thing a new customer reads."],
|
|
173
|
+
["review_time","reviews","How many hours a week go on chasing reviews?","","hours","Small on its own. Adds up with the rest."],
|
|
174
|
+
|
|
175
|
+
["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."],
|
|
176
|
+
["site_capture","website","Can someone book you from the website without speaking to anyone?","","text","If not, every after-hours visitor is gone."],
|
|
177
|
+
["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."],
|
|
178
|
+
["site_age","website","When was the site last touched?","","text","Decides rebuild versus repair."],
|
|
179
|
+
|
|
180
|
+
["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."],
|
|
181
|
+
["paid_spend","found","Are you spending anything on advertising each month?","","currency","If they are, every leak we find is also wasted ad spend."],
|
|
182
|
+
["gbp_status","found","Do you manage your Google listing yourself?","","text","The cheapest visibility fix there is, and usually neglected."],
|
|
183
|
+
|
|
184
|
+
["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."],
|
|
185
|
+
["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."],
|
|
186
|
+
["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."],
|
|
187
|
+
["team_size","week","Who else is in the business?","","count","Decides whether a fix is automation or delegation."],
|
|
188
|
+
["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."]
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
var ASSUMPTIONS = [
|
|
192
|
+
["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."],
|
|
193
|
+
["quote_recovery","Chasing an unclosed quote recovers",0.10,"Assumes chasing recovers 1 in 10. Your number, not ours."],
|
|
194
|
+
["response_penalty","Fall-off once an enquiry has waited",0.25,"Assumes a 25% fall-off once they have waited. Your number."],
|
|
195
|
+
["noshow_recovery","A no-show that gets rebooked",0.50,"Assumes half could be rebooked. Your number."],
|
|
196
|
+
["reactivation","Dormant contacts who respond",0.02,"Assumes 2% come back. Your number. One-off, not annual."]
|
|
197
|
+
];
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Hours questions that feed the weekly total. evening_work is deliberately NOT
|
|
201
|
+
* here: it asks how much of admin_hours happens after dinner, so adding it would
|
|
202
|
+
* count the same hours twice. It is a headline, not an addend.
|
|
203
|
+
*/
|
|
204
|
+
var HOUR_KEYS = [
|
|
205
|
+
["repeat_time", "Answering the same questions"],
|
|
206
|
+
["quote_time", "Putting quotes together"],
|
|
207
|
+
["review_time", "Chasing reviews"],
|
|
208
|
+
["admin_hours", "Paperwork and admin"]
|
|
209
|
+
];
|
|
210
|
+
|
|
211
|
+
/** The two answers without which nothing at all can be priced. */
|
|
212
|
+
var PRICING_CORE = ["avg_value", "close_rate"];
|
|
213
|
+
|
|
214
|
+
/** id -> unit, so number parsing can be unit-aware. Built from Q, never hand-listed. */
|
|
215
|
+
var UNIT = {};
|
|
216
|
+
Q.forEach(function (q) { UNIT[q[0]] = q[4]; });
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Parse a number the way the person typing it meant it.
|
|
220
|
+
*
|
|
221
|
+
* The trap this exists for: an owner outside the US types 6.800 meaning six
|
|
222
|
+
* thousand eight hundred. Read naively that is 6.8, and the report tells them
|
|
223
|
+
* their business leaks $24 a month instead of $24,412 — plausible, silent, and
|
|
224
|
+
* completely wrong, on their own letterhead. So a dot used as a THOUSANDS
|
|
225
|
+
* separator (digits in exact groups of three) is stripped, and only for units
|
|
226
|
+
* where that grouping is possible. A rate is never grouped, so 0.500 stays 0.5.
|
|
227
|
+
*/
|
|
228
|
+
function parseTyped(text, unit) {
|
|
229
|
+
var t = String(text).trim().replace(/[^0-9.,\-]/g, "");
|
|
230
|
+
if (t === "" || t === "-") return NaN;
|
|
231
|
+
var grouped = unit !== "rate";
|
|
232
|
+
if (grouped && /^-?\d{1,3}(\.\d{3})+$/.test(t)) t = t.replace(/\./g, ""); // 6.800 -> 6800
|
|
233
|
+
if (grouped && /^-?\d{1,3}(,\d{3})+(\.\d+)?$/.test(t)) t = t.replace(/,/g, ""); // 6,800.50 -> 6800.50
|
|
234
|
+
t = t.replace(/,/g, "."); // 6,5 -> 6.5
|
|
235
|
+
var parts = t.split(".");
|
|
236
|
+
if (parts.length > 2) return NaN; // 6.800.500 is not a number we will guess at
|
|
237
|
+
return parseFloat(t);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Read an assumption the operator typed, in the same way an answer is read.
|
|
242
|
+
*
|
|
243
|
+
* These went through a bare parseFloat until 2026-08-30, which meant an operator
|
|
244
|
+
* typing 0,5 got 0 — and an assumption of zero DROPS its finding. So the report
|
|
245
|
+
* quietly lost a finding because of a comma, and said nothing about it. Same
|
|
246
|
+
* class as the 6.800 bug, one screen over.
|
|
247
|
+
*/
|
|
248
|
+
function parseRate(text) {
|
|
249
|
+
var n = parseTyped(text, "rate");
|
|
250
|
+
if (!isFinite(n) || n < 0) return 0;
|
|
251
|
+
return n > 1 ? n / 100 : n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Compute findings from raw answers.
|
|
256
|
+
* ans { id: value as typed }
|
|
257
|
+
* skip { id: true } when the operator marked it unknown
|
|
258
|
+
* asm { id: rate as a decimal }
|
|
259
|
+
* A value is only usable when it is present, not skipped, and finite. Anything
|
|
260
|
+
* else is treated as unknown, and an unknown OMITS its finding rather than
|
|
261
|
+
* being defaulted from the industry.
|
|
262
|
+
*/
|
|
263
|
+
function compute(ans, skip, asm) {
|
|
264
|
+
ans = ans || {}; skip = skip || {}; asm = asm || {};
|
|
265
|
+
|
|
266
|
+
function raw(id) {
|
|
267
|
+
if (skip[id]) return null;
|
|
268
|
+
var v = ans[id];
|
|
269
|
+
if (v === undefined || v === null || String(v).trim() === "") return null;
|
|
270
|
+
var n = parseTyped(v, UNIT[id]);
|
|
271
|
+
return isFinite(n) ? n : null;
|
|
272
|
+
}
|
|
273
|
+
/** A share typed as either 28 or 0.28. Anything outside 0..1 after scaling is not a share. */
|
|
274
|
+
function share(id) {
|
|
275
|
+
var n = raw(id);
|
|
276
|
+
if (n === null) return null;
|
|
277
|
+
var r = n > 1 ? n / 100 : n;
|
|
278
|
+
return r >= 0 && r <= 1 ? r : null;
|
|
279
|
+
}
|
|
280
|
+
function a(k) { var n = Number(asm[k]); return isFinite(n) && n > 0 ? n : 0; }
|
|
281
|
+
|
|
282
|
+
var v = raw("avg_value"), c = share("close_rate");
|
|
283
|
+
var core = v !== null && v > 0 && c !== null && c > 0;
|
|
284
|
+
var F = [], O = [];
|
|
285
|
+
|
|
286
|
+
function add(id, title, needs, assumption, fn, fix) {
|
|
287
|
+
var missing = needs.filter(function (n) {
|
|
288
|
+
return (n.share ? share(n.id) : raw(n.id)) === null;
|
|
289
|
+
}).map(function (n) { return n.id; });
|
|
290
|
+
if (!core) { O.push({ id: id, title: title, missing: PRICING_CORE.filter(function (k) { return (k === "avg_value" ? v : c) === null; }) }); return; }
|
|
291
|
+
if (missing.length) { O.push({ id: id, title: title, missing: missing }); return; }
|
|
292
|
+
if (assumption && a(assumption) === 0) { O.push({ id: id, title: title, missing: [], declined: assumption }); return; }
|
|
293
|
+
var m = fn();
|
|
294
|
+
if (!isFinite(m) || m < 0) { O.push({ id: id, title: title, missing: needs.map(function (n) { return n.id; }) }); return; }
|
|
295
|
+
// Zero is not a finding. Listing "Quotes nobody chases — $0" pads a client
|
|
296
|
+
// document with things that are not happening.
|
|
297
|
+
if (m === 0) { O.push({ id: id, title: title, missing: [], reason: "nothing to price here on your numbers" }); return; }
|
|
298
|
+
F.push({ id: id, title: title, monthly: m, assumption: assumption || null, fix: fix });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
add("missed", "Calls you never get back to",
|
|
302
|
+
[{ id: "calls_missed" }, { id: "missed_never_return", share: true }], null,
|
|
303
|
+
function () { return raw("calls_missed") * share("missed_never_return") * c * v; },
|
|
304
|
+
"Call answering, day and night");
|
|
305
|
+
|
|
306
|
+
add("waited", "Enquiries that waited",
|
|
307
|
+
[{ id: "waited_month" }], "response_penalty",
|
|
308
|
+
function () { return raw("waited_month") * a("response_penalty") * c * v; },
|
|
309
|
+
"Instant reply on every channel");
|
|
310
|
+
|
|
311
|
+
// quotes_won > quotes_month means the two answers disagree. Clamping to zero
|
|
312
|
+
// would hide a contradiction the operator needs to go back and resolve.
|
|
313
|
+
if (core && raw("quotes_month") !== null && raw("quotes_won") !== null && raw("quotes_won") > raw("quotes_month")) {
|
|
314
|
+
O.push({ id: "quotes", title: "Quotes nobody chases", missing: [],
|
|
315
|
+
reason: "you told us more quotes were won than were sent — worth checking before this is priced" });
|
|
316
|
+
} else {
|
|
317
|
+
add("quotes", "Quotes nobody chases",
|
|
318
|
+
[{ id: "quotes_month" }, { id: "quotes_won" }], "quote_recovery",
|
|
319
|
+
function () { return (raw("quotes_month") - raw("quotes_won")) * a("quote_recovery") * v; },
|
|
320
|
+
"Follow-up that runs itself");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
add("noshow", "No-shows nobody rebooks",
|
|
324
|
+
[{ id: "booked_month" }, { id: "show_rate", share: true }], "noshow_recovery",
|
|
325
|
+
function () { return raw("booked_month") * (1 - share("show_rate")) * a("noshow_recovery") * c * v; },
|
|
326
|
+
"Reminders and rebooking");
|
|
327
|
+
|
|
328
|
+
var monthly = F.reduce(function (s, f) { return s + f.monthly; }, 0);
|
|
329
|
+
var stop = monthly * a("recovery");
|
|
330
|
+
|
|
331
|
+
var oneoff = null;
|
|
332
|
+
if (core && raw("dormant_count") !== null && a("reactivation") > 0) {
|
|
333
|
+
oneoff = raw("dormant_count") * a("reactivation") * c * v;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
var hrs = [];
|
|
337
|
+
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 }); });
|
|
338
|
+
var hrsWk = hrs.reduce(function (s, x) { return s + x.h; }, 0);
|
|
339
|
+
|
|
340
|
+
var verdict, line;
|
|
341
|
+
if (!core) {
|
|
342
|
+
verdict = "insufficient";
|
|
343
|
+
line = "We could not put a number on anything yet, because the figures everything else is priced against are not known.";
|
|
344
|
+
} else if (!F.length) {
|
|
345
|
+
verdict = "insufficient";
|
|
346
|
+
line = "We could not price a single leak from the numbers available.";
|
|
347
|
+
} else if (monthly < v) {
|
|
348
|
+
// Thin is measured against THEIR average job: everything found together is
|
|
349
|
+
// worth less than one new customer a month. Never a benchmark, never a fee.
|
|
350
|
+
verdict = "thin";
|
|
351
|
+
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.";
|
|
352
|
+
} else {
|
|
353
|
+
verdict = "findings";
|
|
354
|
+
line = "We found " + F.length + " thing" + (F.length === 1 ? "" : "s") + " worth fixing.";
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
F: F, O: O, core: core,
|
|
359
|
+
monthly: monthly, annual: monthly * 12,
|
|
360
|
+
stop: stop, annualStop: stop * 12,
|
|
361
|
+
oneoff: oneoff, hrs: hrs, hrsWk: hrsWk,
|
|
362
|
+
verdict: verdict, line: line
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The report — the document the agency hands their prospect.
|
|
368
|
+
*
|
|
369
|
+
* This is the only thing here a stranger ever reads, and it goes out on the
|
|
370
|
+
* agency's letterhead, not ours. Three rules are enforced by construction:
|
|
371
|
+
*
|
|
372
|
+
* 1. NOTHING OF OURS APPEARS. No product name, no tool name, no "generated by".
|
|
373
|
+
* Paul's client is buying from Paul.
|
|
374
|
+
* 2. EVERY FIGURE CARRIES ITS SOURCE. A number the owner gave, a number the
|
|
375
|
+
* operator assumed, and a number nobody knew are visibly different things,
|
|
376
|
+
* and the assumption prints beside the figure it moved rather than in a
|
|
377
|
+
* footnote nobody reads.
|
|
378
|
+
* 3. EVERY PIECE OF TEXT IS ESCAPED. The report is published to a real domain
|
|
379
|
+
* and is full of words a prospect typed into a form. Escaping is a
|
|
380
|
+
* correctness requirement here, not hygiene.
|
|
381
|
+
*
|
|
382
|
+
* Pure: takes data, returns an HTML string. No DOM, no network, no clock beyond
|
|
383
|
+
* the date it is handed. That is what makes it testable, and it is inlined into
|
|
384
|
+
* the assessment page by build-app.mjs like the engine.
|
|
385
|
+
*/
|
|
386
|
+
|
|
387
|
+
function esc(s) {
|
|
388
|
+
return String(s === undefined || s === null ? "" : s)
|
|
389
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
390
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function money(n) {
|
|
394
|
+
return "$" + Math.round(Number(n) || 0).toLocaleString("en-US");
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A colour the operator typed is going straight into a style attribute. */
|
|
398
|
+
function safeColour(c) {
|
|
399
|
+
return /^#[0-9a-fA-F]{3,8}$/.test(String(c || "")) ? String(c) : "#1F4E79";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* agency { name, accent?, contact? } — from Layer 1. Never invented.
|
|
404
|
+
* services[] { name, price?, covers:[findingId] } — THEIR menu at THEIR prices.
|
|
405
|
+
* A finding with no matching service still appears; it just has no price beside
|
|
406
|
+
* it, because recommending something the agency does not sell is worse than a
|
|
407
|
+
* gap in a table.
|
|
408
|
+
*/
|
|
409
|
+
function renderReport(opts) {
|
|
410
|
+
var state = opts.state || {};
|
|
411
|
+
var r = opts.computed || {};
|
|
412
|
+
var agency = opts.agency || {};
|
|
413
|
+
var services = Array.isArray(opts.services) ? opts.services : [];
|
|
414
|
+
var assumptionLabels = opts.assumptionLabels || {};
|
|
415
|
+
var date = opts.date || "";
|
|
416
|
+
var accent = safeColour(agency.accent);
|
|
417
|
+
|
|
418
|
+
var biz = esc(state.biz || "your business");
|
|
419
|
+
var F = Array.isArray(r.F) ? r.F : [];
|
|
420
|
+
var O = Array.isArray(r.O) ? r.O : [];
|
|
421
|
+
var hrs = Array.isArray(r.hrs) ? r.hrs : [];
|
|
422
|
+
|
|
423
|
+
var out = [];
|
|
424
|
+
out.push('<!doctype html><html lang="en"><head><meta charset="utf-8">');
|
|
425
|
+
out.push('<meta name="viewport" content="width=device-width,initial-scale=1">');
|
|
426
|
+
// This document carries another business's revenue, close rate and list size.
|
|
427
|
+
// Wherever it ends up, it must never be indexed.
|
|
428
|
+
out.push('<meta name="robots" content="noindex,nofollow,noarchive">');
|
|
429
|
+
out.push("<title>Where " + biz + " is leaking</title>");
|
|
430
|
+
out.push("<style>" + css(accent) + "</style></head><body><div class=wrap>");
|
|
431
|
+
|
|
432
|
+
// ── masthead: theirs, not ours ────────────────────────────────────────────
|
|
433
|
+
out.push("<header>");
|
|
434
|
+
if (agency.name) out.push('<p class="by">Prepared by ' + esc(agency.name) + "</p>");
|
|
435
|
+
out.push("<h1>Where " + biz + " is leaking</h1>");
|
|
436
|
+
if (date) out.push('<p class="when">' + esc(date) + "</p>");
|
|
437
|
+
out.push("</header>");
|
|
438
|
+
|
|
439
|
+
// ── the three verdicts are three different documents ──────────────────────
|
|
440
|
+
if (r.verdict === "insufficient") {
|
|
441
|
+
out.push('<div class="verdict stop"><b>We could not put a number on this yet</b>'
|
|
442
|
+
+ esc(r.line || "") + "</div>");
|
|
443
|
+
out.push(measureFirst(O));
|
|
444
|
+
out.push(footer(agency));
|
|
445
|
+
return out.join("\n") + "</div></body></html>";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (r.verdict === "thin") {
|
|
449
|
+
out.push('<div class="verdict warn"><b>Honestly, there is not much here</b>'
|
|
450
|
+
+ esc(r.line || "") + "</div>");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ── headline numbers ──────────────────────────────────────────────────────
|
|
454
|
+
out.push('<div class="hgrid">');
|
|
455
|
+
if (r.hrsWk > 0) out.push(head(r.hrsWk.toFixed(1), "hours a week going into work a system should do"));
|
|
456
|
+
if (r.annual > 0) out.push(head(money(r.annual), "a year leaking, on your own numbers"));
|
|
457
|
+
if (r.annualStop > 0) out.push(head(money(r.annualStop), "a year we expect to actually stop"));
|
|
458
|
+
out.push("</div>");
|
|
459
|
+
|
|
460
|
+
// ── what you told us ──────────────────────────────────────────────────────
|
|
461
|
+
if (opts.answered && opts.answered.length) {
|
|
462
|
+
out.push("<h2>What we started from, in your words</h2><table>");
|
|
463
|
+
for (var a = 0; a < opts.answered.length; a++) {
|
|
464
|
+
var q = opts.answered[a];
|
|
465
|
+
out.push("<tr><td>" + esc(q.ask) + '</td><td class="num">'
|
|
466
|
+
+ (q.unknown ? '<span class="unk">you did not know</span>' : esc(q.value)) + "</td></tr>");
|
|
467
|
+
}
|
|
468
|
+
out.push("</table>");
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ── the findings ──────────────────────────────────────────────────────────
|
|
472
|
+
if (F.length) {
|
|
473
|
+
out.push("<h2>Where the money goes</h2><table>");
|
|
474
|
+
out.push('<tr><th>What is happening</th><th class="num">A month</th></tr>');
|
|
475
|
+
for (var i = 0; i < F.length; i++) {
|
|
476
|
+
var f = F[i];
|
|
477
|
+
var note = f.assumption
|
|
478
|
+
? '<span class="assume">' + esc(assumptionLabels[f.assumption] || "This is your assumption, not a measurement.") + "</span>"
|
|
479
|
+
: "";
|
|
480
|
+
out.push("<tr><td><strong>" + esc(f.title) + "</strong>" + note + '</td><td class="num">'
|
|
481
|
+
+ money(f.monthly) + "</td></tr>");
|
|
482
|
+
}
|
|
483
|
+
out.push('<tr class="tot"><td><strong>Leaking, every month</strong></td><td class="num"><strong>'
|
|
484
|
+
+ money(r.monthly) + "</strong></td></tr>");
|
|
485
|
+
if (r.stop > 0) {
|
|
486
|
+
out.push('<tr><td><strong>What we expect to actually stop</strong><span class="assume">'
|
|
487
|
+
+ esc(assumptionLabels.recovery || "No system catches everything. This is your figure.")
|
|
488
|
+
+ '</span></td><td class="num"><strong>' + money(r.stop) + "</strong></td></tr>");
|
|
489
|
+
}
|
|
490
|
+
if (r.oneoff !== null && r.oneoff !== undefined && r.oneoff > 0) {
|
|
491
|
+
out.push('<tr><td>Your dormant list, worked once<span class="assume">'
|
|
492
|
+
+ esc(assumptionLabels.reactivation || "Your assumption, not a measurement.")
|
|
493
|
+
+ " This is a one-off and is left out of every annual figure above."
|
|
494
|
+
+ '</span></td><td class="num">' + money(r.oneoff) + "</td></tr>");
|
|
495
|
+
}
|
|
496
|
+
out.push("</table>");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ── named, deliberately not priced ────────────────────────────────────────
|
|
500
|
+
var named = O.filter(function (o) { return o.reason || o.declined; });
|
|
501
|
+
if (named.length) {
|
|
502
|
+
out.push("<h2>Named, and deliberately not priced</h2><ul>");
|
|
503
|
+
for (var j = 0; j < named.length; j++) {
|
|
504
|
+
var o = named[j];
|
|
505
|
+
out.push("<li><strong>" + esc(o.title) + "</strong> — "
|
|
506
|
+
+ esc(o.declined ? "priced only on an assumption you chose not to make." : o.reason) + "</li>");
|
|
507
|
+
}
|
|
508
|
+
out.push("</ul><p class=small>We can see these matter and we are not going to invent a number for them.</p>");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// ── hours ─────────────────────────────────────────────────────────────────
|
|
512
|
+
if (hrs.length) {
|
|
513
|
+
out.push("<h2>Where the time goes</h2><table>");
|
|
514
|
+
for (var h = 0; h < hrs.length; h++) {
|
|
515
|
+
out.push("<tr><td>" + esc(hrs[h].t) + '</td><td class="num">' + Number(hrs[h].h).toFixed(1) + " hrs</td></tr>");
|
|
516
|
+
}
|
|
517
|
+
out.push('<tr class="tot"><td><strong>A week</strong></td><td class="num"><strong>'
|
|
518
|
+
+ Number(r.hrsWk).toFixed(1) + "</strong></td></tr></table>");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── the plan, from THEIR menu ─────────────────────────────────────────────
|
|
522
|
+
if (F.length) {
|
|
523
|
+
out.push("<h2>What we would do, in order</h2><table>");
|
|
524
|
+
out.push('<tr><th>Fixes</th><th>What it is</th></tr>');
|
|
525
|
+
for (var k = 0; k < F.length; k++) {
|
|
526
|
+
var fx = F[k];
|
|
527
|
+
var svc = services.find(function (s) {
|
|
528
|
+
return Array.isArray(s.covers) && s.covers.indexOf(fx.id) !== -1;
|
|
529
|
+
});
|
|
530
|
+
out.push("<tr><td>" + esc(fx.title) + "</td><td>" + esc(svc ? svc.name : fx.fix || "") + "</td></tr>");
|
|
531
|
+
}
|
|
532
|
+
out.push("</table>");
|
|
533
|
+
if (!services.length) {
|
|
534
|
+
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>');
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ── investment, only when they have priced it ─────────────────────────────
|
|
539
|
+
var priced = services.filter(function (s) { return typeof s.price === "number" && s.price > 0; });
|
|
540
|
+
if (priced.length && r.annualStop > 0) {
|
|
541
|
+
var total = priced.reduce(function (s, x) { return s + x.price; }, 0);
|
|
542
|
+
out.push("<h2>The investment</h2><table>");
|
|
543
|
+
for (var p = 0; p < priced.length; p++) {
|
|
544
|
+
out.push("<tr><td>" + esc(priced[p].name) + '</td><td class="num">' + money(priced[p].price) + "</td></tr>");
|
|
545
|
+
}
|
|
546
|
+
out.push('<tr class="tot"><td><strong>Total</strong></td><td class="num"><strong>' + money(total) + "</strong></td></tr>");
|
|
547
|
+
out.push('<tr><td>Against what we expect to stop, first year</td><td class="num">' + money(r.annualStop) + "</td></tr>");
|
|
548
|
+
if (total > 0) {
|
|
549
|
+
var ratio = r.annualStop / total;
|
|
550
|
+
out.push('<tr class="tot"><td><strong>You are spending $1 to stop $' + ratio.toFixed(2)
|
|
551
|
+
+ "</strong></td><td class=\"num\"><strong>" + ratio.toFixed(1) + "×</strong></td></tr>");
|
|
552
|
+
}
|
|
553
|
+
out.push("</table>");
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
out.push(sourceLine(state, r, assumptionLabels));
|
|
557
|
+
out.push(footer(agency));
|
|
558
|
+
return out.join("\n") + "</div></body></html>";
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function head(n, label) {
|
|
562
|
+
return '<div><span class="hn">' + esc(n) + '</span><span class="hl">' + esc(label) + "</span></div>";
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function measureFirst(O) {
|
|
566
|
+
var missing = [];
|
|
567
|
+
for (var i = 0; i < O.length; i++) {
|
|
568
|
+
var m = O[i].missing || [];
|
|
569
|
+
for (var j = 0; j < m.length; j++) if (missing.indexOf(m[j]) === -1) missing.push(m[j]);
|
|
570
|
+
}
|
|
571
|
+
if (!missing.length) return "";
|
|
572
|
+
return "<h2>What to measure first</h2><p>These are the numbers that would let us price the rest. "
|
|
573
|
+
+ "None of them need a system — a week of writing them down is enough.</p><ul><li>"
|
|
574
|
+
+ missing.map(esc).join("</li><li>") + "</li></ul>";
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** The sentence the whole document rests on. */
|
|
578
|
+
function sourceLine(state, r, labels) {
|
|
579
|
+
var used = [];
|
|
580
|
+
var F = r.F || [];
|
|
581
|
+
for (var i = 0; i < F.length; i++) if (F[i].assumption && used.indexOf(F[i].assumption) === -1) used.push(F[i].assumption);
|
|
582
|
+
var n = used.length + (r.stop > 0 ? 1 : 0);
|
|
583
|
+
return '<p class="src">Every figure here came from what you told us'
|
|
584
|
+
+ (state.trade ? ", or from your own " + esc(state.trade) + " business" : "")
|
|
585
|
+
+ ". " + (n > 0
|
|
586
|
+
? n + " of them " + (n === 1 ? "is an estimate" : "are estimates") + " we chose together, each marked where it is used. "
|
|
587
|
+
: "")
|
|
588
|
+
+ "Nothing here is an industry average applied to your business.</p>";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function footer(agency) {
|
|
592
|
+
if (!agency.name) return "";
|
|
593
|
+
return '<footer>' + esc(agency.name) + (agency.contact ? " · " + esc(agency.contact) : "") + "</footer>";
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function css(accent) {
|
|
597
|
+
return [
|
|
598
|
+
":root{--a:" + accent + ";--ink:#14171F;--mut:#4A5163;--rule:#D8DDE5;--pan:#F4F6F8}",
|
|
599
|
+
"*{box-sizing:border-box}",
|
|
600
|
+
"body{margin:0;background:#fff;color:var(--ink);font:16px/1.6 -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif}",
|
|
601
|
+
".wrap{max-width:44rem;margin:0 auto;padding:2rem 1.1rem 4rem}",
|
|
602
|
+
"header{border-bottom:3px solid var(--a);padding-bottom:.9rem;margin-bottom:1.4rem}",
|
|
603
|
+
".by{font-size:.72rem;letter-spacing:.09em;text-transform:uppercase;color:var(--a);margin:0 0 .4rem;font-weight:600}",
|
|
604
|
+
"h1{font-size:1.9rem;line-height:1.15;margin:0;font-weight:700}",
|
|
605
|
+
".when{color:var(--mut);font-size:.82rem;margin:.4rem 0 0}",
|
|
606
|
+
"h2{font-size:1.15rem;margin:2rem 0 .5rem;font-weight:700}",
|
|
607
|
+
"table{border-collapse:collapse;width:100%;margin:.6rem 0;font-size:.94rem}",
|
|
608
|
+
"th,td{text-align:left;padding:.5rem .6rem;border-bottom:1px solid var(--rule);vertical-align:top}",
|
|
609
|
+
"th{font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;color:var(--mut);border-bottom:2px solid var(--rule)}",
|
|
610
|
+
".num{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}",
|
|
611
|
+
"tr.tot td{background:var(--pan)}",
|
|
612
|
+
".assume{display:block;font-size:.76rem;color:var(--mut);font-style:italic;margin-top:.2rem}",
|
|
613
|
+
".unk{color:var(--mut);font-style:italic}",
|
|
614
|
+
".hgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:1rem;margin:1.2rem 0}",
|
|
615
|
+
".hn{display:block;font-size:1.7rem;font-weight:700;color:var(--a);line-height:1.1}",
|
|
616
|
+
".hl{display:block;font-size:.78rem;color:var(--mut);margin-top:.15rem}",
|
|
617
|
+
".verdict{border-left:4px solid var(--a);background:var(--pan);padding:.75rem .9rem;margin:1rem 0;font-size:.94rem}",
|
|
618
|
+
".verdict.warn{border-left-color:#8A5300}.verdict.stop{border-left-color:#9A3324}",
|
|
619
|
+
".verdict b{display:block;margin-bottom:.15rem}",
|
|
620
|
+
".src{margin-top:1.6rem;padding-top:.8rem;border-top:1px solid var(--rule);font-size:.82rem;color:var(--mut)}",
|
|
621
|
+
".small{font-size:.82rem;color:var(--mut)}",
|
|
622
|
+
"ul{padding-left:1.1rem}li{margin-bottom:.3rem}",
|
|
623
|
+
"footer{margin-top:2rem;padding-top:.8rem;border-top:3px solid var(--a);font-size:.8rem;color:var(--mut)}",
|
|
624
|
+
"@media print{.wrap{max-width:none}body{font-size:11pt}}",
|
|
625
|
+
].join("");
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* ── state (persistence only; all maths lives in engine.js) ──────────────── */
|
|
629
|
+
var K="cmdos-assessment-v1";
|
|
630
|
+
var S={biz:"",trade:"",ans:{},skip:{},asm:{},tab:"ask",area:"money"};
|
|
631
|
+
ASSUMPTIONS.forEach(function(a){S.asm[a[0]]=a[2]});
|
|
632
|
+
try{var raw=localStorage.getItem(K);if(raw)S=Object.assign(S,JSON.parse(raw))}catch(e){}
|
|
633
|
+
function save(){try{localStorage.setItem(K,JSON.stringify(S))}catch(e){}}
|
|
634
|
+
function txt(id){return S.skip[id]?null:(S.ans[id]||null)}
|
|
635
|
+
function money(n){return "$"+Math.round(n).toLocaleString()}
|
|
636
|
+
function R(){return compute(S.ans,S.skip,S.asm)}
|
|
637
|
+
/* ── render ───────────────────────────────────────────────────────────────── */
|
|
638
|
+
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}
|
|
639
|
+
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}
|
|
640
|
+
|
|
641
|
+
function head(){
|
|
642
|
+
var r=R();
|
|
643
|
+
document.getElementById("lProg").textContent=progress()+"/"+Q.length;
|
|
644
|
+
document.getElementById("lHrs").textContent=r.hrsWk?r.hrsWk.toFixed(1):"—";
|
|
645
|
+
document.getElementById("lMon").textContent=r.monthly?money(r.monthly):"—";
|
|
646
|
+
document.getElementById("bizname").textContent=S.biz||"New assessment";
|
|
647
|
+
document.getElementById("bizmeta").textContent=S.trade||"";
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function railHtml(){
|
|
651
|
+
if(S.tab!=="ask")return "";
|
|
652
|
+
return AREAS.map(function(a){return '<button class="'+(S.area===a[0]?"on":"")+'" onclick="goArea(\''+a[0]+'\')">'
|
|
653
|
+
+a[1]+'<span class="c">'+areaCount(a[0])+'</span></button>'}).join("")}
|
|
654
|
+
|
|
655
|
+
function inputFor(q){
|
|
656
|
+
var id=q[0],u=q[4],val=S.ans[id]===undefined?"":S.ans[id],sk=!!S.skip[id];
|
|
657
|
+
var unit={currency:"$",rate:"% or 0-1",count:"number",minutes:"minutes",hours:"hrs/week",text:""}[u]||"";
|
|
658
|
+
var el= u==="text"
|
|
659
|
+
? '<textarea class="a" oninput="setA(\''+id+'\',this.value)" '+(sk?"disabled":"")+'>'+String(val).replace(/</g,"<")+'</textarea>'
|
|
660
|
+
: '<input class="a" inputmode="decimal" value="'+String(val).replace(/"/g,""")+'" oninput="setA(\''+id+'\',this.value)" '+(sk?"disabled":"")+'>';
|
|
661
|
+
return '<div class="row">'+el+'<span class="unit">'+unit+'</span>'
|
|
662
|
+
+'<button class="mini '+(sk?"on":"")+'" onclick="skip(\''+id+'\')">'+(sk?"marked don\'t know":"don\'t know")+'</button>'
|
|
663
|
+
+'<button class="mini" onclick="why(\''+id+'\')">why</button></div>'}
|
|
664
|
+
|
|
665
|
+
function askHtml(){
|
|
666
|
+
var a=AREAS.filter(function(x){return x[0]===S.area})[0];
|
|
667
|
+
var qs=Q.filter(function(q){return q[1]===S.area});
|
|
668
|
+
return '<h2>'+a[1]+'</h2><p class="intro">“'+a[2]+'”</p>'
|
|
669
|
+
+qs.map(function(q){
|
|
670
|
+
var done=S.ans[q[0]]!==undefined&&S.ans[q[0]]!=="",sk=!!S.skip[q[0]];
|
|
671
|
+
return '<div class="q '+(sk?"skip":done?"done":"")+'" id="q_'+q[0]+'">'
|
|
672
|
+
+'<p class="ask">'+q[2]+'</p>'
|
|
673
|
+
+(q[3]?'<p class="probe">If they go vague: “'+q[3]+'”</p>':'')
|
|
674
|
+
+inputFor(q)+'<p class="why">'+q[5]+'</p></div>'}).join("")
|
|
675
|
+
+'<div class="btnrow"><button class="btn" onclick="nextArea()">Next area</button>'
|
|
676
|
+
+'<button class="btn ghost" onclick="tab(\'find\')">See findings</button></div>'}
|
|
677
|
+
|
|
678
|
+
function setHtml(){
|
|
679
|
+
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">'
|
|
680
|
+
+ASSUMPTIONS.map(function(a){
|
|
681
|
+
return '<div class="assume"><div><b>'+a[1]+'</b><br><span class="unit">'+a[3]+'</span></div>'
|
|
682
|
+
+'<input class="a" style="max-width:5rem" value="'+(S.asm[a[0]])+'" oninput="setAsm(\''+a[0]+'\',this.value)"></div>'}).join("")
|
|
683
|
+
+'</div><div class="card"><h2 style="font-size:1rem">Who is this for?</h2>'
|
|
684
|
+
+'<div class="row"><input class="a" placeholder="Business name" value="'+(S.biz||"")+'" oninput="S.biz=this.value;save();head()">'
|
|
685
|
+
+'<input class="a" placeholder="Trade" value="'+(S.trade||"")+'" oninput="S.trade=this.value;save();head()"></div></div>'}
|
|
686
|
+
|
|
687
|
+
function findHtml(){
|
|
688
|
+
var r=R();
|
|
689
|
+
var vb={findings:"note go",thin:"note warn",insufficient:"note stop"}[r.verdict];
|
|
690
|
+
var h='<h2>What it found</h2><div class="'+vb+'"><b>Verdict: '+r.verdict+'</b>'+r.line+'</div>';
|
|
691
|
+
if(r.F.length){
|
|
692
|
+
h+='<div class="card"><table><thead><tr><th>Finding</th><th class="num">A month</th></tr></thead><tbody>'
|
|
693
|
+
+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("")
|
|
694
|
+
+'<tr><td><b>Leaking, monthly</b></td><td class="num"><b>'+money(r.monthly)+'</b></td></tr>'
|
|
695
|
+
+'<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>'
|
|
696
|
+
+(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>':'')
|
|
697
|
+
+'</tbody></table></div>'}
|
|
698
|
+
if(r.hrs.length){
|
|
699
|
+
h+='<div class="card"><table><thead><tr><th>Where the time goes</th><th class="num">Hrs/week</th></tr></thead><tbody>'
|
|
700
|
+
+r.hrs.map(function(x){return '<tr><td>'+x.t+'</td><td class="num">'+x.h.toFixed(1)+'</td></tr>'}).join("")
|
|
701
|
+
+'<tr><td><b>Total</b></td><td class="num"><b>'+r.hrsWk.toFixed(1)+'</b></td></tr></tbody></table></div>'}
|
|
702
|
+
if(r.O.length){
|
|
703
|
+
h+='<div class="note warn"><b>Not priced, and deliberately not guessed</b>'
|
|
704
|
+
+r.O.map(function(o){return o.declined?(o.title+" — you set "+o.declined.replace(/_/g," ")+" to zero")
|
|
705
|
+
:(o.title+" — missing: "+o.miss.join(", "))}).join("<br>")+'</div>'}
|
|
706
|
+
h+='<div class="btnrow"><button class="btn" onclick="tab(\'rep\')">Build the report</button></div>';
|
|
707
|
+
return h}
|
|
708
|
+
|
|
709
|
+
function reportHtml(){
|
|
710
|
+
var r=R();
|
|
711
|
+
var answered=[];
|
|
712
|
+
Q.forEach(function(q){
|
|
713
|
+
var id=q[0];
|
|
714
|
+
if(S.skip[id]) answered.push({ask:q[2],unknown:true});
|
|
715
|
+
else if(S.ans[id]!==undefined&&String(S.ans[id]).trim()!=="") answered.push({ask:q[2],value:S.ans[id]});
|
|
716
|
+
});
|
|
717
|
+
var labels={};
|
|
718
|
+
ASSUMPTIONS.forEach(function(a){labels[a[0]]=a[3]});
|
|
719
|
+
return renderReport({
|
|
720
|
+
state:{biz:S.biz,trade:S.trade,ans:S.ans},
|
|
721
|
+
computed:r, agency:S.agency||{}, services:S.services||[],
|
|
722
|
+
assumptionLabels:labels,
|
|
723
|
+
date:new Date().toLocaleDateString(undefined,{day:"numeric",month:"long",year:"numeric"}),
|
|
724
|
+
answered:answered
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
function repHtml(){
|
|
728
|
+
var r=R();
|
|
729
|
+
var doc=reportHtml();
|
|
730
|
+
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>'
|
|
731
|
+
+(S.biz?'':'<div class="note warn"><b>No business name yet</b>Set it on the Assumptions tab so the report is addressed to someone.</div>')
|
|
732
|
+
+'<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>'
|
|
733
|
+
+'<div class="btnrow"><button class="btn" onclick="openReport()">Open it full size</button>'
|
|
734
|
+
+'<button class="btn ghost" onclick="showSource()">Show the file</button>'
|
|
735
|
+
+'<button class="btn ghost" onclick="dl()">Save answers</button></div>'
|
|
736
|
+
+'<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>';
|
|
737
|
+
}
|
|
738
|
+
function paintReport(){
|
|
739
|
+
var f=document.getElementById("rpv");
|
|
740
|
+
if(f) f.srcdoc=reportHtml();
|
|
741
|
+
}
|
|
742
|
+
function openReport(){
|
|
743
|
+
var w=window.open("","_blank");
|
|
744
|
+
if(w){w.document.open();w.document.write(reportHtml());w.document.close();}
|
|
745
|
+
}
|
|
746
|
+
function showSource(){
|
|
747
|
+
var d=document.getElementById("dump"); if(d){d.remove();return}
|
|
748
|
+
var w=document.createElement("div");w.id="dump";w.className="card";
|
|
749
|
+
w.innerHTML='<b style="font-size:.82rem">The report as a file. Select all, copy, save as report.html.</b>'
|
|
750
|
+
+'<textarea class="a" style="min-height:12rem;font-family:\'JetBrains Mono\',monospace;font-size:.66rem"></textarea>';
|
|
751
|
+
document.getElementById("body").appendChild(w);
|
|
752
|
+
var t=w.querySelector("textarea");t.value=reportHtml();t.focus();t.select();
|
|
753
|
+
}
|
|
754
|
+
function draw(){
|
|
755
|
+
head();
|
|
756
|
+
document.getElementById("rail").innerHTML=railHtml();
|
|
757
|
+
document.getElementById("body").innerHTML=
|
|
758
|
+
S.tab==="ask"?askHtml():S.tab==="set"?setHtml():S.tab==="find"?findHtml():repHtml();
|
|
759
|
+
if(S.tab==="rep") paintReport();
|
|
760
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
761
|
+
b.className=b.dataset.t===S.tab?"on":""})}
|
|
762
|
+
|
|
763
|
+
function setA(id,v){S.ans[id]=v;if(v!=="")S.skip[id]=false;save();head();
|
|
764
|
+
var el=document.getElementById("q_"+id);if(el)el.className="q "+(v!==""?"done":"")}
|
|
765
|
+
function skip(id){S.skip[id]=!S.skip[id];if(S.skip[id])S.ans[id]="";save();draw()}
|
|
766
|
+
function why(id){var el=document.getElementById("q_"+id);if(el)el.classList.toggle("open")}
|
|
767
|
+
function setAsm(k,v){S.asm[k]=parseRate(v);save();head()}
|
|
768
|
+
function goArea(a){S.area=a;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
769
|
+
function nextArea(){var i=AREAS.map(function(x){return x[0]}).indexOf(S.area);
|
|
770
|
+
if(i<AREAS.length-1)goArea(AREAS[i+1][0]);else tab("find")}
|
|
771
|
+
function tab(t){S.tab=t;save();draw();window.scrollTo({top:0,behavior:"smooth"})}
|
|
772
|
+
function dl(){var d=document.getElementById("dump");
|
|
773
|
+
if(d){d.remove();return}
|
|
774
|
+
var w=document.createElement("div");w.id="dump";w.className="card";
|
|
775
|
+
w.innerHTML='<b style="font-size:.82rem">Your answers. Select all and copy, or save as a .json file.</b>'
|
|
776
|
+
+'<textarea class="a" style="min-height:12rem;font-family:\'JetBrains Mono\',monospace;font-size:.68rem"></textarea>';
|
|
777
|
+
document.getElementById("body").appendChild(w);
|
|
778
|
+
var t=w.querySelector("textarea");t.value=JSON.stringify(S,null,2);t.focus();t.select()}
|
|
779
|
+
function wipe(){if(!confirm)return;S.ans={};S.skip={};S.biz="";S.trade="";S.tab="ask";S.area="money";save();draw()}
|
|
780
|
+
|
|
781
|
+
Array.prototype.forEach.call(document.querySelectorAll(".tabs button"),function(b){
|
|
782
|
+
b.onclick=function(){tab(b.dataset.t)}});
|
|
783
|
+
(function(){var MIN=18,MAX=32,TK="cmdos-type-size",R=document.documentElement;
|
|
784
|
+
function g(){try{var v=parseFloat(localStorage.getItem(TK));if(v>=MIN&&v<=MAX)return v}catch(e){}return 21}
|
|
785
|
+
function s(v){v=Math.min(MAX,Math.max(MIN,v));R.style.fontSize=v+"px";try{localStorage.setItem(TK,String(v))}catch(e){}return v}
|
|
786
|
+
var c=s(g());document.getElementById("tu").onclick=function(){c=s(c+2)};
|
|
787
|
+
document.getElementById("td").onclick=function(){c=s(c-2)}})();
|
|
788
|
+
draw();
|
|
789
|
+
</script>
|