crewly 1.20.448 → 1.20.450
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/checkup-report/SKILL.md +96 -0
- package/config/skills/agent/checkup-report/app/index.html +265 -0
- package/config/skills/agent/checkup-report/execute.sh +37 -0
- package/config/skills/agent/checkup-report/execute.test.sh +137 -0
- package/config/skills/agent/checkup-report/report.mjs +194 -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,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 ]
|
|
@@ -0,0 +1,96 @@
|
|
|
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), and writes daily-summary documents for app-data.
|
|
4
|
+
version: 1.0.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
|
+
tags:
|
|
16
|
+
- check-up
|
|
17
|
+
- small-business
|
|
18
|
+
- apps
|
|
19
|
+
- report
|
|
20
|
+
execution:
|
|
21
|
+
type: script
|
|
22
|
+
script:
|
|
23
|
+
file: execute.sh
|
|
24
|
+
interpreter: bash
|
|
25
|
+
timeoutMs: 60000
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
# Check-up Report
|
|
29
|
+
|
|
30
|
+
Steps 1 and 4 of the small-business check-up (crewly#1062), on one Crewly
|
|
31
|
+
App the owner opens on their phone: **Your answers** (five intake questions),
|
|
32
|
+
**Report** (the top 3 breaks with estimate, formula, examples and what an agent
|
|
33
|
+
could do; every break; files; assumptions) and **Daily** (a line a day for the
|
|
34
|
+
7 free days after the report). The page is built once; the data is per
|
|
35
|
+
business.
|
|
36
|
+
|
|
37
|
+
## 1. Intake — publish the App and get the five answers
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
bash execute.sh --app-dir ./checkup-app # copies the page into the project (publish-app needs a project path)
|
|
41
|
+
bash publish-app --dir ./checkup-app --name "<Business> check-up" --notify
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The owner lands on **Your answers** (no report yet): which apps, the slowest
|
|
45
|
+
weekly task, what they fear slipping through, how many apps one customer
|
|
46
|
+
passes through, which number they want to see first. Saving writes
|
|
47
|
+
`intake/answers` and sends you a message. Read it:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bash app-data --app <appId> --get intake answers > ./intake.json
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use `apps` to decide the connect path per app (connector, `ingest-exports`,
|
|
54
|
+
or the browser as last resort) and `firstNumber` for the report.
|
|
55
|
+
|
|
56
|
+
## 2. Connect and analyse
|
|
57
|
+
|
|
58
|
+
`ingest-exports` (exports → common schema) then `checkup-analyse`
|
|
59
|
+
(breaks + estimates → `breaks.json`). See those skills.
|
|
60
|
+
|
|
61
|
+
## 3. Report — put the result in the App
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
bash execute.sh --breaks <analysis>/breaks.json --business "<Business>" --intake ./intake.json --out ./checkup-data
|
|
65
|
+
bash app-data --app <appId> --set report current --data-file ./checkup-data/report-doc.json
|
|
66
|
+
bash publish-app --app <appId> --share # a fresh one-tap link to the owner
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`report-doc.json` holds the top 3, every break with its estimate and formula,
|
|
70
|
+
up to three short examples per break (none when the exports were hashed),
|
|
71
|
+
notes for checks the data could not support, the files and the assumptions.
|
|
72
|
+
When the intake said which number to see first, the report leads with it
|
|
73
|
+
(`firstNumber`): money owed (overdue invoices + unpaid sign-ups), missed
|
|
74
|
+
inquiries, hours lost to double entry, or lapsed customers. The whole list of
|
|
75
|
+
people never goes into the App; it stays in `breaks.json` on this machine.
|
|
76
|
+
|
|
77
|
+
## 4. Daily — one line a day for 7 days
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bash execute.sh --daily "Replied to 3 open inquiries; 1 booked. Sent 2 invoice reminders." --out ./checkup-data
|
|
81
|
+
bash app-data --app <appId> --set daily 2026-10-27 --data-file ./checkup-data/daily-2026-10-27.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Say what changed and what was done, in the owner's language, under 300
|
|
85
|
+
characters. Seven entries, then stop unless the owner asks for more.
|
|
86
|
+
|
|
87
|
+
## Time log
|
|
88
|
+
|
|
89
|
+
Record the start and end of each step (intake, connect, analyse, report) in
|
|
90
|
+
your work notes; the check-up is measured by how long it really takes.
|
|
91
|
+
|
|
92
|
+
## Report back
|
|
93
|
+
|
|
94
|
+
Tell the owner in plain words: the App is ready (the card has the link), the
|
|
95
|
+
top 3 with count and estimate, that the dollars are estimates from listed
|
|
96
|
+
assumptions, and the one thing you would start on. Do not paste the document.
|
|
@@ -0,0 +1,265 @@
|
|
|
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 · <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
|
+
<h2>Assumptions</h2>
|
|
72
|
+
<ul class="plain" id="assumptions" data-crewly-id="assumptions"></ul>
|
|
73
|
+
<button class="secondary" id="ask" data-crewly-id="ask">Ask about this report</button>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
|
|
77
|
+
<!-- ───────── Intake ───────── -->
|
|
78
|
+
<section id="intake" class="hidden" data-crewly-id="intake">
|
|
79
|
+
<p class="muted">Five questions, two minutes. Your answers tell the check-up where to look first.</p>
|
|
80
|
+
<label for="q-apps">1. Which apps does the business run on?</label>
|
|
81
|
+
<div class="chips" id="q-apps-chips" data-crewly-id="q-apps"></div>
|
|
82
|
+
<input id="q-apps" placeholder="Others, comma-separated">
|
|
83
|
+
<label for="q-task">2. Which weekly task takes the most time?</label>
|
|
84
|
+
<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>
|
|
85
|
+
<label>3. What do you fear slipping through?</label>
|
|
86
|
+
<div class="chips" id="q-fear-chips" data-crewly-id="q-fear"></div>
|
|
87
|
+
<input id="q-fear" placeholder="Anything else">
|
|
88
|
+
<label for="q-hops">4. How many apps does one customer pass through, from first contact to payment?</label>
|
|
89
|
+
<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>
|
|
90
|
+
<label for="q-first">5. Which number do you want to see first?</label>
|
|
91
|
+
<div class="chips" id="q-first-chips" data-crewly-id="q-first"></div>
|
|
92
|
+
<input id="q-first" placeholder="Or say it in your words">
|
|
93
|
+
<button id="save" data-crewly-id="save">Save answers</button>
|
|
94
|
+
<div id="msg" data-crewly-id="msg"></div>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<!-- ───────── Daily ───────── -->
|
|
98
|
+
<section id="daily" class="hidden" data-crewly-id="daily">
|
|
99
|
+
<p class="muted">A short summary each day for 7 days after the report: what changed, what was done.</p>
|
|
100
|
+
<ul class="plain" id="daily-list" data-crewly-id="daily-list"></ul>
|
|
101
|
+
<p class="muted hidden" id="daily-empty">Nothing yet.</p>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<script>
|
|
105
|
+
// Old Safari (iOS 12) has no replaceChildren; the owner's family iPad must still render this.
|
|
106
|
+
if (!Element.prototype.replaceChildren) {
|
|
107
|
+
Element.prototype.replaceChildren = function () {
|
|
108
|
+
while (this.firstChild) this.removeChild(this.firstChild);
|
|
109
|
+
for (var i = 0; i < arguments.length; i += 1) this.appendChild(arguments[i]);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
var $ = function (id) { return document.getElementById(id); };
|
|
113
|
+
var APP_CHIPS = ['Square', 'Stripe', 'QuickBooks', 'Mailchimp', 'Jotform', 'Jackrabbit', 'Mindbody', 'Calendly', 'HubSpot', 'Google Sheets', 'Gmail'];
|
|
114
|
+
var FEAR_CHIPS = ['Unanswered inquiries', 'Signed up but never paid', 'Customers who stopped coming', 'Unpaid invoices', 'Typing the same person twice'];
|
|
115
|
+
var FIRST_CHIPS = ['Money owed to me', 'Inquiries I missed', 'Hours lost to double entry', 'Customers who lapsed'];
|
|
116
|
+
var state = { report: null, intake: null, tab: 'report' };
|
|
117
|
+
|
|
118
|
+
function text(el, s) { el.textContent = s == null ? '' : String(s); }
|
|
119
|
+
function el(tag, cls, s) { var e = document.createElement(tag); if (cls) e.className = cls; if (s != null) e.textContent = String(s); return e; }
|
|
120
|
+
function money(n) { return '$' + Math.round(Number(n) || 0).toLocaleString(); }
|
|
121
|
+
function chips(container, options, picked) {
|
|
122
|
+
container.replaceChildren();
|
|
123
|
+
options.forEach(function (opt) {
|
|
124
|
+
var c = el('span', 'chip' + (picked.indexOf(opt) >= 0 ? ' on' : ''), opt);
|
|
125
|
+
c.setAttribute('role', 'button');
|
|
126
|
+
c.onclick = function () { c.classList.toggle('on'); };
|
|
127
|
+
container.appendChild(c);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function pickedChips(container) {
|
|
131
|
+
return Array.prototype.map.call(container.querySelectorAll('.chip.on'), function (c) { return c.textContent; });
|
|
132
|
+
}
|
|
133
|
+
function splitList(s) { return String(s || '').split(',').map(function (x) { return x.trim(); }).filter(Boolean); }
|
|
134
|
+
|
|
135
|
+
// ── tabs ──
|
|
136
|
+
function showTab(name) {
|
|
137
|
+
state.tab = name;
|
|
138
|
+
['report', 'intake', 'daily'].forEach(function (t) {
|
|
139
|
+
$(t).classList.toggle('hidden', t !== name);
|
|
140
|
+
$('tab-' + t).classList.toggle('on', t === name);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
$('tab-report').onclick = function () { showTab('report'); };
|
|
144
|
+
$('tab-intake').onclick = function () { showTab('intake'); };
|
|
145
|
+
$('tab-daily').onclick = function () { showTab('daily'); };
|
|
146
|
+
|
|
147
|
+
// ── report ──
|
|
148
|
+
function renderReport() {
|
|
149
|
+
var r = state.report;
|
|
150
|
+
var hasReport = r && Array.isArray(r.breaks) && r.breaks.length;
|
|
151
|
+
$('report-empty').classList.toggle('hidden', !!hasReport);
|
|
152
|
+
$('report-body').classList.toggle('hidden', !hasReport);
|
|
153
|
+
text($('title'), (r && r.business) ? r.business + ' check-up' : 'Business check-up');
|
|
154
|
+
if (!hasReport) { text($('subtitle'), 'The report is not ready yet.'); return; }
|
|
155
|
+
text($('subtitle'), 'Where your apps disagree, and what it costs.');
|
|
156
|
+
text($('asof'), 'As of ' + r.asOf + ' · data ' + r.period.from + ' to ' + r.period.to + ' · ' + r.stats.people + ' people across ' + r.stats.systems.join(', '));
|
|
157
|
+
text($('total-hours'), r.totals.hoursPerWeek);
|
|
158
|
+
text($('total-dollars'), money(r.totals.dollarsPerMonth));
|
|
159
|
+
|
|
160
|
+
var fn = $('first-number');
|
|
161
|
+
if (r.firstNumber) {
|
|
162
|
+
fn.classList.remove('hidden'); fn.replaceChildren();
|
|
163
|
+
fn.appendChild(el('div', 'muted', 'The number you asked to see first'));
|
|
164
|
+
fn.appendChild(el('div', 'num', r.firstNumber.value));
|
|
165
|
+
fn.appendChild(el('div', 'note', r.firstNumber.label));
|
|
166
|
+
} else { fn.classList.add('hidden'); }
|
|
167
|
+
|
|
168
|
+
var byType = {}; r.breaks.forEach(function (b) { byType[b.type] = b; });
|
|
169
|
+
var top = $('top3'); top.replaceChildren();
|
|
170
|
+
(r.top3 || []).forEach(function (t, i) {
|
|
171
|
+
var b = byType[t]; if (!b) return;
|
|
172
|
+
var card = el('div', 'card'); card.setAttribute('data-crewly-id', 'top-' + (i + 1));
|
|
173
|
+
var h = el('h3'); h.appendChild(el('span', 'rank', i + 1)); h.appendChild(document.createTextNode(b.label + ' — ' + b.count)); card.appendChild(h);
|
|
174
|
+
card.appendChild(el('p', 'est', b.estimate.hoursPerWeek + ' h/week · ' + money(b.estimate.dollarsPerMonth) + '/month (estimate)'));
|
|
175
|
+
card.appendChild(el('div', 'formula', b.estimate.formula));
|
|
176
|
+
if (b.examples && b.examples.length) {
|
|
177
|
+
var ul = el('ul', 'plain'); b.examples.forEach(function (x) { ul.appendChild(el('li', 'note', x)); }); card.appendChild(ul);
|
|
178
|
+
}
|
|
179
|
+
card.appendChild(el('div', 'agent', 'What an agent could do: ' + b.whatAnAgentCouldDo));
|
|
180
|
+
top.appendChild(card);
|
|
181
|
+
});
|
|
182
|
+
if (!top.children.length) top.appendChild(el('p', 'muted', 'No breaks found in this data.'));
|
|
183
|
+
|
|
184
|
+
var rows = $('all-rows'); rows.replaceChildren();
|
|
185
|
+
r.breaks.forEach(function (b) {
|
|
186
|
+
var tr = el('tr'); tr.appendChild(el('td', null, b.label)); tr.appendChild(el('td', 'r', b.count));
|
|
187
|
+
tr.appendChild(el('td', 'r', b.estimate.hoursPerWeek)); tr.appendChild(el('td', 'r', money(b.estimate.dollarsPerMonth))); rows.appendChild(tr);
|
|
188
|
+
});
|
|
189
|
+
var notes = $('notes'); notes.replaceChildren();
|
|
190
|
+
(r.notes || []).forEach(function (n) { notes.appendChild(el('li', null, n)); });
|
|
191
|
+
$('notes-wrap').classList.toggle('hidden', !(r.notes && r.notes.length));
|
|
192
|
+
var files = $('files'); files.replaceChildren();
|
|
193
|
+
(r.files || []).forEach(function (f) { files.appendChild(el('li', null, f.file + ' — ' + f.source + ' ' + f.kind + ', ' + f.rows + ' rows')); });
|
|
194
|
+
var as = $('assumptions'); as.replaceChildren();
|
|
195
|
+
Object.keys(r.assumptions || {}).forEach(function (k) { as.appendChild(el('li', null, k + ': ' + r.assumptions[k])); });
|
|
196
|
+
}
|
|
197
|
+
$('ask').onclick = function () {
|
|
198
|
+
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 () {
|
|
199
|
+
text($('ask'), 'Sent — your agent will reply in chat'); $('ask').disabled = true;
|
|
200
|
+
}).catch(function (e) { text($('ask'), 'Could not send: ' + e.message); });
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ── intake ──
|
|
204
|
+
function renderIntake() {
|
|
205
|
+
var a = state.intake || {};
|
|
206
|
+
chips($('q-apps-chips'), APP_CHIPS, a.apps || []);
|
|
207
|
+
$('q-apps').value = (a.apps || []).filter(function (x) { return APP_CHIPS.indexOf(x) < 0; }).join(', ');
|
|
208
|
+
$('q-task').value = a.slowestTask || '';
|
|
209
|
+
chips($('q-fear-chips'), FEAR_CHIPS, a.fears || []);
|
|
210
|
+
$('q-fear').value = (a.fears || []).filter(function (x) { return FEAR_CHIPS.indexOf(x) < 0; }).join(', ');
|
|
211
|
+
$('q-hops').value = a.appsPerCustomer || '';
|
|
212
|
+
chips($('q-first-chips'), FIRST_CHIPS, a.firstNumber ? [a.firstNumber] : []);
|
|
213
|
+
$('q-first').value = (a.firstNumber && FIRST_CHIPS.indexOf(a.firstNumber) < 0) ? a.firstNumber : '';
|
|
214
|
+
if (a.savedAt) { $('msg').className = 'ok'; text($('msg'), 'Saved ' + a.savedAt.slice(0, 16).replace('T', ' ')); }
|
|
215
|
+
}
|
|
216
|
+
$('save').onclick = function () {
|
|
217
|
+
var first = pickedChips($('q-first-chips'))[0] || $('q-first').value.trim() || null;
|
|
218
|
+
var answers = {
|
|
219
|
+
apps: pickedChips($('q-apps-chips')).concat(splitList($('q-apps').value)),
|
|
220
|
+
slowestTask: $('q-task').value.trim(),
|
|
221
|
+
fears: pickedChips($('q-fear-chips')).concat(splitList($('q-fear').value)),
|
|
222
|
+
appsPerCustomer: $('q-hops').value,
|
|
223
|
+
firstNumber: first,
|
|
224
|
+
savedAt: new Date().toISOString()
|
|
225
|
+
};
|
|
226
|
+
if (!answers.apps.length && !answers.slowestTask) { $('msg').className = ''; text($('msg'), 'Tell us at least which apps you use.'); return; }
|
|
227
|
+
$('save').disabled = true;
|
|
228
|
+
crewly.db.set('intake', 'answers', answers).then(function () {
|
|
229
|
+
state.intake = answers; $('msg').className = 'ok'; text($('msg'), 'Saved. Your agent will take it from here.'); $('save').disabled = false;
|
|
230
|
+
return crewly.notify('The owner saved their check-up answers (intake/answers). Read them with app-data and start the connect step.');
|
|
231
|
+
}).catch(function (e) { $('msg').className = ''; text($('msg'), 'Could not save: ' + e.message); $('save').disabled = false; });
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// ── daily ──
|
|
235
|
+
function renderDaily(docs) {
|
|
236
|
+
var list = $('daily-list'); list.replaceChildren();
|
|
237
|
+
docs.sort(function (a, b) { return String(b.data.date).localeCompare(String(a.data.date)); });
|
|
238
|
+
docs.forEach(function (d) {
|
|
239
|
+
var li = el('li'); li.appendChild(el('div', 'muted', d.data.date)); li.appendChild(el('div', null, d.data.summary)); list.appendChild(li);
|
|
240
|
+
});
|
|
241
|
+
$('daily-empty').classList.toggle('hidden', docs.length > 0);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── load ──
|
|
245
|
+
function notFoundAsNull(e) { if (e && e.code === 'not_found') return null; throw e; }
|
|
246
|
+
function loadAll() {
|
|
247
|
+
return Promise.all([
|
|
248
|
+
crewly.db.get('report', 'current').then(function (d) { return d.data; }, notFoundAsNull),
|
|
249
|
+
crewly.db.get('intake', 'answers').then(function (d) { return d.data; }, notFoundAsNull),
|
|
250
|
+
crewly.db.list('daily', { limit: 30 }).then(function (r) { return r.docs; })
|
|
251
|
+
]).then(function (res) {
|
|
252
|
+
state.report = res[0]; state.intake = res[1];
|
|
253
|
+
renderReport(); renderIntake(); renderDaily(res[2]);
|
|
254
|
+
if (!state.report && !state.intake) showTab('intake');
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
crewly.ready.then(function () {
|
|
258
|
+
return loadAll().then(function () {
|
|
259
|
+
crewly.db.subscribe('report', function () { loadAll().catch(fail); });
|
|
260
|
+
crewly.db.subscribe('daily', function () { loadAll().catch(fail); });
|
|
261
|
+
});
|
|
262
|
+
}).catch(fail);
|
|
263
|
+
function fail(e) { text($('subtitle'), 'Could not load: ' + (e && e.message ? e.message : e)); }
|
|
264
|
+
</script>
|
|
265
|
+
</body></html>
|
|
@@ -0,0 +1,37 @@
|
|
|
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 '{"breaks":"…/breaks.json","business":"Oak Pottery","out":"./checkup-data"}'
|
|
13
|
+
#
|
|
14
|
+
# Output: one JSON line with the files written and the exact next command
|
|
15
|
+
# (publish-app or app-data). Exit 0 = done, 2 = an input could not be read, 1 = usage.
|
|
16
|
+
# =============================================================================
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
REPORT="${SCRIPT_DIR}/report.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
|
+
for key in appDir breaks business intake examples out daily date; do
|
|
27
|
+
v=$(printf '%s' "$1" | jq -r --arg k "$key" '.[$k] // empty')
|
|
28
|
+
if [ -n "$v" ]; then
|
|
29
|
+
case "$key" in appDir) flag=--app-dir ;; *) flag="--$key" ;; esac
|
|
30
|
+
ARGS+=("$flag" "$v")
|
|
31
|
+
fi
|
|
32
|
+
done
|
|
33
|
+
shift
|
|
34
|
+
fi
|
|
35
|
+
ARGS+=("$@")
|
|
36
|
+
|
|
37
|
+
exec node "$REPORT" ${ARGS[@]+"${ARGS[@]}"}
|