crewly 1.20.449 → 1.20.451

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.
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: Check-up Report
3
+ description: The intake and report steps of the small-business check-up as one Crewly App. Copies the bundled App page (five intake questions, the report with the top 3 breaks and what an agent could do, a 7-day daily summary) into the project for publish-app, turns checkup-analyse's breaks.json into the App's report document (with the number the owner asked to see first), writes daily-summary documents for app-data, and keeps a per-step time log (--log-step) the report shows.
4
+ version: 1.1.0
5
+ category: productivity
6
+ skillType: claude-skill
7
+ assignableRoles:
8
+ - "*"
9
+ triggers:
10
+ - check-up report
11
+ - checkup app
12
+ - publish the check-up
13
+ - intake questions
14
+ - daily check-up summary
15
+ - log a check-up step
16
+ tags:
17
+ - check-up
18
+ - small-business
19
+ - apps
20
+ - report
21
+ execution:
22
+ type: script
23
+ script:
24
+ file: execute.sh
25
+ interpreter: bash
26
+ timeoutMs: 60000
27
+ ---
28
+
29
+ # Check-up Report
30
+
31
+ Steps 1 and 4 of the small-business check-up (crewly#1062), on one Crewly
32
+ App the owner opens on their phone: **Your answers** (five intake questions),
33
+ **Report** (the top 3 breaks with estimate, formula, examples and what an agent
34
+ could do; every break; files; assumptions) and **Daily** (a line a day for the
35
+ 7 free days after the report). The page is built once; the data is per
36
+ business.
37
+
38
+ ## 1. Intake — publish the App and get the five answers
39
+
40
+ ```bash
41
+ bash execute.sh --app-dir ./checkup-app # copies the page into the project (publish-app needs a project path)
42
+ bash publish-app --dir ./checkup-app --name "<Business> check-up" --notify
43
+ ```
44
+
45
+ The owner lands on **Your answers** (no report yet): which apps, the slowest
46
+ weekly task, what they fear slipping through, how many apps one customer
47
+ passes through, which number they want to see first. Saving writes
48
+ `intake/answers` and sends you a message. Read it:
49
+
50
+ ```bash
51
+ bash app-data --app <appId> --get intake answers > ./intake.json
52
+ ```
53
+
54
+ Use `apps` to decide the connect path per app (connector, `ingest-exports`,
55
+ or the browser as last resort) and `firstNumber` for the report.
56
+
57
+ ## 2. Connect and analyse
58
+
59
+ `ingest-exports` (exports → common schema) then `checkup-analyse`
60
+ (breaks + estimates → `breaks.json`). See those skills.
61
+
62
+ ## 3. Report — put the result in the App
63
+
64
+ ```bash
65
+ bash execute.sh --breaks <analysis>/breaks.json --business "<Business>" --intake ./intake.json --out ./checkup-data
66
+ bash app-data --app <appId> --set report current --data-file ./checkup-data/report-doc.json
67
+ bash publish-app --app <appId> --share # a fresh one-tap link to the owner
68
+ ```
69
+
70
+ `report-doc.json` holds the top 3, every break with its estimate and formula,
71
+ up to three short examples per break (none when the exports were hashed),
72
+ notes for checks the data could not support, the files and the assumptions.
73
+ When the intake said which number to see first, the report leads with it
74
+ (`firstNumber`): money owed (overdue invoices + unpaid sign-ups), missed
75
+ inquiries, hours lost to double entry, or lapsed customers. The whole list of
76
+ people never goes into the App; it stays in `breaks.json` on this machine.
77
+
78
+ ## 4. Daily — one line a day for 7 days
79
+
80
+ ```bash
81
+ bash execute.sh --daily "Replied to 3 open inquiries; 1 booked. Sent 2 invoice reminders." --out ./checkup-data
82
+ bash app-data --app <appId> --set daily 2026-10-27 --data-file ./checkup-data/daily-2026-10-27.json
83
+ ```
84
+
85
+ Say what changed and what was done, in the owner's language, under 300
86
+ characters. Seven entries, then stop unless the owner asks for more.
87
+
88
+ The free **Small Business Check-up** team template (`small-business-checkup`)
89
+ runs these steps with a Checkup Lead, a Data Analyst and an optional Reviewer
90
+ who checks `report-doc.json` before it is published; remove the Reviewer from
91
+ the team to skip that step.
92
+
93
+ ## Time log — how long the check-up really took
94
+
95
+ Mark the start and end of every step; the report shows the minutes per step
96
+ and the total, and the owner sees them under "How long it took".
97
+
98
+ ```bash
99
+ bash execute.sh --log-step intake --start --out ./checkup-data # steps: intake, connect, analyse, report, daily
100
+ bash execute.sh --log-step intake --end --out ./checkup-data
101
+ ```
102
+
103
+ `--out` is the same directory as the report; the log is `timelog.json` there
104
+ and `--breaks` folds it in as `timing` (closed steps with minutes, the total,
105
+ and any step still open). A second `--start` on an open step restarts it;
106
+ `--end` with no open entry fails (exit 2) so a forgotten start is noticed.
107
+ `--at <iso>` back-fills a time you know. One `daily` entry per day is fine.
108
+
109
+ ## Report back
110
+
111
+ Tell the owner in plain words: the App is ready (the card has the link), the
112
+ top 3 with count and estimate, that the dollars are estimates from listed
113
+ assumptions, and the one thing you would start on. Do not paste the document.
@@ -0,0 +1,275 @@
1
+ <!doctype html>
2
+ <html lang="en"><head>
3
+ <meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
4
+ <title>Business check-up</title>
5
+ <style>
6
+ :root { --ink: #1c1c1e; --muted: #6b6b70; --line: #e4e4e8; --accent: #1f6feb; --bg: #fff; --card: #f6f7f9; --warn: #b4540a; }
7
+ * { box-sizing: border-box; }
8
+ body { font: 16px/1.45 system-ui, -apple-system, sans-serif; margin: 0; padding: 16px 16px 48px; max-width: 640px; color: var(--ink); background: var(--bg); }
9
+ h1 { font-size: 22px; margin: 0 0 4px; }
10
+ h2 { font-size: 18px; margin: 28px 0 8px; }
11
+ h3 { font-size: 16px; margin: 0 0 6px; }
12
+ p { margin: 0 0 10px; }
13
+ .muted { color: var(--muted); font-size: 14px; }
14
+ .card { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px; margin: 0 0 12px; }
15
+ .rank { display: inline-block; min-width: 28px; height: 28px; line-height: 28px; text-align: center; border-radius: 14px; background: var(--accent); color: #fff; font-weight: 600; margin-right: 8px; }
16
+ .num { font-size: 20px; font-weight: 600; }
17
+ .est { margin: 8px 0 4px; }
18
+ .formula { font-size: 13px; color: var(--muted); border-left: 3px solid var(--line); padding-left: 8px; margin: 6px 0; }
19
+ .agent { background: #eef4ff; border-radius: 8px; padding: 8px 10px; margin-top: 8px; font-size: 15px; }
20
+ table { width: 100%; border-collapse: collapse; font-size: 15px; }
21
+ th, td { text-align: left; padding: 8px 4px; border-bottom: 1px solid var(--line); vertical-align: top; }
22
+ td.r, th.r { text-align: right; white-space: nowrap; }
23
+ label { display: block; font-weight: 600; margin: 16px 0 6px; }
24
+ input, textarea, select, button { font: inherit; font-size: 16px; width: 100%; min-height: 44px; padding: 10px; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); }
25
+ textarea { min-height: 72px; resize: vertical; }
26
+ button { background: var(--accent); color: #fff; border: 0; font-weight: 600; margin-top: 16px; }
27
+ button.secondary { background: var(--card); color: var(--ink); border: 1px solid var(--line); margin-top: 8px; }
28
+ .chips { margin: 0 -4px; }
29
+ .chip { display: inline-block; margin: 4px; padding: 8px 12px; min-height: 44px; line-height: 28px; border: 1px solid var(--line); border-radius: 22px; background: #fff; cursor: pointer; }
30
+ .chip.on { background: var(--accent); color: #fff; border-color: var(--accent); }
31
+ #msg { min-height: 20px; color: var(--warn); margin-top: 8px; }
32
+ .ok { color: #1a7f37; }
33
+ .hidden { display: none; }
34
+ .tabs { display: flex; margin: 0 0 16px; border-bottom: 1px solid var(--line); }
35
+ .tab { flex: 1; text-align: center; padding: 12px 4px; min-height: 44px; color: var(--muted); cursor: pointer; border-bottom: 2px solid transparent; }
36
+ .tab.on { color: var(--ink); border-bottom-color: var(--accent); font-weight: 600; }
37
+ ul.plain { list-style: none; padding: 0; margin: 0; }
38
+ ul.plain li { padding: 8px 0; border-bottom: 1px solid var(--line); }
39
+ .note { font-size: 14px; color: var(--muted); }
40
+ </style>
41
+ </head><body>
42
+ <h1 id="title" data-crewly-id="title">Business check-up</h1>
43
+ <p class="muted" id="subtitle" data-crewly-id="subtitle">Loading…</p>
44
+
45
+ <div class="tabs" id="tabs">
46
+ <div class="tab on" id="tab-report" data-crewly-id="tab-report">Report</div>
47
+ <div class="tab" id="tab-intake" data-crewly-id="tab-intake">Your answers</div>
48
+ <div class="tab" id="tab-daily" data-crewly-id="tab-daily">Daily</div>
49
+ </div>
50
+
51
+ <!-- ───────── Report ───────── -->
52
+ <section id="report" data-crewly-id="report">
53
+ <div id="report-empty" class="card hidden" data-crewly-id="report-empty">
54
+ <h3>No report yet</h3>
55
+ <p class="muted">Answer the five questions under "Your answers" and share your exports. The report appears here when the check-up is done.</p>
56
+ </div>
57
+ <div id="report-body" class="hidden">
58
+ <div class="card" id="headline" data-crewly-id="headline">
59
+ <div class="muted" id="asof"></div>
60
+ <p class="est"><span class="num" id="total-hours"></span> h/week &nbsp;·&nbsp; <span class="num" id="total-dollars"></span>/month</p>
61
+ <p class="note">Estimates from the assumptions at the end. Change an assumption and the numbers follow.</p>
62
+ </div>
63
+ <div id="first-number" class="card hidden" data-crewly-id="first-number"></div>
64
+ <h2>Top 3</h2>
65
+ <div id="top3" data-crewly-id="top3"></div>
66
+ <h2>Every break</h2>
67
+ <table data-crewly-id="all-breaks"><thead><tr><th>Break</th><th class="r">Count</th><th class="r">h/wk</th><th class="r">$/mo</th></tr></thead><tbody id="all-rows"></tbody></table>
68
+ <div id="notes-wrap" class="hidden"><h2>Could not check</h2><ul class="plain" id="notes" data-crewly-id="notes"></ul></div>
69
+ <h2>Files</h2>
70
+ <ul class="plain" id="files" data-crewly-id="files"></ul>
71
+ <div id="timing-wrap" class="hidden"><h2>How long it took</h2><ul class="plain" id="timing" data-crewly-id="timing"></ul></div>
72
+ <h2>Assumptions</h2>
73
+ <ul class="plain" id="assumptions" data-crewly-id="assumptions"></ul>
74
+ <button class="secondary" id="ask" data-crewly-id="ask">Ask about this report</button>
75
+ </div>
76
+ </section>
77
+
78
+ <!-- ───────── Intake ───────── -->
79
+ <section id="intake" class="hidden" data-crewly-id="intake">
80
+ <p class="muted">Five questions, two minutes. Your answers tell the check-up where to look first.</p>
81
+ <label for="q-apps">1. Which apps does the business run on?</label>
82
+ <div class="chips" id="q-apps-chips" data-crewly-id="q-apps"></div>
83
+ <input id="q-apps" placeholder="Others, comma-separated">
84
+ <label for="q-task">2. Which weekly task takes the most time?</label>
85
+ <textarea id="q-task" data-crewly-id="q-task" placeholder="e.g. copying new sign-ups from the form into the roster and the mailing list"></textarea>
86
+ <label>3. What do you fear slipping through?</label>
87
+ <div class="chips" id="q-fear-chips" data-crewly-id="q-fear"></div>
88
+ <input id="q-fear" placeholder="Anything else">
89
+ <label for="q-hops">4. How many apps does one customer pass through, from first contact to payment?</label>
90
+ <select id="q-hops" data-crewly-id="q-hops"><option value="">Pick one</option><option>1</option><option>2</option><option>3</option><option>4</option><option>5 or more</option></select>
91
+ <label for="q-first">5. Which number do you want to see first?</label>
92
+ <div class="chips" id="q-first-chips" data-crewly-id="q-first"></div>
93
+ <input id="q-first" placeholder="Or say it in your words">
94
+ <button id="save" data-crewly-id="save">Save answers</button>
95
+ <div id="msg" data-crewly-id="msg"></div>
96
+ </section>
97
+
98
+ <!-- ───────── Daily ───────── -->
99
+ <section id="daily" class="hidden" data-crewly-id="daily">
100
+ <p class="muted">A short summary each day for 7 days after the report: what changed, what was done.</p>
101
+ <ul class="plain" id="daily-list" data-crewly-id="daily-list"></ul>
102
+ <p class="muted hidden" id="daily-empty">Nothing yet.</p>
103
+ </section>
104
+
105
+ <script>
106
+ // Old Safari (iOS 12) has no replaceChildren; the owner's family iPad must still render this.
107
+ if (!Element.prototype.replaceChildren) {
108
+ Element.prototype.replaceChildren = function () {
109
+ while (this.firstChild) this.removeChild(this.firstChild);
110
+ for (var i = 0; i < arguments.length; i += 1) this.appendChild(arguments[i]);
111
+ };
112
+ }
113
+ var $ = function (id) { return document.getElementById(id); };
114
+ var APP_CHIPS = ['Square', 'Stripe', 'QuickBooks', 'Mailchimp', 'Jotform', 'Jackrabbit', 'Mindbody', 'Calendly', 'HubSpot', 'Google Sheets', 'Gmail'];
115
+ var FEAR_CHIPS = ['Unanswered inquiries', 'Signed up but never paid', 'Customers who stopped coming', 'Unpaid invoices', 'Typing the same person twice'];
116
+ var FIRST_CHIPS = ['Money owed to me', 'Inquiries I missed', 'Hours lost to double entry', 'Customers who lapsed'];
117
+ var state = { report: null, intake: null, tab: 'report' };
118
+
119
+ function text(el, s) { el.textContent = s == null ? '' : String(s); }
120
+ function el(tag, cls, s) { var e = document.createElement(tag); if (cls) e.className = cls; if (s != null) e.textContent = String(s); return e; }
121
+ function money(n) { return '$' + Math.round(Number(n) || 0).toLocaleString(); }
122
+ function chips(container, options, picked) {
123
+ container.replaceChildren();
124
+ options.forEach(function (opt) {
125
+ var c = el('span', 'chip' + (picked.indexOf(opt) >= 0 ? ' on' : ''), opt);
126
+ c.setAttribute('role', 'button');
127
+ c.onclick = function () { c.classList.toggle('on'); };
128
+ container.appendChild(c);
129
+ });
130
+ }
131
+ function pickedChips(container) {
132
+ return Array.prototype.map.call(container.querySelectorAll('.chip.on'), function (c) { return c.textContent; });
133
+ }
134
+ function splitList(s) { return String(s || '').split(',').map(function (x) { return x.trim(); }).filter(Boolean); }
135
+
136
+ // ── tabs ──
137
+ function showTab(name) {
138
+ state.tab = name;
139
+ ['report', 'intake', 'daily'].forEach(function (t) {
140
+ $(t).classList.toggle('hidden', t !== name);
141
+ $('tab-' + t).classList.toggle('on', t === name);
142
+ });
143
+ }
144
+ $('tab-report').onclick = function () { showTab('report'); };
145
+ $('tab-intake').onclick = function () { showTab('intake'); };
146
+ $('tab-daily').onclick = function () { showTab('daily'); };
147
+
148
+ // ── report ──
149
+ function renderReport() {
150
+ var r = state.report;
151
+ var hasReport = r && Array.isArray(r.breaks) && r.breaks.length;
152
+ $('report-empty').classList.toggle('hidden', !!hasReport);
153
+ $('report-body').classList.toggle('hidden', !hasReport);
154
+ text($('title'), (r && r.business) ? r.business + ' check-up' : 'Business check-up');
155
+ if (!hasReport) { text($('subtitle'), 'The report is not ready yet.'); return; }
156
+ text($('subtitle'), 'Where your apps disagree, and what it costs.');
157
+ text($('asof'), 'As of ' + r.asOf + ' · data ' + r.period.from + ' to ' + r.period.to + ' · ' + r.stats.people + ' people across ' + r.stats.systems.join(', '));
158
+ text($('total-hours'), r.totals.hoursPerWeek);
159
+ text($('total-dollars'), money(r.totals.dollarsPerMonth));
160
+
161
+ var fn = $('first-number');
162
+ if (r.firstNumber) {
163
+ fn.classList.remove('hidden'); fn.replaceChildren();
164
+ fn.appendChild(el('div', 'muted', 'The number you asked to see first'));
165
+ fn.appendChild(el('div', 'num', r.firstNumber.value));
166
+ fn.appendChild(el('div', 'note', r.firstNumber.label));
167
+ } else { fn.classList.add('hidden'); }
168
+
169
+ var byType = {}; r.breaks.forEach(function (b) { byType[b.type] = b; });
170
+ var top = $('top3'); top.replaceChildren();
171
+ (r.top3 || []).forEach(function (t, i) {
172
+ var b = byType[t]; if (!b) return;
173
+ var card = el('div', 'card'); card.setAttribute('data-crewly-id', 'top-' + (i + 1));
174
+ var h = el('h3'); h.appendChild(el('span', 'rank', i + 1)); h.appendChild(document.createTextNode(b.label + ' — ' + b.count)); card.appendChild(h);
175
+ card.appendChild(el('p', 'est', b.estimate.hoursPerWeek + ' h/week · ' + money(b.estimate.dollarsPerMonth) + '/month (estimate)'));
176
+ card.appendChild(el('div', 'formula', b.estimate.formula));
177
+ if (b.examples && b.examples.length) {
178
+ var ul = el('ul', 'plain'); b.examples.forEach(function (x) { ul.appendChild(el('li', 'note', x)); }); card.appendChild(ul);
179
+ }
180
+ card.appendChild(el('div', 'agent', 'What an agent could do: ' + b.whatAnAgentCouldDo));
181
+ top.appendChild(card);
182
+ });
183
+ if (!top.children.length) top.appendChild(el('p', 'muted', 'No breaks found in this data.'));
184
+
185
+ var rows = $('all-rows'); rows.replaceChildren();
186
+ r.breaks.forEach(function (b) {
187
+ var tr = el('tr'); tr.appendChild(el('td', null, b.label)); tr.appendChild(el('td', 'r', b.count));
188
+ tr.appendChild(el('td', 'r', b.estimate.hoursPerWeek)); tr.appendChild(el('td', 'r', money(b.estimate.dollarsPerMonth))); rows.appendChild(tr);
189
+ });
190
+ var notes = $('notes'); notes.replaceChildren();
191
+ (r.notes || []).forEach(function (n) { notes.appendChild(el('li', null, n)); });
192
+ $('notes-wrap').classList.toggle('hidden', !(r.notes && r.notes.length));
193
+ var files = $('files'); files.replaceChildren();
194
+ (r.files || []).forEach(function (f) { files.appendChild(el('li', null, f.file + ' — ' + f.source + ' ' + f.kind + ', ' + f.rows + ' rows')); });
195
+ var as = $('assumptions'); as.replaceChildren();
196
+ Object.keys(r.assumptions || {}).forEach(function (k) { as.appendChild(el('li', null, k + ': ' + r.assumptions[k])); });
197
+ var timing = $('timing'); timing.replaceChildren();
198
+ var t = r.timing;
199
+ if (t && t.steps && t.steps.length) {
200
+ t.steps.forEach(function (s) {
201
+ timing.appendChild(el('li', null, s.step + ': ' + (s.endedAt ? s.minutes + ' min' : 'still running')));
202
+ });
203
+ timing.appendChild(el('li', 'note', 'Total ' + t.totalMinutes + ' min of agent time' + (t.open && t.open.length ? ' so far' : '')));
204
+ }
205
+ $('timing-wrap').classList.toggle('hidden', !(t && t.steps && t.steps.length));
206
+ }
207
+ $('ask').onclick = function () {
208
+ crewly.notify('The owner opened the check-up report and tapped "Ask about this report". Reply in their DM with a one-line offer to walk through the top 3.').then(function () {
209
+ text($('ask'), 'Sent — your agent will reply in chat'); $('ask').disabled = true;
210
+ }).catch(function (e) { text($('ask'), 'Could not send: ' + e.message); });
211
+ };
212
+
213
+ // ── intake ──
214
+ function renderIntake() {
215
+ var a = state.intake || {};
216
+ chips($('q-apps-chips'), APP_CHIPS, a.apps || []);
217
+ $('q-apps').value = (a.apps || []).filter(function (x) { return APP_CHIPS.indexOf(x) < 0; }).join(', ');
218
+ $('q-task').value = a.slowestTask || '';
219
+ chips($('q-fear-chips'), FEAR_CHIPS, a.fears || []);
220
+ $('q-fear').value = (a.fears || []).filter(function (x) { return FEAR_CHIPS.indexOf(x) < 0; }).join(', ');
221
+ $('q-hops').value = a.appsPerCustomer || '';
222
+ chips($('q-first-chips'), FIRST_CHIPS, a.firstNumber ? [a.firstNumber] : []);
223
+ $('q-first').value = (a.firstNumber && FIRST_CHIPS.indexOf(a.firstNumber) < 0) ? a.firstNumber : '';
224
+ if (a.savedAt) { $('msg').className = 'ok'; text($('msg'), 'Saved ' + a.savedAt.slice(0, 16).replace('T', ' ')); }
225
+ }
226
+ $('save').onclick = function () {
227
+ var first = pickedChips($('q-first-chips'))[0] || $('q-first').value.trim() || null;
228
+ var answers = {
229
+ apps: pickedChips($('q-apps-chips')).concat(splitList($('q-apps').value)),
230
+ slowestTask: $('q-task').value.trim(),
231
+ fears: pickedChips($('q-fear-chips')).concat(splitList($('q-fear').value)),
232
+ appsPerCustomer: $('q-hops').value,
233
+ firstNumber: first,
234
+ savedAt: new Date().toISOString()
235
+ };
236
+ if (!answers.apps.length && !answers.slowestTask) { $('msg').className = ''; text($('msg'), 'Tell us at least which apps you use.'); return; }
237
+ $('save').disabled = true;
238
+ crewly.db.set('intake', 'answers', answers).then(function () {
239
+ state.intake = answers; $('msg').className = 'ok'; text($('msg'), 'Saved. Your agent will take it from here.'); $('save').disabled = false;
240
+ return crewly.notify('The owner saved their check-up answers (intake/answers). Read them with app-data and start the connect step.');
241
+ }).catch(function (e) { $('msg').className = ''; text($('msg'), 'Could not save: ' + e.message); $('save').disabled = false; });
242
+ };
243
+
244
+ // ── daily ──
245
+ function renderDaily(docs) {
246
+ var list = $('daily-list'); list.replaceChildren();
247
+ docs.sort(function (a, b) { return String(b.data.date).localeCompare(String(a.data.date)); });
248
+ docs.forEach(function (d) {
249
+ var li = el('li'); li.appendChild(el('div', 'muted', d.data.date)); li.appendChild(el('div', null, d.data.summary)); list.appendChild(li);
250
+ });
251
+ $('daily-empty').classList.toggle('hidden', docs.length > 0);
252
+ }
253
+
254
+ // ── load ──
255
+ function notFoundAsNull(e) { if (e && e.code === 'not_found') return null; throw e; }
256
+ function loadAll() {
257
+ return Promise.all([
258
+ crewly.db.get('report', 'current').then(function (d) { return d.data; }, notFoundAsNull),
259
+ crewly.db.get('intake', 'answers').then(function (d) { return d.data; }, notFoundAsNull),
260
+ crewly.db.list('daily', { limit: 30 }).then(function (r) { return r.docs; })
261
+ ]).then(function (res) {
262
+ state.report = res[0]; state.intake = res[1];
263
+ renderReport(); renderIntake(); renderDaily(res[2]);
264
+ if (!state.report && !state.intake) showTab('intake');
265
+ });
266
+ }
267
+ crewly.ready.then(function () {
268
+ return loadAll().then(function () {
269
+ crewly.db.subscribe('report', function () { loadAll().catch(fail); });
270
+ crewly.db.subscribe('daily', function () { loadAll().catch(fail); });
271
+ });
272
+ }).catch(fail);
273
+ function fail(e) { text($('subtitle'), 'Could not load: ' + (e && e.message ? e.message : e)); }
274
+ </script>
275
+ </body></html>
@@ -0,0 +1,41 @@
1
+ #!/bin/bash
2
+ # =============================================================================
3
+ # checkup-report — the report step of the small-business check-up
4
+ # (crewly#1062): copy the bundled report App into the project for publish-app,
5
+ # turn checkup-analyse's breaks.json into the App's report document, and
6
+ # write daily-summary documents for app-data.
7
+ #
8
+ # Usage:
9
+ # bash execute.sh --app-dir ./checkup-app # copy the App page
10
+ # bash execute.sh --breaks …/analysis/breaks.json --business "Oak Pottery" --intake ./intake.json --out ./checkup-data
11
+ # bash execute.sh --daily "Replied to 3 open inquiries; 1 booked." --out ./checkup-data
12
+ # bash execute.sh --log-step intake --start --out ./checkup-data # per-step time log (timelog.json)
13
+ # bash execute.sh '{"logStep":"intake","mark":"end","out":"./checkup-data"}'
14
+ # bash execute.sh '{"breaks":"…/breaks.json","business":"Oak Pottery","out":"./checkup-data"}'
15
+ #
16
+ # Output: one JSON line with the files written and the exact next command
17
+ # (publish-app or app-data). Exit 0 = done, 2 = an input could not be read, 1 = usage.
18
+ # =============================================================================
19
+ set -euo pipefail
20
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ REPORT="${SCRIPT_DIR}/report.mjs"
22
+
23
+ command -v node >/dev/null 2>&1 || { printf '{"success":false,"error":"node is required but not on PATH"}\n'; exit 1; }
24
+
25
+ ARGS=()
26
+ if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
27
+ command -v jq >/dev/null 2>&1 || { printf '{"success":false,"error":"jq is required to read JSON input"}\n'; exit 1; }
28
+ for key in appDir breaks business intake examples out daily date logStep at; do
29
+ v=$(printf '%s' "$1" | jq -r --arg k "$key" '.[$k] // empty')
30
+ if [ -n "$v" ]; then
31
+ case "$key" in appDir) flag=--app-dir ;; logStep) flag=--log-step ;; *) flag="--$key" ;; esac
32
+ ARGS+=("$flag" "$v")
33
+ fi
34
+ done
35
+ mark=$(printf '%s' "$1" | jq -r '.mark // empty')
36
+ case "$mark" in start|end) ARGS+=("--$mark") ;; esac
37
+ shift
38
+ fi
39
+ ARGS+=("$@")
40
+
41
+ exec node "$REPORT" ${ARGS[@]+"${ARGS[@]}"}
@@ -0,0 +1,174 @@
1
+ #!/bin/bash
2
+ # Tests for checkup-report — run with: bash execute.test.sh
3
+ # Offline: runs ingest-exports + checkup-analyse on the ingest fixtures first. Needs node and jq.
4
+ set -u
5
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ EXEC="$HERE/execute.sh"
7
+ INGEST="$HERE/../ingest-exports/execute.sh"
8
+ ANALYSE="$HERE/../checkup-analyse/execute.sh"
9
+ FX="$HERE/../ingest-exports/fixtures"
10
+ PASS=0; FAIL=0
11
+ check() {
12
+ local name="$1"; local got="$2"; local want="$3"
13
+ if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
14
+ }
15
+ T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
16
+ bash "$INGEST" --in "$FX" --out "$T/ingest" >/dev/null || { echo "ingest-exports failed"; exit 1; }
17
+ bash "$INGEST" --in "$FX" --out "$T/ingest-hashed" --hash-names >/dev/null
18
+ bash "$ANALYSE" --in "$T/ingest" --as-of 2026-10-26 --out "$T/an" >/dev/null || { echo "checkup-analyse failed"; exit 1; }
19
+ bash "$ANALYSE" --in "$T/ingest-hashed" --as-of 2026-10-26 --out "$T/anh" >/dev/null
20
+
21
+ # 1. The App page follows the Crewly Apps rules (sandbox: no network, no storage, old Safari)
22
+ PAGE="$HERE/app/index.html"
23
+ check "page exists" "$([ -f "$PAGE" ] && echo yes || echo no)" "yes"
24
+ check "viewport meta" "$(grep -c 'name="viewport"' "$PAGE")" "1"
25
+ check "no external script" "$(grep -c -E '<script[^>]+src=' "$PAGE")" "0"
26
+ check "no fetch/XHR" "$(grep -c -E '\bfetch\(|XMLHttpRequest|WebSocket' "$PAGE")" "0"
27
+ check "no localStorage/cookies" "$(grep -c -E 'localStorage|sessionStorage|document\.cookie' "$PAGE")" "0"
28
+ check "no alert/confirm/prompt" "$(grep -c -E '\b(alert|confirm|prompt)\(' "$PAGE")" "0"
29
+ check "no innerHTML with data" "$(grep -c 'innerHTML' "$PAGE")" "0"
30
+ check "no optional chaining / nullish (iOS 12)" "$(grep -c -E '\?\.[a-zA-Z(]|\?\?' "$PAGE")" "0"
31
+ check "no arrow functions / class fields (iOS 12)" "$(grep -c -E '=>' "$PAGE")" "0"
32
+ check "replaceChildren polyfilled" "$(grep -c 'Element.prototype.replaceChildren = ' "$PAGE")" "1"
33
+ check "reads report/current" "$(grep -c "crewly.db.get('report', 'current')" "$PAGE")" "1"
34
+ check "writes intake/answers" "$(grep -c "crewly.db.set('intake', 'answers'" "$PAGE")" "1"
35
+ check "lists daily" "$(grep -c "crewly.db.list('daily'" "$PAGE")" "1"
36
+ check "subscribes to report changes" "$(grep -c "crewly.db.subscribe('report'" "$PAGE")" "1"
37
+ check "data-crewly-id on the key elements" "$(grep -o 'data-crewly-id="[^"]*"' "$PAGE" | sort -u | wc -l | tr -d ' ' | awk '{print ($1>=15)?"enough":"few"}')" "enough"
38
+ check "page JS parses (node)" "$(node -e '
39
+ const fs=require("fs");const html=fs.readFileSync(process.argv[1],"utf8");
40
+ const m=html.match(/<script>([\s\S]*?)<\/script>/);try{new Function(m[1]);console.log("ok")}catch(e){console.log("syntax: "+e.message)}' "$PAGE")" "ok"
41
+
42
+ # 2. --app-dir copies the page into the project for publish-app
43
+ OUT="$(bash "$EXEC" --app-dir "$T/checkup-app")"; RC=$?
44
+ check "app-dir exits 0" "$RC" "0"
45
+ check "index.html copied" "$([ -f "$T/checkup-app/index.html" ] && echo yes || echo no)" "yes"
46
+ check "copied list" "$(printf '%s' "$OUT" | jq -c '.copied')" '["index.html"]'
47
+ check "next says publish-app" "$(printf '%s' "$OUT" | jq -r '.next' | cut -c1-11)" "publish-app"
48
+ check "copy is identical" "$(cmp -s "$PAGE" "$T/checkup-app/index.html" && echo same || echo differs)" "same"
49
+
50
+ # 3. --breaks builds the report document
51
+ cat > "$T/intake.json" <<'EOF'
52
+ {"apps":["Square","Jotform","Jackrabbit","Mailchimp","QuickBooks"],"slowestTask":"copying sign-ups into the roster","fears":["Unpaid invoices"],"appsPerCustomer":"3","firstNumber":"Money owed to me","savedAt":"2026-10-26T10:00:00.000Z"}
53
+ EOF
54
+ ROUT="$(bash "$EXEC" --breaks "$T/an/breaks.json" --business "Oak Pottery" --intake "$T/intake.json" --out "$T/data")"; RC=$?
55
+ check "breaks exits 0" "$RC" "0"
56
+ check "report-doc written" "$([ -f "$T/data/report-doc.json" ] && echo yes || echo no)" "yes"
57
+ D="$T/data/report-doc.json"
58
+ check "business" "$(jq -r '.business' "$D")" "Oak Pottery"
59
+ check "as-of carried" "$(jq -r '.asOf' "$D")" "2026-10-26"
60
+ check "top3 carried" "$(jq -c '.top3' "$D")" "$(jq -c '.top3' "$T/an/breaks.json")"
61
+ check "totals carried" "$(jq -c '.totals' "$D")" "$(jq -c '.totals' "$T/an/breaks.json")"
62
+ check "8 breaks" "$(jq -r '.breaks | length' "$D")" "8"
63
+ check "no raw items in the doc" "$(jq -r '[.breaks[] | has("items")] | any' "$D")" "false"
64
+ check "estimate keeps the formula" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .estimate.formula | length > 20' "$D")" "true"
65
+ check "examples for overdue invoices" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples | length' "$D")" "2"
66
+ check " example text" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples[0]' "$D")" "Harbor Dental: invoice 1042 \$1250, 26 days overdue"
67
+ check "example for unpaid sign-up" "$(jq -r '.breaks[] | select(.type=="signed_up_unpaid") | .examples[0]' "$D")" "Sam Rivera: Kids Pottery Sat 10am on 2026-09-10, \$180 unpaid"
68
+ check "example for double entry" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples[0]' "$D")" "Maria Lopez is in jackrabbit, mailchimp, square"
69
+ check "examples capped at 3 by default" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples | length' "$D")" "3"
70
+ check "first number: money owed" "$(jq -r '.firstNumber.key' "$D")" "money_owed"
71
+ check " value = overdue + unpaid sign-ups" "$(jq -r '.firstNumber.value' "$D")" "\$3,571"
72
+ check " asked text kept" "$(jq -r '.firstNumber.asked' "$D")" "Money owed to me"
73
+ check "summary line has top3" "$(printf '%s' "$ROUT" | jq -r '.top3[0]')" "overdue_invoices"
74
+ check "summary line next = app-data set" "$(printf '%s' "$ROUT" | jq -r '.next' | grep -c '^app-data --app <appId> --set report current --data-file ')" "1"
75
+ check "files carried" "$(jq -r '.files | length' "$D")" "7"
76
+ check "assumptions carried" "$(jq -r '.assumptions.hourlyValue' "$D")" "30"
77
+ check "doc small" "$(wc -c < "$D" | awk '{print ($1<20000)?"small":"big"}')" "small"
78
+
79
+ # 4. Examples cap, other first-number choices, intake in app-data output shape, free-text first number
80
+ bash "$EXEC" --breaks "$T/an/breaks.json" --examples 1 --out "$T/data1" >/dev/null
81
+ check "--examples 1" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples | length' "$T/data1/report-doc.json")" "1"
82
+ bash "$EXEC" --breaks "$T/an/breaks.json" --examples 4 --out "$T/data4x" >/dev/null
83
+ check "--examples 4 reaches the conflict example" "$(jq -r '.breaks[] | select(.type=="double_entry") | .examples[3]' "$T/data4x/report-doc.json")" "Tom Baker is in jotform, mailchimp (phone missing in mailchimp)"
84
+ bash "$EXEC" --breaks "$T/an/breaks.json" --examples 0 --out "$T/data0" >/dev/null
85
+ check "--examples 0 → none" "$(jq -r '[.breaks[].examples | length] | add' "$T/data0/report-doc.json")" "0"
86
+ printf '{"success":true,"id":"answers","data":{"apps":["Square"],"firstNumber":"Inquiries I missed"},"rev":1}' > "$T/intake-appdata.json"
87
+ bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-appdata.json" --out "$T/data2" >/dev/null
88
+ check "intake from app-data output shape" "$(jq -r '.firstNumber.key' "$T/data2/report-doc.json")" "missed_inquiries"
89
+ check " value" "$(jq -r '.firstNumber.value' "$T/data2/report-doc.json")" "1"
90
+ printf '{"firstNumber":"how many hours we lose typing things twice"}' > "$T/intake-free.json"
91
+ bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-free.json" --out "$T/data3" >/dev/null
92
+ check "free-text first number mapped" "$(jq -r '.firstNumber.key' "$T/data3/report-doc.json")" "double_entry_hours"
93
+ printf '{"firstNumber":"Customers who lapsed"}' > "$T/intake-lapsed.json"
94
+ bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-lapsed.json" --out "$T/data4" >/dev/null
95
+ check "lapsed first number" "$(jq -r '.firstNumber.key' "$T/data4/report-doc.json")" "lapsed"
96
+ printf '{"firstNumber":"the weather"}' > "$T/intake-odd.json"
97
+ bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/intake-odd.json" --out "$T/data5" >/dev/null
98
+ check "unmappable first number → no firstNumber" "$(jq -r 'has("firstNumber")' "$T/data5/report-doc.json")" "false"
99
+ check " but a note" "$(jq -r '.notes | map(select(test("the weather"))) | length' "$T/data5/report-doc.json")" "1"
100
+ bash "$EXEC" --breaks "$T/an/breaks.json" --out "$T/data6" >/dev/null
101
+ check "no intake → no firstNumber, no note" "$(jq -r 'has("firstNumber"), (.notes|length)' "$T/data6/report-doc.json" | tr '\n' ' ')" "false 0 "
102
+
103
+ # 5. Hashed data: no names, examples say "names hashed"
104
+ bash "$EXEC" --breaks "$T/anh/breaks.json" --out "$T/datah" >/dev/null
105
+ check "hashed: no plain names in the doc" "$(grep -c -E "Lopez|Rivera|Baker|Chen|Natarajan|example\.test" "$T/datah/report-doc.json")" "0"
106
+ check "hashed: no hashes leak either" "$(grep -c '"h:' "$T/datah/report-doc.json")" "0"
107
+ check "hashed: example says names hashed" "$(jq -r '.breaks[] | select(.type=="signed_up_unpaid") | .examples[0]' "$T/datah/report-doc.json")" "1 person (names hashed)"
108
+ check "hashed: invoice customers hashed too → count only" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .examples[0]' "$T/datah/report-doc.json")" "2 people (names hashed)"
109
+
110
+ # 6. --daily
111
+ DOUT="$(bash "$EXEC" --daily "Replied to 3 open inquiries; 1 booked." --date 2026-10-27 --out "$T/data")"
112
+ check "daily exits 0" "$?" "0"
113
+ check "daily file" "$([ -f "$T/data/daily-2026-10-27.json" ] && echo yes || echo no)" "yes"
114
+ check "daily doc" "$(jq -c '{date,summary}' "$T/data/daily-2026-10-27.json")" '{"date":"2026-10-27","summary":"Replied to 3 open inquiries; 1 booked."}'
115
+ check "daily next = app-data set daily <date>" "$(printf '%s' "$DOUT" | jq -r '.next' | grep -c '^app-data --app <appId> --set daily 2026-10-27 --data-file ')" "1"
116
+ check "daily default date = today" "$(bash "$EXEC" --daily "x" --out "$T/data" | jq -r '.dailyDoc' | grep -c "daily-$(date -u +%F).json")" "1"
117
+
118
+ # 7. JSON input and combined calls
119
+ JOUT="$(bash "$EXEC" "{\"appDir\":\"$T/japp\",\"breaks\":\"$T/an/breaks.json\",\"business\":\"Oak\",\"out\":\"$T/jdata\"}")"
120
+ check "json input: app copied" "$([ -f "$T/japp/index.html" ] && echo yes || echo no)" "yes"
121
+ check "json input: report written" "$(jq -r '.business' "$T/jdata/report-doc.json")" "Oak"
122
+ check "json input: summary has both" "$(printf '%s' "$JOUT" | jq -r '(.copied|length), (.top3|length)' | tr '\n' ' ')" "1 3 "
123
+
124
+ # 8. Errors
125
+ bash "$EXEC" >/dev/null 2>&1; check "no args exits 1" "$?" "1"
126
+ bash "$EXEC" --breaks "$T/an/breaks.json" >/dev/null 2>&1; check "breaks without --out exits 1" "$?" "1"
127
+ bash "$EXEC" --breaks "$T/nope.json" --out "$T/x" >/dev/null 2>&1; check "missing breaks exits 2" "$?" "2"
128
+ printf '{"foo":1}' > "$T/notbreaks.json"
129
+ bash "$EXEC" --breaks "$T/notbreaks.json" --out "$T/x" >/dev/null 2>&1; check "wrong file exits 2" "$?" "2"
130
+ bash "$EXEC" --breaks "$T/an/breaks.json" --intake "$T/nope.json" --out "$T/x" >/dev/null 2>&1; check "missing intake exits 2" "$?" "2"
131
+ bash "$EXEC" --daily "x" --date 27/10/2026 --out "$T/x" >/dev/null 2>&1; check "bad date exits 1" "$?" "1"
132
+ bash "$EXEC" --daily " " --out "$T/x" >/dev/null 2>&1; check "empty daily exits 1" "$?" "1"
133
+ bash "$EXEC" --breaks "$T/an/breaks.json" --examples lots --out "$T/x" >/dev/null 2>&1; check "bad examples exits 1" "$?" "1"
134
+ bash "$EXEC" --bogus >/dev/null 2>&1; check "unknown flag exits 1" "$?" "1"
135
+
136
+ # 9. --log-step: per-step time log
137
+ check "page has a timing block" "$(grep -c 'data-crewly-id="timing"' "$PAGE")" "1"
138
+ bash "$EXEC" --log-step intake --start --at 2026-10-26T09:00:00Z --out "$T/tl" >/dev/null; check "log start exits 0" "$?" "0"
139
+ check "timelog written" "$([ -f "$T/tl/timelog.json" ] && echo yes || echo no)" "yes"
140
+ check "open entry" "$(jq -c '.steps[0] | {step, endedAt, minutes}' "$T/tl/timelog.json")" '{"step":"intake","endedAt":null,"minutes":null}'
141
+ LOUT="$(bash "$EXEC" --log-step intake --end --at 2026-10-26T09:12:30Z --out "$T/tl")"
142
+ check "log end exits 0" "$?" "0"
143
+ check "minutes = 12.5" "$(jq -r '.steps[0].minutes' "$T/tl/timelog.json")" "12.5"
144
+ check "end output carries the entry" "$(printf '%s' "$LOUT" | jq -r '.entry.endedAt')" "2026-10-26T09:12:30.000Z"
145
+ check "end output totals" "$(printf '%s' "$LOUT" | jq -c '.timing | {totalMinutes, open}')" '{"totalMinutes":12.5,"open":[]}'
146
+ bash "$EXEC" --log-step connect --start --at 2026-10-26T09:13:00Z --out "$T/tl" >/dev/null
147
+ bash "$EXEC" --log-step connect --start --at 2026-10-26T09:14:00Z --out "$T/tl" >/dev/null
148
+ check "restart replaces the open start" "$(jq -r '[.steps[] | select(.step=="connect")] | length, .[0].startedAt' "$T/tl/timelog.json" | tr '\n' ' ')" "1 2026-10-26T09:14:00.000Z "
149
+ bash "$EXEC" --log-step connect --end --at 2026-10-26T10:14:00Z --out "$T/tl" >/dev/null
150
+ bash "$EXEC" --log-step analyse --start --at 2026-10-26T10:15:00Z --out "$T/tl" >/dev/null
151
+ bash "$EXEC" --log-step analyse --end --at 2026-10-26T10:21:00Z --out "$T/tl" >/dev/null
152
+ bash "$EXEC" --log-step report --start --at 2026-10-26T10:22:00Z --out "$T/tl" >/dev/null
153
+ JLOG="$(bash "$EXEC" "{\"logStep\":\"report\",\"mark\":\"end\",\"at\":\"2026-10-26T10:30:00Z\",\"out\":\"$T/tl\"}")"
154
+ check "json input logs a step" "$(printf '%s' "$JLOG" | jq -r '.entry.minutes')" "8"
155
+ check "four closed steps" "$(jq -r '[.steps[] | select(.endedAt)] | length' "$T/tl/timelog.json")" "4"
156
+ check "same step twice keeps both" "$(bash "$EXEC" --log-step daily --start --at 2026-10-27T09:00:00Z --out "$T/tl" >/dev/null; bash "$EXEC" --log-step daily --end --at 2026-10-27T09:03:00Z --out "$T/tl" >/dev/null; bash "$EXEC" --log-step daily --start --at 2026-10-28T09:00:00Z --out "$T/tl" >/dev/null; jq -r '[.steps[] | select(.step=="daily")] | length' "$T/tl/timelog.json")" "2"
157
+ bash "$EXEC" --breaks "$T/an/breaks.json" --business "Oak Pottery" --out "$T/tl" >/dev/null
158
+ check "report folds the time log in" "$(jq -c '.timing | {totalMinutes, open, n: (.steps|length)}' "$T/tl/report-doc.json")" '{"totalMinutes":89.5,"open":["daily"],"n":6}'
159
+ check "timing steps carry minutes" "$(jq -r '.timing.steps[1] | "\(.step) \(.minutes)"' "$T/tl/report-doc.json")" "connect 60"
160
+ check "no time log → no timing" "$(jq -r 'has("timing")' "$D")" "false"
161
+ bash "$EXEC" --log-step cook --start --out "$T/tl" >/dev/null 2>&1; check "unknown step exits 1" "$?" "1"
162
+ bash "$EXEC" --log-step analyse --end --out "$T/tl" >/dev/null 2>&1; check "end without start exits 2" "$?" "2"
163
+ bash "$EXEC" --log-step intake --out "$T/tl" >/dev/null 2>&1; check "log-step without --start/--end exits 1" "$?" "1"
164
+ bash "$EXEC" --start --out "$T/tl" >/dev/null 2>&1; check "--start without --log-step exits 1" "$?" "1"
165
+ bash "$EXEC" --log-step intake --start >/dev/null 2>&1; check "log-step without --out exits 1" "$?" "1"
166
+ bash "$EXEC" --log-step intake --start --at yesterday --out "$T/tl" >/dev/null 2>&1; check "bad --at exits 1" "$?" "1"
167
+ bash "$EXEC" --log-step intake --start --at 2026-10-26T09:00:00Z --out "$T/tl2" >/dev/null
168
+ bash "$EXEC" --log-step intake --end --at 2026-10-26T08:00:00Z --out "$T/tl2" >/dev/null 2>&1; check "end before start exits 1" "$?" "1"
169
+ printf '{"nope":1}' > "$T/tl3.json"; mkdir -p "$T/tl3"; cp "$T/tl3.json" "$T/tl3/timelog.json"
170
+ bash "$EXEC" --log-step intake --start --out "$T/tl3" >/dev/null 2>&1; check "broken timelog exits 2" "$?" "2"
171
+ bash "$EXEC" --breaks "$T/an/breaks.json" --out "$T/tl3" >/dev/null 2>&1; check "report with broken timelog exits 2" "$?" "2"
172
+
173
+ echo "checkup-report: $PASS passed, $FAIL failed"
174
+ [ "$FAIL" -eq 0 ]
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // =============================================================================
3
+ // checkup-report — the report step of the small-business check-up
4
+ // (crewly#1062). Turns `breaks.json` from checkup-analyse into the document
5
+ // the bundled report App renders (report/current), optionally places the
6
+ // number the owner asked to see first (from the intake answers), writes
7
+ // daily-summary documents, and copies the App's files into the project so
8
+ // publish-app can upload them.
9
+ //
10
+ // node report.mjs --app-dir ./checkup-app # copy the App page to publish
11
+ // node report.mjs --breaks …/breaks.json --business "Oak Pottery" [--intake answers.json] --out ./checkup-data
12
+ // node report.mjs --daily "Replied to 3 open inquiries; 1 booked." [--date 2026-10-27] --out ./checkup-data
13
+ // node report.mjs --log-step intake --start --out ./checkup-data # per-step time log → timelog.json
14
+ // node report.mjs --log-step intake --end --out ./checkup-data
15
+ // =============================================================================
16
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, readdirSync, statSync } from 'node:fs';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const HERE = dirname(fileURLToPath(import.meta.url));
21
+ const APP_SRC = join(HERE, 'app');
22
+ const DEFAULT_EXAMPLES = 3;
23
+ /** Crewly App documents are capped at 256 KB; stay well under it. */
24
+ const MAX_DOC_BYTES = 200 * 1024;
25
+ /** The check-up's steps, in order; the time log accepts no other name. */
26
+ export const STEPS = ['intake', 'connect', 'analyse', 'report', 'daily'];
27
+ const TIMELOG_FILE = 'timelog.json';
28
+
29
+ /** The "which number first" choices offered by the intake form, with how each is read off the breaks. */
30
+ const FIRST_NUMBER_RULES = [
31
+ { key: 'money_owed', match: /owed|owe|invoice|unpaid|money|cash|balance/i, label: 'Owed to you: overdue invoices plus unpaid sign-ups',
32
+ value: (by) => '$' + Math.round((by.overdue_invoices?.estimate.outstanding || 0) + (by.signed_up_unpaid?.estimate.outstanding || 0)).toLocaleString('en-US') },
33
+ { key: 'missed_inquiries', match: /inquir|lead|enquir|missed|unanswered/i, label: 'Inquiries with no sign-up or payment after them',
34
+ value: (by) => String(by.inquiry_no_follow_through?.count || 0) },
35
+ { key: 'double_entry_hours', match: /double|entry|typing|hours|time|twice/i, label: 'Hours a week spent keying the same people into more than one system (estimate)',
36
+ value: (by) => `${by.double_entry?.estimate.hoursPerWeek || 0} h/week` },
37
+ { key: 'lapsed', match: /laps|stopped|churn|left|came back|coming/i, label: 'Customers with no activity for a while',
38
+ value: (by) => String(by.lapsed?.count || 0) },
39
+ ];
40
+
41
+ /** Short, owner-readable examples for a break's first items; none when the data is hashed. */
42
+ function examplesFor(b, limit) {
43
+ const out = [];
44
+ for (const it of b.items.slice(0, limit)) {
45
+ const who = it.person || it.customer || '';
46
+ if (!who || String(who).startsWith('h:')) continue;
47
+ switch (b.type) {
48
+ case 'inquiry_no_follow_through': out.push(`${who} asked about ${it.item || 'a class'} on ${it.inquiredAt}`); break;
49
+ case 'signed_up_unpaid': out.push(`${who}: ${it.item || 'sign-up'} on ${it.signedUpAt}, $${it.fee} unpaid`); break;
50
+ case 'paid_not_listed': out.push(`${who} paid $${it.amount} on ${it.paidAt} (${it.item || 'no item'}) and is on no roster`); break;
51
+ case 'dropped': out.push(`${who}: ${it.item || 'sign-up'} dropped${it.droppedAt ? ` on ${it.droppedAt}` : ''}`); break;
52
+ case 'lapsed': out.push(`${who}: last seen ${it.lastActivity}, ${it.daysSince} days ago`); break;
53
+ case 'overdue_invoices': out.push(`${who}: invoice ${it.number || ''} $${it.balance}, ${it.daysOverdue} days overdue`); break;
54
+ case 'double_entry': out.push(`${who} is in ${it.systems.join(', ')}${it.conflicts.length ? ` (${it.conflicts.join('; ')})` : ''}`); break;
55
+ case 'not_on_mailing_list': out.push(`${who}`); break;
56
+ default: out.push(who);
57
+ }
58
+ }
59
+ if (!out.length && b.items.length && String(b.items[0].person || b.items[0].customer || '').startsWith('h:')) {
60
+ out.push(`${b.count} ${b.count === 1 ? 'person' : 'people'} (names hashed)`);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Build the report document the App renders from checkup-analyse output.
67
+ * Items are summarised into a few examples per break so the document stays small
68
+ * and no full list of people lands in the App's data. `opts.timelog` (from
69
+ * `readTimelog`) adds a `timing` block with minutes per step.
70
+ */
71
+ export function buildReportDoc(breaks, opts = {}) {
72
+ const by = Object.fromEntries(breaks.breaks.map((b) => [b.type, b]));
73
+ const limit = Number.isFinite(opts.examples) ? opts.examples : DEFAULT_EXAMPLES;
74
+ const doc = {
75
+ business: opts.business || null,
76
+ generatedAt: new Date().toISOString(),
77
+ asOf: breaks.asOf,
78
+ period: breaks.period,
79
+ stats: { people: breaks.stats.people, systems: breaks.stats.systems, records: breaks.stats.records },
80
+ totals: breaks.totals,
81
+ breaks: breaks.breaks.map((b) => ({
82
+ type: b.type,
83
+ label: b.label,
84
+ count: b.count,
85
+ estimate: { hoursPerWeek: b.estimate.hoursPerWeek, dollarsPerMonth: b.estimate.dollarsPerMonth, formula: b.estimate.formula, isEstimate: true },
86
+ whatAnAgentCouldDo: b.whatAnAgentCouldDo,
87
+ examples: examplesFor(b, limit),
88
+ })),
89
+ top3: breaks.top3,
90
+ notes: breaks.notes || [],
91
+ files: (breaks.files || []).map((f) => ({ file: f.file, source: f.source, kind: f.kind, rows: f.rows })),
92
+ assumptions: breaks.assumptions,
93
+ };
94
+ const want = opts.intake && opts.intake.firstNumber ? String(opts.intake.firstNumber) : '';
95
+ if (want) {
96
+ const rule = FIRST_NUMBER_RULES.find((r) => r.match.test(want));
97
+ if (rule) doc.firstNumber = { asked: want, key: rule.key, label: rule.label, value: rule.value(by) };
98
+ else doc.notes = [...doc.notes, `The owner asked to see "${want}" first; no break maps to it, so the top 3 lead.`];
99
+ }
100
+ // How long the check-up took, when the agent logged its steps (`--log-step`).
101
+ if (opts.timelog && opts.timelog.steps.length) doc.timing = summariseTimelog(opts.timelog);
102
+ return doc;
103
+ }
104
+
105
+ /** Read `<dir>/timelog.json`; an absent file is an empty log, a broken one throws. */
106
+ export function readTimelog(dir) {
107
+ const file = join(dir, TIMELOG_FILE);
108
+ if (!existsSync(file)) return { steps: [] };
109
+ const raw = JSON.parse(readFileSync(file, 'utf8'));
110
+ if (!raw || !Array.isArray(raw.steps)) throw new Error(`${file}: not a time log ({ steps: [] })`);
111
+ return raw;
112
+ }
113
+
114
+ /**
115
+ * Record the start or end of a check-up step in the time log.
116
+ *
117
+ * `--start` opens an entry (a second start on a still-open step restarts it);
118
+ * `--end` closes the latest open entry of that step and fills in `minutes`.
119
+ * `at` overrides the clock (ISO timestamp) for back-filling and tests.
120
+ *
121
+ * @returns the updated log and the entry touched
122
+ * @throws when the step is unknown, `--end` finds no open entry, or `at` is not a date
123
+ */
124
+ export function logStep(log, step, mark, at) {
125
+ if (!STEPS.includes(step)) throw new Error(`unknown step "${step}"; use one of ${STEPS.join(', ')}`);
126
+ const when = at ? new Date(at) : new Date();
127
+ if (Number.isNaN(when.getTime())) throw new Error(`--at must be an ISO timestamp, got "${at}"`);
128
+ const iso = when.toISOString();
129
+ const open = [...log.steps].reverse().find((s) => s.step === step && !s.endedAt);
130
+ let entry;
131
+ if (mark === 'start') {
132
+ if (open) { open.startedAt = iso; entry = open; }
133
+ else { entry = { step, startedAt: iso, endedAt: null, minutes: null }; log.steps.push(entry); }
134
+ } else {
135
+ if (!open) throw new Error(`no open "${step}" entry to end; run --log-step ${step} --start first`);
136
+ if (when.getTime() < new Date(open.startedAt).getTime()) throw new Error(`end (${iso}) is before start (${open.startedAt})`);
137
+ open.endedAt = iso;
138
+ open.minutes = Math.round((when.getTime() - new Date(open.startedAt).getTime()) / 6000) / 10;
139
+ entry = open;
140
+ }
141
+ return { log, entry };
142
+ }
143
+
144
+ /** Fold a time log into the shape the report document carries: closed steps with minutes, plus the total. */
145
+ export function summariseTimelog(log) {
146
+ const steps = log.steps.map((s) => ({ step: s.step, startedAt: s.startedAt, endedAt: s.endedAt, minutes: s.minutes }));
147
+ const closed = steps.filter((s) => s.endedAt);
148
+ const totalMinutes = Math.round(closed.reduce((a, s) => a + (s.minutes || 0), 0) * 10) / 10;
149
+ return { steps, totalMinutes, open: steps.filter((s) => !s.endedAt).map((s) => s.step) };
150
+ }
151
+
152
+ /** Read intake answers from a plain document or from app-data's `{ success, data }` output. */
153
+ function readIntake(path) {
154
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
155
+ return raw && raw.data && typeof raw.data === 'object' && !raw.apps ? raw.data : raw;
156
+ }
157
+
158
+ /** Copy the App page into a directory inside the project so publish-app can upload it. */
159
+ function copyApp(dir) {
160
+ mkdirSync(dir, { recursive: true });
161
+ const copied = [];
162
+ for (const f of readdirSync(APP_SRC)) {
163
+ const src = join(APP_SRC, f);
164
+ if (!statSync(src).isFile()) continue;
165
+ copyFileSync(src, join(dir, f));
166
+ copied.push(f);
167
+ }
168
+ return copied;
169
+ }
170
+
171
+ function out(obj, code) {
172
+ process.stdout.write(JSON.stringify(obj) + '\n');
173
+ process.exit(code);
174
+ }
175
+
176
+ function usage() {
177
+ out({ success: false, error: 'usage: report.mjs --app-dir <dir> | --breaks <breaks.json> [--business <name>] [--intake <answers.json>] [--examples N] --out <dir> | --daily "<summary>" [--date YYYY-MM-DD] --out <dir> | --log-step <intake|connect|analyse|report|daily> --start|--end [--at <iso>] --out <dir>' }, 1);
178
+ }
179
+
180
+ function main(argv) {
181
+ const o = { examples: DEFAULT_EXAMPLES };
182
+ for (let i = 0; i < argv.length; i += 1) {
183
+ const a = argv[i];
184
+ const next = () => { i += 1; if (i >= argv.length) usage(); return argv[i]; };
185
+ if (a === '--app-dir') o.appDir = next();
186
+ else if (a === '--breaks') o.breaks = next();
187
+ else if (a === '--business') o.business = next();
188
+ else if (a === '--intake') o.intake = next();
189
+ else if (a === '--examples') o.examples = Number(next());
190
+ else if (a === '--out') o.out = next();
191
+ else if (a === '--daily') o.daily = next();
192
+ else if (a === '--date') o.date = next();
193
+ else if (a === '--log-step') o.logStep = next();
194
+ else if (a === '--start') o.mark = 'start';
195
+ else if (a === '--end') o.mark = 'end';
196
+ else if (a === '--at') o.at = next();
197
+ else usage();
198
+ }
199
+ if (!o.appDir && !o.breaks && !o.daily && !o.logStep) usage();
200
+ if ((o.breaks || o.daily || o.logStep) && !o.out) usage();
201
+ if (o.logStep && !o.mark) usage();
202
+ if (o.mark && !o.logStep) usage();
203
+ if (o.date && !/^\d{4}-\d{2}-\d{2}$/.test(o.date)) out({ success: false, error: `--date must be YYYY-MM-DD, got "${o.date}"` }, 1);
204
+ if (!Number.isFinite(o.examples) || o.examples < 0) out({ success: false, error: '--examples needs a number >= 0' }, 1);
205
+
206
+ const result = { success: true };
207
+ if (o.logStep) {
208
+ let log;
209
+ try {
210
+ log = readTimelog(o.out);
211
+ } catch (e) {
212
+ out({ success: false, error: e.message }, 2);
213
+ }
214
+ let entry;
215
+ try {
216
+ ({ entry } = logStep(log, o.logStep, o.mark, o.at));
217
+ } catch (e) {
218
+ out({ success: false, error: e.message }, o.mark === 'end' && /no open/.test(e.message) ? 2 : 1);
219
+ }
220
+ mkdirSync(o.out, { recursive: true });
221
+ writeFileSync(join(o.out, TIMELOG_FILE), JSON.stringify(log, null, 2));
222
+ result.timelog = resolve(join(o.out, TIMELOG_FILE));
223
+ result.entry = entry;
224
+ result.timing = summariseTimelog(log);
225
+ }
226
+ if (o.appDir) {
227
+ result.appDir = resolve(o.appDir);
228
+ result.copied = copyApp(o.appDir);
229
+ result.next = `publish-app --dir ${o.appDir} --name "<business> check-up" --notify`;
230
+ }
231
+ if (o.breaks) {
232
+ if (!existsSync(o.breaks)) out({ success: false, error: `${o.breaks}: not found (run checkup-analyse with --out first)` }, 2);
233
+ let breaks;
234
+ try {
235
+ breaks = JSON.parse(readFileSync(o.breaks, 'utf8'));
236
+ } catch (e) {
237
+ out({ success: false, error: `${o.breaks}: ${e.message}` }, 2);
238
+ }
239
+ if (!Array.isArray(breaks.breaks) || !breaks.totals) out({ success: false, error: `${o.breaks}: not a checkup-analyse breaks.json` }, 2);
240
+ let intake = null;
241
+ if (o.intake) {
242
+ if (!existsSync(o.intake)) out({ success: false, error: `${o.intake}: not found` }, 2);
243
+ try {
244
+ intake = readIntake(o.intake);
245
+ } catch (e) {
246
+ out({ success: false, error: `${o.intake}: ${e.message}` }, 2);
247
+ }
248
+ }
249
+ let timelog = null;
250
+ try {
251
+ timelog = readTimelog(o.out);
252
+ } catch (e) {
253
+ out({ success: false, error: e.message }, 2);
254
+ }
255
+ const doc = buildReportDoc(breaks, { business: o.business, intake, examples: o.examples, timelog });
256
+ const json = JSON.stringify(doc, null, 2);
257
+ if (Buffer.byteLength(json) > MAX_DOC_BYTES) out({ success: false, error: `report document is ${Buffer.byteLength(json)} bytes; lower --examples` }, 2);
258
+ mkdirSync(o.out, { recursive: true });
259
+ writeFileSync(join(o.out, 'report-doc.json'), json);
260
+ result.reportDoc = resolve(join(o.out, 'report-doc.json'));
261
+ result.top3 = doc.top3;
262
+ result.totals = doc.totals;
263
+ result.firstNumber = doc.firstNumber || null;
264
+ result.timing = doc.timing || null;
265
+ result.next = `app-data --app <appId> --set report current --data-file ${join(o.out, 'report-doc.json')}`;
266
+ }
267
+ if (o.daily) {
268
+ const summary = String(o.daily).trim();
269
+ if (!summary) out({ success: false, error: '--daily needs text' }, 1);
270
+ const date = o.date || new Date().toISOString().slice(0, 10);
271
+ const doc = { date, summary, postedAt: new Date().toISOString() };
272
+ mkdirSync(o.out, { recursive: true });
273
+ const file = join(o.out, `daily-${date}.json`);
274
+ writeFileSync(file, JSON.stringify(doc, null, 2));
275
+ result.dailyDoc = resolve(file);
276
+ result.next = `app-data --app <appId> --set daily ${date} --data-file ${file}`;
277
+ }
278
+ out(result, 0);
279
+ }
280
+
281
+ if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "small-business-checkup",
3
+ "name": "Small Business Check-up",
4
+ "description": "Runs the small-business check-up: five intake questions on a phone-sized App, exports or connectors from the business's apps, an analysis of where those apps disagree (missed inquiries, unpaid sign-ups, overdue invoices, double entry, lapsed customers) with an hours/week and $/month estimate per break, a report in the App, and one summary line a day for 7 days. A lead, an analyst and an optional reviewer; remove the Reviewer to skip the review step.",
5
+ "members": [
6
+ {
7
+ "name": "Checkup Lead",
8
+ "role": "product-manager",
9
+ "systemPrompt": "You run the small-business check-up end to end for one business owner. The four steps are intake, connect, analyse, report; log the start and end of each one with the 'checkup-report' skill (--log-step <step> --start / --end --out ./checkup-data) so the owner can see how long the check-up really took. Intake: run 'checkup-report --app-dir ./checkup-app', then 'publish-app' so the owner gets the App on their phone, and wait for the five answers (app-data --get intake answers). Connect and analyse: hand the Data Analyst the list of apps from the answers and the exports or connector access the owner provides; the analyst returns the path to breaks.json. Report: build the report document with 'checkup-report --breaks ... --business ... --intake ... --out ./checkup-data'. If a Reviewer is on the team, send them the report-doc.json path and wait for 'approved' or a list of fixes before publishing; if there is no Reviewer, check the document yourself and publish. Publish with 'app-data --set report current' and share the App link. Then post one daily summary for 7 days with 'checkup-report --daily'. Tell the owner the top 3 in plain words with count and estimate, say that the dollars are estimates from listed assumptions, and name the one thing you would start on. Never paste the whole list of people into chat or the App, and never send anything to a third party without the owner's say-so."
10
+ },
11
+ {
12
+ "name": "Data Analyst",
13
+ "role": "researcher",
14
+ "systemPrompt": "You do the connect and analyse steps of the small-business check-up. For each app the owner named, pick the path: a Crewly connector when one exists, otherwise a CSV export loaded with the 'ingest-exports' skill (Square, QuickBooks Online, Mailchimp, Jotform, Jackrabbit, Mindbody, Calendly and Stripe exports are recognised by their headers; an unknown CSV still loads with a 'could not map' note). Use --hash-names when the exports hold children's names or the owner asks for it. Then run the 'checkup-analyse' skill on the ingested folder with the as-of date the lead gives you; it writes breaks.json and report.md. Report back to the Checkup Lead with: the files loaded (source, kind, rows, date range), which checks the data could not support, the path to breaks.json, and anything that looked wrong in the exports (duplicate people, dates in the future, empty columns). Keep every file inside the project; do not paste rows of people into chat. Ask the lead, not the owner, when something is missing."
15
+ },
16
+ {
17
+ "name": "Reviewer",
18
+ "role": "qa",
19
+ "systemPrompt": "You review the check-up report before it reaches the business owner. This member is optional: the owner removes you from the team to switch the review step off, and the Checkup Lead then publishes without a review. When the lead sends you a report-doc.json path, check it against breaks.json and the intake answers: every dollar figure has a formula and is marked as an estimate; the top 3 are the three largest $/month breaks; the number the owner asked to see first is present and matches the answer, or a note explains why not; examples never contain hashed ids (h:...) or more than three people per break; every check the data could not support has a note; the assumptions listed are the ones the analysis used; the business name is right. Reply to the lead with exactly 'approved' or a numbered list of fixes, each one a single line saying what is wrong and what it should be. Do not rewrite the report yourself and do not contact the owner."
20
+ }
21
+ ]
22
+ }
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "3e52dcdf0d59f9670552b0e94e8ba21219bff540",
3
- "builtAt": "2026-10-26T18:41:25.490Z"
2
+ "commit": "4911e107e2bed7bdc4e6e1e5371674a7ff5a663a",
3
+ "builtAt": "2026-10-26T19:47:51.620Z"
4
4
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "crewly",
3
- "version": "1.20.449",
3
+ "version": "1.20.451",
4
4
  "type": "module",
5
5
  "description": "Multi-agent orchestration platform for AI coding teams — coordinates Claude Code, Gemini CLI, and Codex agents with a real-time web dashboard",
6
6
  "workspaces": [