crewly 1.20.448 → 1.20.449
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/config/skills/agent/checkup-analyse/SKILL.md +99 -0
- package/config/skills/agent/checkup-analyse/analyse.mjs +603 -0
- package/config/skills/agent/checkup-analyse/execute.sh +34 -0
- package/config/skills/agent/checkup-analyse/execute.test.sh +126 -0
- package/config/skills/agent/ingest-exports/execute.sh +1 -1
- package/dist/backend/build-info.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: Check-up Analysis
|
|
3
|
+
description: The analysis step of the small-business check-up. Reads what ingest-exports wrote, links the same person across every file, follows inquiry → sign-up → payment → repeat and lists where it breaks (unanswered inquiries, sign-ups with no payment, payments from nobody on the roster, drop-outs, lapsed customers, overdue invoices, double entry, customers missing from the mailing list), each with an hours/week and $/month estimate whose formula is shown. Writes breaks.json and report.md.
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
category: analysis
|
|
6
|
+
skillType: claude-skill
|
|
7
|
+
assignableRoles:
|
|
8
|
+
- "*"
|
|
9
|
+
triggers:
|
|
10
|
+
- check-up analysis
|
|
11
|
+
- analyse check-up
|
|
12
|
+
- find the breaks
|
|
13
|
+
- where does the business leak
|
|
14
|
+
- checkup report
|
|
15
|
+
- top 3 breaks
|
|
16
|
+
tags:
|
|
17
|
+
- check-up
|
|
18
|
+
- small-business
|
|
19
|
+
- analysis
|
|
20
|
+
- report
|
|
21
|
+
execution:
|
|
22
|
+
type: script
|
|
23
|
+
script:
|
|
24
|
+
file: execute.sh
|
|
25
|
+
interpreter: bash
|
|
26
|
+
timeoutMs: 120000
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
# Check-up Analysis
|
|
30
|
+
|
|
31
|
+
Step 3 of the small-business check-up (crewly#1062). After `ingest-exports`
|
|
32
|
+
has turned the owner's CSV exports into one schema, this skill reads that
|
|
33
|
+
folder and says where the chain **inquiry → sign-up → payment → repeat**
|
|
34
|
+
breaks between the apps, and what each break costs.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bash execute.sh --in "$CREWLY_HOME/checkup/<customer>/ingest" --out "$CREWLY_HOME/checkup/<customer>/analysis"
|
|
38
|
+
bash execute.sh --in …/ingest --as-of 2026-10-31 --assume hourlyValue=40 --assume lapsedDays=60
|
|
39
|
+
bash execute.sh '{"in":"…/ingest","out":"…/analysis","asOf":"2026-10-31","assume":{"winBackRate":0.1}}'
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`--in` is the `--out` folder of `ingest-exports` (or single `<file>.json`
|
|
43
|
+
files from it). `--as-of` defaults to the last dated activity in the data;
|
|
44
|
+
pass today's date when the exports are fresh so overdue and lapsed are
|
|
45
|
+
measured against now.
|
|
46
|
+
|
|
47
|
+
## The breaks
|
|
48
|
+
|
|
49
|
+
| type | what it means |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `inquiry_no_follow_through` | a form submission (Jotform) from someone with no sign-up and no payment at any time |
|
|
52
|
+
| `signed_up_unpaid` | a sign-up (Jackrabbit, Mindbody, Calendly) with no payment from that person on or after it (7-day grace before) |
|
|
53
|
+
| `paid_not_listed` | a payment from someone on no roster (only checked when a roster export is present) |
|
|
54
|
+
| `dropped` | a sign-up marked dropped / cancelled |
|
|
55
|
+
| `lapsed` | a customer with no activity for `lapsedDays` (90) before as-of |
|
|
56
|
+
| `overdue_invoices` | an invoice with a balance whose due date is before as-of |
|
|
57
|
+
| `double_entry` | the same person (by email, then phone) in two or more systems; notes a phone missing in one or differing names on contact rows |
|
|
58
|
+
| `not_on_mailing_list` | a paying or signed-up customer the mailing list (Mailchimp) does not have (only when a list export is present) |
|
|
59
|
+
|
|
60
|
+
People are linked by email first, then phone, then name when a row has
|
|
61
|
+
neither. Hashed exports (`ingest-exports --hash-names`) link the same way,
|
|
62
|
+
so the counts are identical and no name appears in the output.
|
|
63
|
+
|
|
64
|
+
## Estimates
|
|
65
|
+
|
|
66
|
+
Every break carries `estimate: { hoursPerWeek, dollarsPerMonth, formula,
|
|
67
|
+
isEstimate: true }`. The formula is written out with the numbers used, e.g.
|
|
68
|
+
`$180 in unpaid fees ÷ 1 months = $180/month; 1 × 10 min ÷ 3.3 weeks = 0.1
|
|
69
|
+
h/week`. The inputs are assumptions the owner can change with
|
|
70
|
+
`--assume key=value`:
|
|
71
|
+
|
|
72
|
+
- `hourlyValue` 30 — what an hour of the owner's or staff's time is worth
|
|
73
|
+
- `minutesPerManualEntry` 4, `minutesPerFollowUp` 10
|
|
74
|
+
- `inquiryConversionRate` 0.3, `winBackRate` 0.2, `listReachRate` 0.1
|
|
75
|
+
- `lapsedDays` 90
|
|
76
|
+
- `avgCustomerValue` — default: the mean paid amount / sign-up fee in the data
|
|
77
|
+
|
|
78
|
+
Hours and dollars are spread over the period the data covers (first activity
|
|
79
|
+
to as-of), so a one-off $180 over one month reads as $180/month.
|
|
80
|
+
|
|
81
|
+
## What comes out
|
|
82
|
+
|
|
83
|
+
One JSON line (per-break count and estimate, `top3`, `totals`, `notes` for
|
|
84
|
+
checks the data could not support) and, with `--out`:
|
|
85
|
+
|
|
86
|
+
- `breaks.json` — everything, including every item (person label, dates,
|
|
87
|
+
amounts, systems, conflicts) and the assumptions used
|
|
88
|
+
- `report.md` — the owner-facing text: top 3 breaks with count, estimate,
|
|
89
|
+
formula and what an agent could do for each, then every break, the files,
|
|
90
|
+
and the assumptions
|
|
91
|
+
|
|
92
|
+
## Report back
|
|
93
|
+
|
|
94
|
+
Give the owner the top 3 in plain words with the count and the estimate, and
|
|
95
|
+
say that the dollars are estimates from the assumptions listed. Name people
|
|
96
|
+
only when the owner needs to act on them and the exports were not hashed;
|
|
97
|
+
otherwise report counts. Do not paste `breaks.json`. If `notes` says a check
|
|
98
|
+
could not run (no roster export, no mailing-list export), say which export
|
|
99
|
+
would make it possible.
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// checkup-analyse — the analysis step of the small-business check-up
|
|
4
|
+
// (crewly#1062). Reads the common-schema records that `ingest-exports` wrote,
|
|
5
|
+
// links the same person across every file, follows inquiry → sign-up →
|
|
6
|
+
// payment → repeat, and lists where that chain breaks:
|
|
7
|
+
//
|
|
8
|
+
// inquiry_no_follow_through a form submission with no sign-up or payment after it
|
|
9
|
+
// signed_up_unpaid a sign-up with no payment from that person
|
|
10
|
+
// paid_not_listed a payment from someone on no roster
|
|
11
|
+
// dropped a sign-up marked dropped / cancelled
|
|
12
|
+
// lapsed a customer with no activity for N days
|
|
13
|
+
// overdue_invoices an invoice past due with a balance
|
|
14
|
+
// double_entry the same person keyed into two or more systems
|
|
15
|
+
// not_on_mailing_list a customer the mailing list does not have
|
|
16
|
+
//
|
|
17
|
+
// Every break carries an hours/week and $/month estimate with the formula
|
|
18
|
+
// written out and marked as an estimate. No model calls, no dependencies.
|
|
19
|
+
//
|
|
20
|
+
// node analyse.mjs --in <ingest out folder> [--out <folder>] [--as-of YYYY-MM-DD]
|
|
21
|
+
// [--assume hourlyValue=40] [--assume lapsedDays=60] ...
|
|
22
|
+
// =============================================================================
|
|
23
|
+
import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
|
|
24
|
+
import { join, basename, resolve } from 'node:path';
|
|
25
|
+
|
|
26
|
+
const SCHEMA_VERSION = '1.0';
|
|
27
|
+
const DAY_MS = 86_400_000;
|
|
28
|
+
|
|
29
|
+
/** Sources whose "booking" rows are inquiries (someone asked), not sign-ups. */
|
|
30
|
+
const INQUIRY_SOURCES = new Set(['jotform']);
|
|
31
|
+
/** Sources that are a mailing list rather than a customer record. */
|
|
32
|
+
const MAILING_SOURCES = new Set(['mailchimp']);
|
|
33
|
+
const DROPPED_STATUSES = new Set(['dropped', 'cancelled', 'canceled', 'withdrawn']);
|
|
34
|
+
const FAILED_PAYMENT_STATUSES = new Set(['failed', 'declined', 'voided', 'void', 'pending']);
|
|
35
|
+
|
|
36
|
+
/** Every number in an estimate comes from one of these; the owner can change any of them. */
|
|
37
|
+
export const DEFAULT_ASSUMPTIONS = {
|
|
38
|
+
hourlyValue: 30, // $ an hour of the owner's or staff's time is worth
|
|
39
|
+
minutesPerManualEntry: 4, // minutes to key one person into one more system
|
|
40
|
+
minutesPerFollowUp: 10, // minutes to chase one inquiry, unpaid sign-up or invoice
|
|
41
|
+
inquiryConversionRate: 0.3, // share of followed-up inquiries that become customers
|
|
42
|
+
winBackRate: 0.2, // share of dropped or lapsed customers a nudge brings back
|
|
43
|
+
listReachRate: 0.1, // share of customers a mailing list brings back for another purchase
|
|
44
|
+
lapsedDays: 90, // days without activity before a customer counts as lapsed
|
|
45
|
+
avgCustomerValue: null, // $ per customer; null = mean paid amount / sign-up fee in the data
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const BREAK_DEFS = {
|
|
49
|
+
inquiry_no_follow_through: {
|
|
50
|
+
label: 'Inquiries with no sign-up or payment after them',
|
|
51
|
+
agent: 'Reply to each open inquiry within the hour, offer the next free slot, and remind once after three days.',
|
|
52
|
+
},
|
|
53
|
+
signed_up_unpaid: {
|
|
54
|
+
label: 'Sign-ups with no payment',
|
|
55
|
+
agent: 'Send a payment link the day of sign-up and a reminder before the first session; flag anyone still unpaid to the owner.',
|
|
56
|
+
},
|
|
57
|
+
paid_not_listed: {
|
|
58
|
+
label: 'Payments from people on no roster',
|
|
59
|
+
agent: 'Match each payment to a booking or add the person to the roster, and ask the owner about the ones that match nothing.',
|
|
60
|
+
},
|
|
61
|
+
dropped: {
|
|
62
|
+
label: 'Sign-ups that were dropped or cancelled',
|
|
63
|
+
agent: 'Ask each one why in one short message, offer a make-up or the next session, and report the reasons back.',
|
|
64
|
+
},
|
|
65
|
+
lapsed: {
|
|
66
|
+
label: 'Customers with no activity for a while',
|
|
67
|
+
agent: 'Send a personal "we have a spot for you" note with the next dates, in batches, and track who comes back.',
|
|
68
|
+
},
|
|
69
|
+
overdue_invoices: {
|
|
70
|
+
label: 'Invoices past due with a balance',
|
|
71
|
+
agent: 'Send one friendly reminder per overdue invoice with the amount and a payment link, then a second after a week.',
|
|
72
|
+
},
|
|
73
|
+
double_entry: {
|
|
74
|
+
label: 'The same person keyed into two or more systems',
|
|
75
|
+
agent: 'Keep one system as the source of truth and copy new people into the others each day, so nothing is typed twice.',
|
|
76
|
+
},
|
|
77
|
+
not_on_mailing_list: {
|
|
78
|
+
label: 'Customers the mailing list does not have',
|
|
79
|
+
agent: 'Add each paying customer to the list (with consent) so the next class or offer reaches them.',
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// --- helpers ----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function isHash(v) {
|
|
86
|
+
return typeof v === 'string' && v.startsWith('h:');
|
|
87
|
+
}
|
|
88
|
+
function normEmail(v) {
|
|
89
|
+
if (!v) return '';
|
|
90
|
+
const s = String(v).trim();
|
|
91
|
+
return isHash(s) ? s : s.toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
function normPhone(v) {
|
|
94
|
+
if (!v) return '';
|
|
95
|
+
const s = String(v).trim();
|
|
96
|
+
if (isHash(s)) return s;
|
|
97
|
+
const d = s.replace(/\D/g, '');
|
|
98
|
+
if (d.length < 7) return '';
|
|
99
|
+
return d.length === 11 && d.startsWith('1') ? d.slice(1) : d;
|
|
100
|
+
}
|
|
101
|
+
function normName(v) {
|
|
102
|
+
if (!v) return '';
|
|
103
|
+
const s = String(v).trim();
|
|
104
|
+
return isHash(s) ? s : s.toLowerCase().replace(/\s+/g, ' ');
|
|
105
|
+
}
|
|
106
|
+
function dayOf(iso) {
|
|
107
|
+
return typeof iso === 'string' && iso.length >= 10 ? iso.slice(0, 10) : '';
|
|
108
|
+
}
|
|
109
|
+
function ms(iso) {
|
|
110
|
+
const d = dayOf(iso);
|
|
111
|
+
return d ? Date.parse(`${d}T00:00:00Z`) : NaN;
|
|
112
|
+
}
|
|
113
|
+
function round1(n) {
|
|
114
|
+
return Math.round(n * 10) / 10;
|
|
115
|
+
}
|
|
116
|
+
function money(n) {
|
|
117
|
+
return Math.round(n);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- people -----------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
class People {
|
|
123
|
+
constructor() {
|
|
124
|
+
this.persons = [];
|
|
125
|
+
this.keyToPerson = new Map();
|
|
126
|
+
}
|
|
127
|
+
/** Register a record under every identity it carries; merge persons the record joins. */
|
|
128
|
+
add(rec) {
|
|
129
|
+
const keys = [];
|
|
130
|
+
const email = normEmail(rec.email);
|
|
131
|
+
const phone = normPhone(rec.phone);
|
|
132
|
+
const name = normName(rec.name);
|
|
133
|
+
if (email) keys.push(`e:${email}`);
|
|
134
|
+
if (phone) keys.push(`p:${phone}`);
|
|
135
|
+
if (!email && !phone && name) keys.push(`n:${name}`);
|
|
136
|
+
if (keys.length === 0) return null;
|
|
137
|
+
const found = [...new Set(keys.map((k) => this.keyToPerson.get(k)).filter(Boolean))];
|
|
138
|
+
let person = found[0];
|
|
139
|
+
if (!person) {
|
|
140
|
+
person = { id: this.persons.length + 1, names: new Set(), emails: new Set(), phones: new Set(), sources: new Set(), records: [] };
|
|
141
|
+
this.persons.push(person);
|
|
142
|
+
}
|
|
143
|
+
for (const other of found.slice(1)) this.merge(person, other);
|
|
144
|
+
for (const k of keys) this.keyToPerson.set(k, person);
|
|
145
|
+
if (name) person.names.add(String(rec.name).trim());
|
|
146
|
+
if (email) person.emails.add(email);
|
|
147
|
+
if (phone) person.phones.add(phone);
|
|
148
|
+
person.sources.add(rec.source || 'unknown');
|
|
149
|
+
person.records.push(rec);
|
|
150
|
+
return person;
|
|
151
|
+
}
|
|
152
|
+
merge(into, from) {
|
|
153
|
+
for (const n of from.names) into.names.add(n);
|
|
154
|
+
for (const e of from.emails) into.emails.add(e);
|
|
155
|
+
for (const p of from.phones) into.phones.add(p);
|
|
156
|
+
for (const s of from.sources) into.sources.add(s);
|
|
157
|
+
into.records.push(...from.records);
|
|
158
|
+
for (const [k, p] of this.keyToPerson) if (p === from) this.keyToPerson.set(k, into);
|
|
159
|
+
from.merged = true;
|
|
160
|
+
}
|
|
161
|
+
live() {
|
|
162
|
+
return this.persons.filter((p) => !p.merged);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The name to show for a person: the adult on a contact or payment row before a child's name on a booking row. */
|
|
167
|
+
function label(person) {
|
|
168
|
+
const adult = person.records.find((r) => (r.kind === 'contact' || r.kind === 'payment') && r.name);
|
|
169
|
+
return (adult && String(adult.name).trim()) || [...person.names][0] || [...person.emails][0] || [...person.phones][0] || `person-${person.id}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- events -----------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
/** Turn a common-schema record into what happened: inquiry, signup, dropped, payment, refund, invoice, listed, contact. */
|
|
175
|
+
export function classify(rec) {
|
|
176
|
+
const status = String(rec.status || '').toLowerCase();
|
|
177
|
+
switch (rec.kind) {
|
|
178
|
+
case 'payment':
|
|
179
|
+
if (status === 'refunded') return 'refund';
|
|
180
|
+
if (FAILED_PAYMENT_STATUSES.has(status)) return 'failed_payment';
|
|
181
|
+
return 'payment';
|
|
182
|
+
case 'invoice':
|
|
183
|
+
return 'invoice';
|
|
184
|
+
case 'booking':
|
|
185
|
+
if (INQUIRY_SOURCES.has(rec.source)) return 'inquiry';
|
|
186
|
+
return DROPPED_STATUSES.has(status) ? 'dropped' : 'signup';
|
|
187
|
+
case 'contact':
|
|
188
|
+
return MAILING_SOURCES.has(rec.source) ? 'listed' : 'contact';
|
|
189
|
+
default:
|
|
190
|
+
return 'other';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// --- loading ----------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
function loadRecords(inputs) {
|
|
197
|
+
const files = [];
|
|
198
|
+
const problems = [];
|
|
199
|
+
for (const input of inputs) {
|
|
200
|
+
const p = resolve(input);
|
|
201
|
+
if (!existsSync(p)) {
|
|
202
|
+
problems.push(`${input}: not found`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (statSync(p).isDirectory()) {
|
|
206
|
+
for (const f of readdirSync(p).sort()) {
|
|
207
|
+
if (f.endsWith('.json') && f !== 'summary.json') files.push(join(p, f));
|
|
208
|
+
}
|
|
209
|
+
} else if (p.endsWith('.json')) {
|
|
210
|
+
files.push(p);
|
|
211
|
+
} else {
|
|
212
|
+
problems.push(`${input}: not an ingest-exports JSON file (run ingest-exports first)`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const records = [];
|
|
216
|
+
const sources = [];
|
|
217
|
+
for (const f of files) {
|
|
218
|
+
try {
|
|
219
|
+
const data = JSON.parse(readFileSync(f, 'utf8'));
|
|
220
|
+
if (!Array.isArray(data.records)) {
|
|
221
|
+
problems.push(`${basename(f)}: no records array`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
sources.push({ file: data.file || basename(f), source: data.source || 'unknown', kind: data.kind || 'other', rows: data.records.length });
|
|
225
|
+
records.push(...data.records.map((r) => ({ ...r, event: classify(r) })));
|
|
226
|
+
} catch (e) {
|
|
227
|
+
problems.push(`${basename(f)}: ${e.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return { records, sources, problems };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- analysis ---------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
function parseAssumptions(pairs) {
|
|
236
|
+
const a = { ...DEFAULT_ASSUMPTIONS };
|
|
237
|
+
for (const pair of pairs) {
|
|
238
|
+
const [k, v] = String(pair).split('=');
|
|
239
|
+
if (!(k in DEFAULT_ASSUMPTIONS)) throw new Error(`unknown assumption "${k}" (known: ${Object.keys(DEFAULT_ASSUMPTIONS).join(', ')})`);
|
|
240
|
+
const n = Number(v);
|
|
241
|
+
if (!Number.isFinite(n)) throw new Error(`assumption ${k} needs a number, got "${v}"`);
|
|
242
|
+
a[k] = n;
|
|
243
|
+
}
|
|
244
|
+
return a;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function analyse(records, opts = {}) {
|
|
248
|
+
const a = { ...DEFAULT_ASSUMPTIONS, ...(opts.assumptions || {}) };
|
|
249
|
+
const notes = [];
|
|
250
|
+
const people = new People();
|
|
251
|
+
const anonymous = {};
|
|
252
|
+
let ignored = 0;
|
|
253
|
+
|
|
254
|
+
for (const rec of records) {
|
|
255
|
+
if (rec.event === 'other') {
|
|
256
|
+
ignored += 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
if (!people.add(rec)) anonymous[rec.event] = (anonymous[rec.event] || 0) + 1;
|
|
260
|
+
}
|
|
261
|
+
const persons = people.live();
|
|
262
|
+
const used = records.filter((r) => r.event !== 'other');
|
|
263
|
+
const systems = [...new Set(used.map((r) => r.source || 'unknown'))].sort();
|
|
264
|
+
const hasSignupSystem = records.some((r) => r.event === 'signup' || r.event === 'dropped');
|
|
265
|
+
const hasMailingList = records.some((r) => r.event === 'listed');
|
|
266
|
+
|
|
267
|
+
// Period: first activity → as-of (default: the last activity in the data). Activity means
|
|
268
|
+
// something happened (an inquiry, sign-up, payment, invoice), not when a contact was created.
|
|
269
|
+
const ACTIVITY = new Set(['inquiry', 'signup', 'dropped', 'payment', 'refund', 'failed_payment', 'invoice']);
|
|
270
|
+
const ats = used.filter((r) => ACTIVITY.has(r.event)).map((r) => ms(r.at)).filter(Number.isFinite);
|
|
271
|
+
const firstMs = ats.length ? Math.min(...ats) : NaN;
|
|
272
|
+
const lastMs = ats.length ? Math.max(...ats) : NaN;
|
|
273
|
+
const asOfMs = opts.asOf ? ms(opts.asOf) : lastMs;
|
|
274
|
+
if (!Number.isFinite(asOfMs)) throw new Error('no dated records and no --as-of; nothing to analyse');
|
|
275
|
+
const asOf = new Date(asOfMs).toISOString().slice(0, 10);
|
|
276
|
+
const days = Number.isFinite(firstMs) ? Math.max(1, Math.round((asOfMs - firstMs) / DAY_MS)) : 1;
|
|
277
|
+
const weeks = Math.max(1, days / 7);
|
|
278
|
+
const months = Math.max(1, days / 30.44);
|
|
279
|
+
const period = { from: Number.isFinite(firstMs) ? new Date(firstMs).toISOString().slice(0, 10) : asOf, to: asOf, days, weeks: round1(weeks), months: round1(months) };
|
|
280
|
+
|
|
281
|
+
// Average customer value: mean of completed payments and sign-up fees unless assumed.
|
|
282
|
+
let avgCustomerValue = a.avgCustomerValue;
|
|
283
|
+
let avgSource = 'assumed';
|
|
284
|
+
if (avgCustomerValue == null) {
|
|
285
|
+
const amounts = records.filter((r) => (r.event === 'payment' || r.event === 'signup') && Number(r.amount) > 0).map((r) => Number(r.amount));
|
|
286
|
+
if (amounts.length) {
|
|
287
|
+
avgCustomerValue = Math.round(amounts.reduce((s, n) => s + n, 0) / amounts.length);
|
|
288
|
+
avgSource = `mean of ${amounts.length} paid amounts and sign-up fees in the data`;
|
|
289
|
+
} else {
|
|
290
|
+
avgCustomerValue = 50;
|
|
291
|
+
avgSource = 'default (no paid amounts in the data)';
|
|
292
|
+
notes.push('No paid amounts or sign-up fees in the data; $ estimates use a $50 customer value.');
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const hoursWeek = (count, minutes) => round1((count * minutes) / 60 / weeks);
|
|
296
|
+
const dollarsMonthFromHours = (h) => money(h * 4.345 * a.hourlyValue);
|
|
297
|
+
|
|
298
|
+
const evs = (p, ...types) => p.records.filter((r) => types.includes(r.event));
|
|
299
|
+
const breaks = [];
|
|
300
|
+
|
|
301
|
+
// 1. Inquiry with no follow-through
|
|
302
|
+
{
|
|
303
|
+
const items = [];
|
|
304
|
+
for (const p of persons) {
|
|
305
|
+
const inquiries = evs(p, 'inquiry');
|
|
306
|
+
if (!inquiries.length) continue;
|
|
307
|
+
if (evs(p, 'signup', 'dropped', 'payment').length) continue;
|
|
308
|
+
const first = inquiries.sort((x, y) => String(x.at).localeCompare(String(y.at)))[0];
|
|
309
|
+
items.push({ person: label(p), inquiredAt: dayOf(first.at), item: first.item || null, source: first.source });
|
|
310
|
+
}
|
|
311
|
+
const n = items.length;
|
|
312
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
313
|
+
const d = money((n * a.inquiryConversionRate * avgCustomerValue) / months);
|
|
314
|
+
breaks.push(mkBreak('inquiry_no_follow_through', items, h, d, `${n} inquiries × ${a.inquiryConversionRate} conversion × $${avgCustomerValue} customer value ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min follow-up ÷ ${round1(weeks)} weeks = ${h} h/week`));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 2. Signed up but unpaid
|
|
318
|
+
{
|
|
319
|
+
const items = [];
|
|
320
|
+
for (const p of persons) {
|
|
321
|
+
const payments = evs(p, 'payment');
|
|
322
|
+
for (const s of evs(p, 'signup')) {
|
|
323
|
+
const since = ms(s.at) - 7 * DAY_MS;
|
|
324
|
+
const paid = payments.some((pay) => !Number.isFinite(since) || !Number.isFinite(ms(pay.at)) || ms(pay.at) >= since);
|
|
325
|
+
if (paid) continue;
|
|
326
|
+
items.push({ person: label(p), item: s.item || null, signedUpAt: dayOf(s.at), fee: Number(s.amount) || 0, source: s.source });
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
const n = items.length;
|
|
330
|
+
const outstanding = money(items.reduce((s, i) => s + i.fee, 0));
|
|
331
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
332
|
+
const d = money(outstanding / months);
|
|
333
|
+
breaks.push(mkBreak('signed_up_unpaid', items, h, d, `$${outstanding} in unpaid fees ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`, { outstanding }));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 3. Paid but on no roster (only meaningful when a roster system is in the data)
|
|
337
|
+
{
|
|
338
|
+
const items = [];
|
|
339
|
+
if (hasSignupSystem) {
|
|
340
|
+
for (const p of persons) {
|
|
341
|
+
const payments = evs(p, 'payment');
|
|
342
|
+
if (!payments.length || evs(p, 'signup', 'dropped').length) continue;
|
|
343
|
+
for (const pay of payments) items.push({ person: label(p), paidAt: dayOf(pay.at), amount: Number(pay.amount) || 0, item: pay.item || null, source: pay.source });
|
|
344
|
+
}
|
|
345
|
+
} else {
|
|
346
|
+
notes.push('No roster or booking export in the data, so "paid but not listed" could not be checked.');
|
|
347
|
+
}
|
|
348
|
+
const n = items.length;
|
|
349
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
350
|
+
breaks.push(mkBreak('paid_not_listed', items, h, 0, `${n} payments × ${a.minutesPerFollowUp} min to reconcile ÷ ${round1(weeks)} weeks = ${h} h/week; no $ figure (the money arrived; the risk is a person who shows up unlisted)`));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// 4. Dropped sign-ups
|
|
354
|
+
{
|
|
355
|
+
const items = [];
|
|
356
|
+
for (const p of persons) {
|
|
357
|
+
for (const s of evs(p, 'dropped')) items.push({ person: label(p), item: s.item || null, signedUpAt: dayOf(s.at), droppedAt: dayOf(s.endAt) || null, fee: Number(s.amount) || 0, source: s.source });
|
|
358
|
+
}
|
|
359
|
+
const n = items.length;
|
|
360
|
+
const fees = money(items.reduce((s, i) => s + i.fee, 0));
|
|
361
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
362
|
+
const d = money((fees * a.winBackRate) / months);
|
|
363
|
+
breaks.push(mkBreak('dropped', items, h, d, `$${fees} in dropped fees × ${a.winBackRate} win-back ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 5. Lapsed customers
|
|
367
|
+
{
|
|
368
|
+
const items = [];
|
|
369
|
+
const cutoff = asOfMs - a.lapsedDays * DAY_MS;
|
|
370
|
+
for (const p of persons) {
|
|
371
|
+
const customerEvents = evs(p, 'payment', 'signup', 'dropped');
|
|
372
|
+
if (!customerEvents.length) continue;
|
|
373
|
+
const stamps = p.records.flatMap((r) => [ms(r.at), ms(r.endAt)]).filter(Number.isFinite);
|
|
374
|
+
if (!stamps.length) continue;
|
|
375
|
+
const last = Math.max(...stamps);
|
|
376
|
+
if (last >= cutoff) continue;
|
|
377
|
+
const totalPaid = money(evs(p, 'payment').reduce((s, r) => s + (Number(r.amount) || 0), 0));
|
|
378
|
+
items.push({ person: label(p), lastActivity: new Date(last).toISOString().slice(0, 10), daysSince: Math.round((asOfMs - last) / DAY_MS), totalPaid });
|
|
379
|
+
}
|
|
380
|
+
const n = items.length;
|
|
381
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
382
|
+
const d = money((n * a.winBackRate * avgCustomerValue) / months);
|
|
383
|
+
breaks.push(mkBreak('lapsed', items, h, d, `${n} lapsed (no activity for ${a.lapsedDays} days) × ${a.winBackRate} win-back × $${avgCustomerValue} ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// 6. Overdue invoices
|
|
387
|
+
{
|
|
388
|
+
const items = [];
|
|
389
|
+
let openNotDue = 0;
|
|
390
|
+
for (const r of records) {
|
|
391
|
+
if (r.event !== 'invoice' || !(Number(r.balance) > 0)) continue;
|
|
392
|
+
const due = ms(r.dueAt);
|
|
393
|
+
if (Number.isFinite(due) && due < asOfMs) {
|
|
394
|
+
items.push({ customer: r.name || null, number: r.id || null, dueAt: dayOf(r.dueAt), daysOverdue: Math.round((asOfMs - due) / DAY_MS), balance: Number(r.balance), source: r.source });
|
|
395
|
+
} else {
|
|
396
|
+
openNotDue += 1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
const n = items.length;
|
|
400
|
+
const outstanding = money(items.reduce((s, i) => s + i.balance, 0));
|
|
401
|
+
const h = hoursWeek(n, a.minutesPerFollowUp);
|
|
402
|
+
const d = money(outstanding / months);
|
|
403
|
+
breaks.push(mkBreak('overdue_invoices', items, h, d, `$${outstanding} overdue ÷ ${round1(months)} months = $${d}/month; ${n} × ${a.minutesPerFollowUp} min ÷ ${round1(weeks)} weeks = ${h} h/week`, { outstanding, openNotYetDue: openNotDue }));
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 7. Double entry
|
|
407
|
+
{
|
|
408
|
+
const items = [];
|
|
409
|
+
let extraEntries = 0;
|
|
410
|
+
for (const p of persons) {
|
|
411
|
+
if (p.sources.size < 2) continue;
|
|
412
|
+
const conflicts = [];
|
|
413
|
+
const withPhone = new Set(p.records.filter((r) => normPhone(r.phone)).map((r) => r.source));
|
|
414
|
+
const withoutPhone = [...p.sources].filter((s) => !withPhone.has(s) && p.records.some((r) => r.source === s && r.kind === 'contact'));
|
|
415
|
+
if (withPhone.size && withoutPhone.length) conflicts.push(`phone missing in ${withoutPhone.join(', ')}`);
|
|
416
|
+
// Only names on contact rows are compared: a booking row carries the child's name, which is not a conflict.
|
|
417
|
+
const contactNames = [...new Set(p.records.filter((r) => r.kind === 'contact' && r.name).map((r) => String(r.name).trim()))];
|
|
418
|
+
if (new Set(contactNames.map(normName)).size > 1) conflicts.push(`names differ: ${contactNames.join(' / ')}`);
|
|
419
|
+
extraEntries += p.sources.size - 1;
|
|
420
|
+
items.push({ person: label(p), systems: [...p.sources].sort(), conflicts });
|
|
421
|
+
}
|
|
422
|
+
const n = items.length;
|
|
423
|
+
const h = round1((extraEntries * a.minutesPerManualEntry) / 60 / weeks);
|
|
424
|
+
const d = dollarsMonthFromHours(h);
|
|
425
|
+
breaks.push(mkBreak('double_entry', items, h, d, `${extraEntries} extra entries × ${a.minutesPerManualEntry} min ÷ ${round1(weeks)} weeks = ${h} h/week; × 4.345 weeks × $${a.hourlyValue}/h = $${d}/month`, { extraEntries, people: n }));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// 8. Customers not on the mailing list (only when a mailing list is in the data)
|
|
429
|
+
{
|
|
430
|
+
const items = [];
|
|
431
|
+
if (hasMailingList) {
|
|
432
|
+
for (const p of persons) {
|
|
433
|
+
if (!evs(p, 'payment', 'signup', 'dropped').length || evs(p, 'listed').length) continue;
|
|
434
|
+
items.push({ person: label(p) });
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
notes.push('No mailing-list export in the data, so "not on the mailing list" could not be checked.');
|
|
438
|
+
}
|
|
439
|
+
const n = items.length;
|
|
440
|
+
const d = money((n * a.listReachRate * avgCustomerValue) / months);
|
|
441
|
+
breaks.push(mkBreak('not_on_mailing_list', items, 0, d, `${n} customers × ${a.listReachRate} reach × $${avgCustomerValue} ÷ ${round1(months)} months = $${d}/month; adding them is a one-off`));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const ranked = breaks.filter((b) => b.count > 0).sort((x, y) => y.estimate.dollarsPerMonth - x.estimate.dollarsPerMonth || y.estimate.hoursPerWeek - x.estimate.hoursPerWeek);
|
|
445
|
+
const refunds = records.filter((r) => r.event === 'refund');
|
|
446
|
+
const stats = {
|
|
447
|
+
records: records.length,
|
|
448
|
+
ignoredOtherRows: ignored,
|
|
449
|
+
people: persons.length,
|
|
450
|
+
systems,
|
|
451
|
+
byEvent: countBy(records, (r) => r.event),
|
|
452
|
+
anonymousRows: anonymous,
|
|
453
|
+
refunds: { count: refunds.length, amount: money(refunds.reduce((s, r) => s + (Number(r.amount) || 0), 0)) },
|
|
454
|
+
avgCustomerValue,
|
|
455
|
+
avgCustomerValueFrom: avgSource,
|
|
456
|
+
};
|
|
457
|
+
const totals = {
|
|
458
|
+
hoursPerWeek: round1(breaks.reduce((s, b) => s + b.estimate.hoursPerWeek, 0)),
|
|
459
|
+
dollarsPerMonth: money(breaks.reduce((s, b) => s + b.estimate.dollarsPerMonth, 0)),
|
|
460
|
+
};
|
|
461
|
+
return { schemaVersion: SCHEMA_VERSION, asOf, period, assumptions: { ...a, avgCustomerValue }, stats, breaks, top3: ranked.slice(0, 3).map((b) => b.type), totals, notes };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function mkBreak(type, items, hoursPerWeek, dollarsPerMonth, formula, extra = {}) {
|
|
465
|
+
return {
|
|
466
|
+
type,
|
|
467
|
+
label: BREAK_DEFS[type].label,
|
|
468
|
+
count: items.length,
|
|
469
|
+
items,
|
|
470
|
+
estimate: { hoursPerWeek, dollarsPerMonth, ...extra, formula, isEstimate: true },
|
|
471
|
+
whatAnAgentCouldDo: BREAK_DEFS[type].agent,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function countBy(list, fn) {
|
|
476
|
+
const out = {};
|
|
477
|
+
for (const x of list) {
|
|
478
|
+
const k = fn(x);
|
|
479
|
+
out[k] = (out[k] || 0) + 1;
|
|
480
|
+
}
|
|
481
|
+
return out;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// --- report -----------------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
export function renderReport(result, sources) {
|
|
487
|
+
const L = [];
|
|
488
|
+
L.push('# Check-up: where the apps disagree');
|
|
489
|
+
L.push('');
|
|
490
|
+
L.push(`As of ${result.asOf}. Data covers ${result.period.from} to ${result.period.to} (${result.period.days} days) across ${result.stats.systems.join(', ')}: ${result.stats.records} rows, ${result.stats.people} people.`);
|
|
491
|
+
L.push('');
|
|
492
|
+
L.push(`Every hour and dollar figure below is an **estimate** from the assumptions at the end; change an assumption and the numbers follow. Together the breaks come to about **${result.totals.hoursPerWeek} h/week** and **$${result.totals.dollarsPerMonth}/month**.`);
|
|
493
|
+
L.push('');
|
|
494
|
+
L.push('## Top 3');
|
|
495
|
+
L.push('');
|
|
496
|
+
const byType = Object.fromEntries(result.breaks.map((b) => [b.type, b]));
|
|
497
|
+
if (!result.top3.length) L.push('No breaks found in this data.');
|
|
498
|
+
result.top3.forEach((t, i) => {
|
|
499
|
+
const b = byType[t];
|
|
500
|
+
L.push(`### ${i + 1}. ${b.label} — ${b.count}`);
|
|
501
|
+
L.push('');
|
|
502
|
+
L.push(`Estimate: ${b.estimate.hoursPerWeek} h/week, $${b.estimate.dollarsPerMonth}/month.`);
|
|
503
|
+
L.push(`How: ${b.estimate.formula}.`);
|
|
504
|
+
L.push(`What an agent could do: ${b.whatAnAgentCouldDo}`);
|
|
505
|
+
L.push('');
|
|
506
|
+
});
|
|
507
|
+
L.push('## Every break');
|
|
508
|
+
L.push('');
|
|
509
|
+
L.push('| Break | Count | h/week | $/month |');
|
|
510
|
+
L.push('|---|---:|---:|---:|');
|
|
511
|
+
for (const b of result.breaks) L.push(`| ${b.label} | ${b.count} | ${b.estimate.hoursPerWeek} | ${b.estimate.dollarsPerMonth} |`);
|
|
512
|
+
L.push('');
|
|
513
|
+
if (result.notes.length) {
|
|
514
|
+
L.push('## Could not check');
|
|
515
|
+
L.push('');
|
|
516
|
+
for (const n of result.notes) L.push(`- ${n}`);
|
|
517
|
+
L.push('');
|
|
518
|
+
}
|
|
519
|
+
L.push('## Files');
|
|
520
|
+
L.push('');
|
|
521
|
+
for (const s of sources) L.push(`- ${s.file}: ${s.source} ${s.kind}, ${s.rows} rows`);
|
|
522
|
+
L.push('');
|
|
523
|
+
L.push('## Assumptions');
|
|
524
|
+
L.push('');
|
|
525
|
+
for (const [k, v] of Object.entries(result.assumptions)) L.push(`- ${k}: ${v}${k === 'avgCustomerValue' ? ` (${result.stats.avgCustomerValueFrom})` : ''}`);
|
|
526
|
+
L.push('');
|
|
527
|
+
return L.join('\n');
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// --- CLI --------------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
function usage() {
|
|
533
|
+
process.stdout.write(
|
|
534
|
+
JSON.stringify({ success: false, error: 'usage: analyse.mjs --in <ingest-exports out folder or <file>.json> [--out <folder>] [--as-of YYYY-MM-DD] [--assume key=value ...]' }) + '\n',
|
|
535
|
+
);
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function main(argv) {
|
|
540
|
+
const inputs = [];
|
|
541
|
+
const assume = [];
|
|
542
|
+
let out = null;
|
|
543
|
+
let asOf = null;
|
|
544
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
545
|
+
const arg = argv[i];
|
|
546
|
+
if (arg === '--in') inputs.push(argv[++i]);
|
|
547
|
+
else if (arg === '--out') out = argv[++i];
|
|
548
|
+
else if (arg === '--as-of') asOf = argv[++i];
|
|
549
|
+
else if (arg === '--assume') assume.push(argv[++i]);
|
|
550
|
+
else if (arg === '--help' || arg === '-h') usage();
|
|
551
|
+
else usage();
|
|
552
|
+
}
|
|
553
|
+
if (!inputs.length || inputs.some((x) => !x)) usage();
|
|
554
|
+
if (asOf && !/^\d{4}-\d{2}-\d{2}$/.test(asOf)) {
|
|
555
|
+
process.stdout.write(JSON.stringify({ success: false, error: `--as-of must be YYYY-MM-DD, got "${asOf}"` }) + '\n');
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
let assumptions;
|
|
559
|
+
try {
|
|
560
|
+
assumptions = parseAssumptions(assume);
|
|
561
|
+
} catch (e) {
|
|
562
|
+
process.stdout.write(JSON.stringify({ success: false, error: e.message }) + '\n');
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const { records, sources, problems } = loadRecords(inputs);
|
|
567
|
+
if (!records.length) {
|
|
568
|
+
process.stdout.write(JSON.stringify({ success: false, error: 'no records found', problems }) + '\n');
|
|
569
|
+
process.exit(2);
|
|
570
|
+
}
|
|
571
|
+
let result;
|
|
572
|
+
try {
|
|
573
|
+
result = analyse(records, { asOf, assumptions });
|
|
574
|
+
} catch (e) {
|
|
575
|
+
process.stdout.write(JSON.stringify({ success: false, error: e.message, problems }) + '\n');
|
|
576
|
+
process.exit(2);
|
|
577
|
+
}
|
|
578
|
+
result.files = sources;
|
|
579
|
+
result.problems = problems;
|
|
580
|
+
|
|
581
|
+
if (out) {
|
|
582
|
+
mkdirSync(out, { recursive: true });
|
|
583
|
+
writeFileSync(join(out, 'breaks.json'), JSON.stringify(result, null, 2));
|
|
584
|
+
writeFileSync(join(out, 'report.md'), renderReport(result, sources));
|
|
585
|
+
}
|
|
586
|
+
const summary = {
|
|
587
|
+
success: problems.length === 0,
|
|
588
|
+
asOf: result.asOf,
|
|
589
|
+
period: result.period,
|
|
590
|
+
people: result.stats.people,
|
|
591
|
+
systems: result.stats.systems,
|
|
592
|
+
breaks: result.breaks.map((b) => ({ type: b.type, count: b.count, hoursPerWeek: b.estimate.hoursPerWeek, dollarsPerMonth: b.estimate.dollarsPerMonth })),
|
|
593
|
+
top3: result.top3,
|
|
594
|
+
totals: result.totals,
|
|
595
|
+
notes: result.notes,
|
|
596
|
+
problems,
|
|
597
|
+
out: out ? resolve(out) : null,
|
|
598
|
+
};
|
|
599
|
+
process.stdout.write(JSON.stringify(summary) + '\n');
|
|
600
|
+
process.exit(problems.length ? 2 : 0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (import.meta.url === `file://${process.argv[1]}`) main(process.argv.slice(2));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# checkup-analyse — the analysis step of the small-business check-up
|
|
4
|
+
# (crewly#1062): read what ingest-exports wrote, link people across files,
|
|
5
|
+
# follow inquiry → sign-up → payment → repeat and list where it breaks, with
|
|
6
|
+
# an hours/week and $/month estimate (formula shown) per break.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# bash execute.sh --in /tmp/checkup/ingest --out /tmp/checkup/analysis
|
|
10
|
+
# bash execute.sh --in /tmp/checkup/ingest --as-of 2026-10-31 --assume hourlyValue=40
|
|
11
|
+
# bash execute.sh '{"in":"/tmp/checkup/ingest","out":"/tmp/checkup/analysis","asOf":"2026-10-31","assume":{"lapsedDays":60}}'
|
|
12
|
+
#
|
|
13
|
+
# Output: one JSON line (per-break counts and estimates, the top 3, totals,
|
|
14
|
+
# notes) and, with --out, breaks.json (every item) + report.md.
|
|
15
|
+
# Exit 0 = analysed, 2 = nothing to analyse or a file could not be read, 1 = usage.
|
|
16
|
+
# =============================================================================
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
ANALYSE="${SCRIPT_DIR}/analyse.mjs"
|
|
20
|
+
|
|
21
|
+
command -v node >/dev/null 2>&1 || { printf '{"success":false,"error":"node is required but not on PATH"}\n'; exit 1; }
|
|
22
|
+
|
|
23
|
+
ARGS=()
|
|
24
|
+
if [[ $# -gt 0 && ${1:0:1} == '{' ]]; then
|
|
25
|
+
command -v jq >/dev/null 2>&1 || { printf '{"success":false,"error":"jq is required to read JSON input"}\n'; exit 1; }
|
|
26
|
+
while IFS= read -r p; do [ -n "$p" ] && ARGS+=(--in "$p"); done < <(printf '%s' "$1" | jq -r '(.in // .inputs // []) | if type=="string" then [.] else . end | .[]')
|
|
27
|
+
out=$(printf '%s' "$1" | jq -r '.out // empty'); [ -n "$out" ] && ARGS+=(--out "$out")
|
|
28
|
+
asof=$(printf '%s' "$1" | jq -r '.asOf // .as_of // empty'); [ -n "$asof" ] && ARGS+=(--as-of "$asof")
|
|
29
|
+
while IFS= read -r kv; do [ -n "$kv" ] && ARGS+=(--assume "$kv"); done < <(printf '%s' "$1" | jq -r '(.assume // .assumptions // {}) | to_entries[] | "\(.key)=\(.value)"')
|
|
30
|
+
shift
|
|
31
|
+
fi
|
|
32
|
+
ARGS+=("$@")
|
|
33
|
+
|
|
34
|
+
exec node "$ANALYSE" ${ARGS[@]+"${ARGS[@]}"}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Tests for checkup-analyse — run with: bash execute.test.sh
|
|
3
|
+
# Offline: runs ingest-exports on its fixtures first, then analyses the result. 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
|
+
FX="$HERE/../ingest-exports/fixtures"
|
|
9
|
+
PASS=0; FAIL=0
|
|
10
|
+
check() {
|
|
11
|
+
local name="$1"; local got="$2"; local want="$3"
|
|
12
|
+
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); echo "FAIL: $name"; echo " want: $want"; echo " got: $got"; fi
|
|
13
|
+
}
|
|
14
|
+
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
15
|
+
bash "$INGEST" --in "$FX" --out "$T/ingest" >/dev/null || { echo "ingest-exports failed; cannot test"; exit 1; }
|
|
16
|
+
bash "$INGEST" --in "$FX" --out "$T/ingest-hashed" --hash-names >/dev/null
|
|
17
|
+
|
|
18
|
+
# 1. Default as-of = last activity; period = activity span, not contact creation dates
|
|
19
|
+
OUT="$(bash "$EXEC" --in "$T/ingest" --out "$T/an")"; RC=$?
|
|
20
|
+
check "exit 0" "$RC" "0"
|
|
21
|
+
check "success" "$(printf '%s' "$OUT" | jq -r '.success')" "true"
|
|
22
|
+
check "as-of = last activity" "$(printf '%s' "$OUT" | jq -r '.asOf')" "2026-09-20"
|
|
23
|
+
check "period from first sign-up, not mailing-list opt-in" "$(printf '%s' "$OUT" | jq -r '.period.from')" "2026-08-28"
|
|
24
|
+
check "period days" "$(printf '%s' "$OUT" | jq -r '.period.days')" "23"
|
|
25
|
+
check "people linked across files" "$(printf '%s' "$OUT" | jq -r '.people')" "8"
|
|
26
|
+
check "systems exclude the unmapped file" "$(printf '%s' "$OUT" | jq -c '.systems')" '["jackrabbit","jotform","mailchimp","quickbooks","square"]'
|
|
27
|
+
check "breaks.json written" "$([ -f "$T/an/breaks.json" ] && echo yes || echo no)" "yes"
|
|
28
|
+
check "report.md written" "$([ -f "$T/an/report.md" ] && echo yes || echo no)" "yes"
|
|
29
|
+
cnt() { jq -r --arg t "$1" '.breaks[] | select(.type==$t) | .count' "$T/an/breaks.json"; }
|
|
30
|
+
item() { jq -r --arg t "$1" ".breaks[] | select(.type==\$t) | $2" "$T/an/breaks.json"; }
|
|
31
|
+
|
|
32
|
+
# 2. Each break finds exactly the planted case
|
|
33
|
+
check "inquiry with no follow-through = Tom only (Alex and Priya signed up)" "$(cnt inquiry_no_follow_through)" "1"
|
|
34
|
+
check " who" "$(item inquiry_no_follow_through '.items[0].person')" "Tom Baker"
|
|
35
|
+
check "signed up unpaid = Sam Rivera (Sofia's family paid on 09-02)" "$(cnt signed_up_unpaid)" "1"
|
|
36
|
+
check " who" "$(item signed_up_unpaid '.items[0].person')" "Sam Rivera"
|
|
37
|
+
check " fee" "$(item signed_up_unpaid '.items[0].fee')" "180"
|
|
38
|
+
check " outstanding" "$(item signed_up_unpaid '.estimate.outstanding')" "180"
|
|
39
|
+
check "paid but on no roster = Daniel (walk-in with no name is skipped)" "$(cnt paid_not_listed)" "1"
|
|
40
|
+
check " who" "$(item paid_not_listed '.items[0].person')" "Daniel Chen"
|
|
41
|
+
check "dropped = Anika" "$(cnt dropped)" "1"
|
|
42
|
+
check " dropped date from end date" "$(item dropped '.items[0].droppedAt')" "2026-09-22"
|
|
43
|
+
check "lapsed = none at default as-of" "$(cnt lapsed)" "0"
|
|
44
|
+
check "overdue invoices = none at default as-of (due 09-30 and 10-15)" "$(cnt overdue_invoices)" "0"
|
|
45
|
+
check " open not yet due" "$(item overdue_invoices '.estimate.openNotYetDue')" "2"
|
|
46
|
+
check "double entry = 4 people in 2+ systems" "$(cnt double_entry)" "4"
|
|
47
|
+
check " adult label, not the child's name" "$(item double_entry '.items[0].person')" "Maria Lopez"
|
|
48
|
+
check " family in three systems" "$(item double_entry '.items[0].systems | join(",")')" "jackrabbit,mailchimp,square"
|
|
49
|
+
check " child vs parent name is not a conflict" "$(item double_entry '.items[0].conflicts | length')" "0"
|
|
50
|
+
check " phone missing in mailchimp noted" "$(item double_entry '.items[3].conflicts[0]')" "phone missing in mailchimp"
|
|
51
|
+
check " extra entries (2+1+2+1)" "$(item double_entry '.estimate.extraEntries')" "6"
|
|
52
|
+
check "not on mailing list = 3 customers" "$(cnt not_on_mailing_list)" "3"
|
|
53
|
+
check " Maria is on it" "$(item not_on_mailing_list '[.items[].person] | index("Maria Lopez")')" "null"
|
|
54
|
+
check "anonymous walk-in counted" "$(jq -r '.stats.anonymousRows.payment' "$T/an/breaks.json")" "1"
|
|
55
|
+
check "unmapped rows ignored, not dropped silently" "$(jq -r '.stats.ignoredOtherRows' "$T/an/breaks.json")" "3"
|
|
56
|
+
check "refunds counted" "$(jq -c '.stats.refunds' "$T/an/breaks.json")" '{"count":1,"amount":49}'
|
|
57
|
+
|
|
58
|
+
# 3. Estimates carry a formula and are marked estimates; avg customer value comes from the data
|
|
59
|
+
check "avg customer value from data" "$(jq -r '.assumptions.avgCustomerValue' "$T/an/breaks.json")" "113"
|
|
60
|
+
check "avg source named" "$(jq -r '.stats.avgCustomerValueFrom' "$T/an/breaks.json" | cut -c1-7)" "mean of"
|
|
61
|
+
check "every estimate marked" "$(jq -r '[.breaks[].estimate.isEstimate] | all' "$T/an/breaks.json")" "true"
|
|
62
|
+
check "every estimate has a formula" "$(jq -r '[.breaks[].estimate.formula | length > 20] | all' "$T/an/breaks.json")" "true"
|
|
63
|
+
check "unpaid $/month = outstanding over 1 month" "$(item signed_up_unpaid '.estimate.dollarsPerMonth')" "180"
|
|
64
|
+
check "top 3 ranked by $/month" "$(jq -c '.top3' "$T/an/breaks.json")" '["signed_up_unpaid","dropped","inquiry_no_follow_through"]'
|
|
65
|
+
check "totals add up" "$(jq -r '.totals.dollarsPerMonth' "$T/an/breaks.json")" "$(jq -r '[.breaks[].estimate.dollarsPerMonth] | add' "$T/an/breaks.json")"
|
|
66
|
+
check "every break says what an agent could do" "$(jq -r '[.breaks[].whatAnAgentCouldDo | length > 20] | all' "$T/an/breaks.json")" "true"
|
|
67
|
+
|
|
68
|
+
# 4. report.md is the owner-facing text
|
|
69
|
+
check "report title" "$(head -1 "$T/an/report.md")" "# Check-up: where the apps disagree"
|
|
70
|
+
check "report has top 3" "$(grep -c '^### [123]\. ' "$T/an/report.md")" "3"
|
|
71
|
+
check "report says estimate" "$(grep -c -i 'estimate' "$T/an/report.md" | awk '{print ($1>0)?"yes":"no"}')" "yes"
|
|
72
|
+
check "report lists assumptions" "$(grep -c '^- hourlyValue: 30' "$T/an/report.md")" "1"
|
|
73
|
+
check "report lists files" "$(grep -c '^- square-transactions.csv: square payment, 4 rows' "$T/an/report.md")" "1"
|
|
74
|
+
|
|
75
|
+
# 5. --as-of moves overdue and lapsed; --assume changes the inputs
|
|
76
|
+
LATE="$(bash "$EXEC" --in "$T/ingest" --as-of 2026-10-26 --out "$T/late")"
|
|
77
|
+
check "as-of honoured" "$(printf '%s' "$LATE" | jq -r '.asOf')" "2026-10-26"
|
|
78
|
+
check "two invoices overdue on 10-26" "$(printf '%s' "$LATE" | jq -r '.breaks[] | select(.type=="overdue_invoices") | .count')" "2"
|
|
79
|
+
check " outstanding" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .estimate.outstanding' "$T/late/breaks.json")" "3391"
|
|
80
|
+
check " days overdue" "$(jq -r '.breaks[] | select(.type=="overdue_invoices") | .items[0].daysOverdue' "$T/late/breaks.json")" "26"
|
|
81
|
+
check " overdue is now top" "$(printf '%s' "$LATE" | jq -r '.top3[0]')" "overdue_invoices"
|
|
82
|
+
check "still nobody lapsed at 90 days" "$(printf '%s' "$LATE" | jq -r '.breaks[] | select(.type=="lapsed") | .count')" "0"
|
|
83
|
+
LAPSED="$(bash "$EXEC" --in "$T/ingest" --as-of 2026-10-26 --assume lapsedDays=30 --assume hourlyValue=60)"
|
|
84
|
+
check "lapsedDays=30 → 4 customers lapsed" "$(printf '%s' "$LAPSED" | jq -r '.breaks[] | select(.type=="lapsed") | .count')" "4"
|
|
85
|
+
FAR="$(bash "$EXEC" --in "$T/ingest" --as-of 2027-01-15 --out "$T/far")"
|
|
86
|
+
check "a year on, every customer is lapsed" "$(printf '%s' "$FAR" | jq -r '.breaks[] | select(.type=="lapsed") | .count')" "4"
|
|
87
|
+
check "lapsed item has days since" "$(jq -r '.breaks[] | select(.type=="lapsed") | .items[0].daysSince > 100' "$T/far/breaks.json")" "true"
|
|
88
|
+
check "hourlyValue assumption applied" "$(bash "$EXEC" --in "$T/ingest" --assume hourlyValue=60 --out "$T/hv" >/dev/null; jq -r '.assumptions.hourlyValue' "$T/hv/breaks.json")" "60"
|
|
89
|
+
|
|
90
|
+
# 6. Hashed input: same counts, no names anywhere
|
|
91
|
+
HOUT="$(bash "$EXEC" --in "$T/ingest-hashed" --out "$T/anh")"
|
|
92
|
+
check "hashed: same break counts" "$(printf '%s' "$HOUT" | jq -c '[.breaks[].count]')" "$(printf '%s' "$OUT" | jq -c '[.breaks[].count]')"
|
|
93
|
+
check "hashed: no plain names in breaks.json" "$(grep -c -E "Lopez|Rivera|Baker|Chen|Natarajan|example\.test|555 010" "$T/anh/breaks.json")" "0"
|
|
94
|
+
check "hashed: no plain names in report.md" "$(grep -c -E "Lopez|Rivera|Baker|Chen|Natarajan" "$T/anh/report.md")" "0"
|
|
95
|
+
check "hashed: person label is a hash" "$(jq -r '.breaks[] | select(.type=="signed_up_unpaid") | .items[0].person' "$T/anh/breaks.json" | cut -c1-2)" "h:"
|
|
96
|
+
|
|
97
|
+
# 7. Partial data: notes say which checks could not run
|
|
98
|
+
mkdir -p "$T/partial"; cp "$T/ingest/square-transactions.json" "$T/ingest/square-customers.json" "$T/partial/"
|
|
99
|
+
POUT="$(bash "$EXEC" --in "$T/partial")"
|
|
100
|
+
check "partial: exits 0" "$?" "0"
|
|
101
|
+
check "partial: roster check skipped with a note" "$(printf '%s' "$POUT" | jq -r '.notes | map(select(test("paid but not listed"))) | length')" "1"
|
|
102
|
+
check "partial: mailing-list check skipped with a note" "$(printf '%s' "$POUT" | jq -r '.notes | map(select(test("mailing list"))) | length')" "1"
|
|
103
|
+
check "partial: paid_not_listed stays 0" "$(printf '%s' "$POUT" | jq -r '.breaks[] | select(.type=="paid_not_listed") | .count')" "0"
|
|
104
|
+
check "single file input works" "$(bash "$EXEC" --in "$T/ingest/quickbooks-invoices.json" --as-of 2026-10-26 | jq -r '.breaks[] | select(.type=="overdue_invoices") | .count')" "2"
|
|
105
|
+
|
|
106
|
+
# 8. JSON input
|
|
107
|
+
JOUT="$(bash "$EXEC" "{\"in\":\"$T/ingest\",\"out\":\"$T/json\",\"asOf\":\"2026-10-26\",\"assume\":{\"lapsedDays\":30}}")"
|
|
108
|
+
check "json input: as-of" "$(printf '%s' "$JOUT" | jq -r '.asOf')" "2026-10-26"
|
|
109
|
+
check "json input: assumption" "$(jq -r '.assumptions.lapsedDays' "$T/json/breaks.json")" "30"
|
|
110
|
+
check "json input: out written" "$([ -f "$T/json/report.md" ] && echo yes || echo no)" "yes"
|
|
111
|
+
|
|
112
|
+
# 9. Errors
|
|
113
|
+
bash "$EXEC" --in "$T/nope" >/dev/null 2>&1; check "missing input exits 2" "$?" "2"
|
|
114
|
+
mkdir -p "$T/empty"; bash "$EXEC" --in "$T/empty" >/dev/null 2>&1; check "empty folder exits 2" "$?" "2"
|
|
115
|
+
bash "$EXEC" >/dev/null 2>&1; check "no args exits 1" "$?" "1"
|
|
116
|
+
bash "$EXEC" --in "$T/ingest" --as-of 10/26/2026 >/dev/null 2>&1; check "bad as-of exits 1" "$?" "1"
|
|
117
|
+
bash "$EXEC" --in "$T/ingest" --assume nope=3 >/dev/null 2>&1; check "unknown assumption exits 1" "$?" "1"
|
|
118
|
+
bash "$EXEC" --in "$T/ingest" --assume hourlyValue=lots >/dev/null 2>&1; check "non-number assumption exits 1" "$?" "1"
|
|
119
|
+
printf 'not json' > "$T/ingest/broken.json"
|
|
120
|
+
BOUT="$(bash "$EXEC" --in "$T/ingest")"; RC=$?
|
|
121
|
+
check "unreadable file → exit 2 but the rest is analysed" "$RC" "2"
|
|
122
|
+
check " problem named" "$(printf '%s' "$BOUT" | jq -r '.problems[0]' | cut -c1-11)" "broken.json"
|
|
123
|
+
check " breaks still computed" "$(printf '%s' "$BOUT" | jq -r '.people')" "8"
|
|
124
|
+
|
|
125
|
+
echo "checkup-analyse: $PASS passed, $FAIL failed"
|
|
126
|
+
[ "$FAIL" -eq 0 ]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "crewly",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.449",
|
|
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": [
|