@hailer/mcp 1.3.14 → 1.3.23
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/.claude/skills/create-and-publish-app/SKILL.md +44 -56
- package/.claude/skills/hailer-app-builder/SKILL.md +414 -970
- package/.claude/skills/hailer-app-primitives/SKILL.md +742 -0
- package/.claude/skills/hailer-apps-pictures/SKILL.md +191 -87
- package/.claude/skills/hailer-design-patterns/SKILL.md +317 -0
- package/.claude/skills/hailer-design-system/SKILL.md +202 -149
- package/.claude/skills/hailer-workflow-archetypes/SKILL.md +301 -0
- package/.claude/skills/insight-join-patterns/SKILL.md +313 -0
- package/.claude/skills/publish-hailer-app/SKILL.md +211 -0
- package/.claude/skills/sdk-activity-patterns/SKILL.md +257 -105
- package/.claude/skills/sdk-function-fields/SKILL.md +253 -492
- package/.claude/skills/sdk-insight-calculations/SKILL.md +364 -0
- package/.claude/skills/sdk-insight-queries/SKILL.md +192 -397
- package/.claude/skills/sdk-ws-config-skill/SKILL.md +265 -720
- package/.claude/skills/tool-response-verification/SKILL.md +143 -0
- package/CLAUDE.md +50 -19
- package/dist/bot/bot.d.ts.map +1 -1
- package/dist/bot/bot.js +26 -34
- package/dist/bot/bot.js.map +1 -1
- package/dist/bot/services/helper-prompt.d.ts +1 -1
- package/dist/bot/services/helper-prompt.d.ts.map +1 -1
- package/dist/bot/services/helper-prompt.js +62 -82
- package/dist/bot/services/helper-prompt.js.map +1 -1
- package/dist/bot/services/system-prompt.js +4 -4
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/mcp/hailer-rpc.d.ts +1 -0
- package/dist/mcp/hailer-rpc.d.ts.map +1 -1
- package/dist/mcp/hailer-rpc.js +4 -0
- package/dist/mcp/hailer-rpc.js.map +1 -1
- package/dist/mcp/tools/activity.d.ts.map +1 -1
- package/dist/mcp/tools/activity.js +8 -2
- package/dist/mcp/tools/activity.js.map +1 -1
- package/dist/mcp/tools/app-core.d.ts.map +1 -1
- package/dist/mcp/tools/app-core.js +6 -3
- package/dist/mcp/tools/app-core.js.map +1 -1
- package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
- package/dist/mcp/tools/app-marketplace.js +5 -1
- package/dist/mcp/tools/app-marketplace.js.map +1 -1
- package/dist/mcp/webhook-handler.d.ts.map +1 -1
- package/dist/mcp/webhook-handler.js +27 -0
- package/dist/mcp/webhook-handler.js.map +1 -1
- package/dist/public-chat/graduate.d.ts.map +1 -1
- package/dist/public-chat/graduate.js +153 -53
- package/dist/public-chat/graduate.js.map +1 -1
- package/dist/public-chat/rate-limit.js +1 -1
- package/dist/public-chat/rate-limit.js.map +1 -1
- package/dist/public-chat/studio-prewarm.js +2 -2
- package/dist/public-chat/studio-prewarm.js.map +1 -1
- package/dist/public-chat/system-prompt.d.ts.map +1 -1
- package/dist/public-chat/system-prompt.js +41 -25
- package/dist/public-chat/system-prompt.js.map +1 -1
- package/package.json +1 -1
- package/.claude/skills/hailer-project-protocol/SKILL.md +0 -398
- package/.opencode/package-lock.json +0 -117
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sdk-insight-calculations
|
|
3
|
+
description: Named calculation recipes for Hailer insights — weighted sums, CASE bucketing, conditional counts, percentage-of-total, conversion rates, date truncation, age-since, top-N, cross-join for zero-activity rows, and Finnish DST offset. Load when designing insights that do more than plain SUM/COUNT/GROUP BY. Use alongside sdk-insight-queries (SQL mechanics) and insight-join-patterns (cross-workflow JOINs).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Insight Calculation Recipes
|
|
7
|
+
|
|
8
|
+
A library of named calculation patterns you can drop into insight SQL. Each recipe includes when to use it, a working query, and common mistakes to avoid.
|
|
9
|
+
|
|
10
|
+
**Always validate** with `mcp__hailer__run_insight` before committing. Many recipes depend on function field materialization or SQL dialect quirks only visible with real rows.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Recipe 1 — Weighted Sum by Category
|
|
15
|
+
|
|
16
|
+
**When to use:** Forecast a pipeline by phase probability, or any metric where each row's contribution depends on a category column.
|
|
17
|
+
|
|
18
|
+
```sql
|
|
19
|
+
SELECT
|
|
20
|
+
COALESCE(SUM(d.value * CASE d.phaseId
|
|
21
|
+
WHEN '${Deals_PhaseIds.new_abc}' THEN 0.10
|
|
22
|
+
WHEN '${Deals_PhaseIds.qualified_def}' THEN 0.25
|
|
23
|
+
WHEN '${Deals_PhaseIds.proposal_ghi}' THEN 0.50
|
|
24
|
+
WHEN '${Deals_PhaseIds.negotiation_jkl}' THEN 0.75
|
|
25
|
+
WHEN '${Deals_PhaseIds.won_mno}' THEN 1.00
|
|
26
|
+
ELSE 0
|
|
27
|
+
END), 0) AS "Ennustearvo",
|
|
28
|
+
COUNT(*) AS "Kauppoja"
|
|
29
|
+
FROM deals d
|
|
30
|
+
WHERE d.phaseId != '${Deals_PhaseIds.lost_pqr}'
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Common mistakes:**
|
|
34
|
+
- Forgetting `COALESCE` on outer SUM → null if no rows
|
|
35
|
+
- Matching on `phaseName` instead of `phaseId` → breaks when someone renames a phase
|
|
36
|
+
- Missing `ELSE 0` → unknown phases contribute NULL and poison the SUM
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Recipe 2 — CASE Bucketing
|
|
41
|
+
|
|
42
|
+
**When to use:** Group a continuous numeric column into labeled bins (deal size tiers, revenue brackets, response-time buckets).
|
|
43
|
+
|
|
44
|
+
```sql
|
|
45
|
+
SELECT
|
|
46
|
+
CASE
|
|
47
|
+
WHEN d.value < 1000 THEN '1_small'
|
|
48
|
+
WHEN d.value < 10000 THEN '2_mid'
|
|
49
|
+
WHEN d.value < 100000 THEN '3_large'
|
|
50
|
+
ELSE '4_enterprise'
|
|
51
|
+
END AS bucket,
|
|
52
|
+
COUNT(*) AS "Kauppoja",
|
|
53
|
+
COALESCE(SUM(d.value), 0) AS "Yhteensä"
|
|
54
|
+
FROM deals d
|
|
55
|
+
GROUP BY bucket
|
|
56
|
+
ORDER BY bucket
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Common mistakes:**
|
|
60
|
+
- Unsorted bucket labels → add sort prefix (`1_small`, `2_mid`) so ORDER BY bucket gives natural order
|
|
61
|
+
- Bucketing a NULL-able field — add `WHEN d.value IS NULL THEN '0_unknown'` as first branch
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Recipe 3 — Conditional Count
|
|
66
|
+
|
|
67
|
+
**When to use:** Count rows matching a condition **within** a broader group — e.g., "won deals per owner, alongside total deals per owner."
|
|
68
|
+
|
|
69
|
+
```sql
|
|
70
|
+
SELECT
|
|
71
|
+
COALESCE(u.name, 'Unassigned') AS "Omistaja",
|
|
72
|
+
COUNT(*) AS "Kauppoja yhteensä",
|
|
73
|
+
COUNT(CASE WHEN d.phaseId = '${Deals_PhaseIds.won_mno}' THEN 1 END) AS "Voitettu",
|
|
74
|
+
COUNT(CASE WHEN d.phaseId = '${Deals_PhaseIds.lost_pqr}' THEN 1 END) AS "Hävitty"
|
|
75
|
+
FROM deals d
|
|
76
|
+
LEFT JOIN user u ON u._id = d.owner
|
|
77
|
+
GROUP BY "Omistaja"
|
|
78
|
+
ORDER BY "Kauppoja yhteensä" DESC
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Key insight:** `COUNT(CASE WHEN ... THEN 1 END)` counts only matched rows (NULL from non-matches is ignored by COUNT).
|
|
82
|
+
|
|
83
|
+
**Common mistake:** `COUNT(CASE WHEN ... THEN 1 ELSE 0 END)` — ELSE 0 makes it count all rows. Drop the ELSE branch.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Recipe 4 — Percentage of Total
|
|
88
|
+
|
|
89
|
+
**When to use:** Show each row's share of the group-wide sum.
|
|
90
|
+
|
|
91
|
+
```sql
|
|
92
|
+
SELECT
|
|
93
|
+
d.phaseName AS "Vaihe",
|
|
94
|
+
COALESCE(SUM(d.value), 0) AS "Arvo",
|
|
95
|
+
ROUND(
|
|
96
|
+
100.0 * SUM(d.value) / NULLIF((SELECT SUM(value) FROM deals), 0),
|
|
97
|
+
1
|
|
98
|
+
) AS "% kokonaisarvosta"
|
|
99
|
+
FROM deals d
|
|
100
|
+
GROUP BY d.phaseName
|
|
101
|
+
ORDER BY "Arvo" DESC
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
**Key parts:**
|
|
105
|
+
- `100.0 *` forces float division (integer division truncates to 0%)
|
|
106
|
+
- `NULLIF(denominator, 0)` prevents division-by-zero
|
|
107
|
+
- Correlated subquery runs once for all rows
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Recipe 5 — Conversion Rate / Win Rate
|
|
112
|
+
|
|
113
|
+
**When to use:** Ratio of "ever reached phase X" count to "ever reached phase Y" count.
|
|
114
|
+
|
|
115
|
+
**Use `meta: 'phaseLastMove'` — one declared source field per phase.**
|
|
116
|
+
|
|
117
|
+
```javascript
|
|
118
|
+
// Source declaration
|
|
119
|
+
sources: [{
|
|
120
|
+
name: 'offers',
|
|
121
|
+
workflowId: WorkflowIds.offers,
|
|
122
|
+
fields: [
|
|
123
|
+
{ name: 'id', meta: '_id' },
|
|
124
|
+
{ name: 'owner', fieldId: Offers_FieldIds.owner_abc },
|
|
125
|
+
{ name: 'plm_sent', meta: 'phaseLastMove', phaseId: Offers_PhaseIds.sent_def },
|
|
126
|
+
{ name: 'plm_accepted',meta: 'phaseLastMove', phaseId: Offers_PhaseIds.accepted_ghi },
|
|
127
|
+
]
|
|
128
|
+
}]
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
```sql
|
|
132
|
+
SELECT
|
|
133
|
+
COALESCE(u.name, 'Unassigned') AS "Omistaja",
|
|
134
|
+
COUNT(CASE WHEN plm_sent IS NOT NULL THEN 1 END) AS "Lähetetty",
|
|
135
|
+
COUNT(CASE WHEN plm_accepted IS NOT NULL THEN 1 END) AS "Hyväksytty",
|
|
136
|
+
ROUND(
|
|
137
|
+
100.0 * COUNT(CASE WHEN plm_accepted IS NOT NULL THEN 1 END)
|
|
138
|
+
/ NULLIF(COUNT(CASE WHEN plm_sent IS NOT NULL THEN 1 END), 0),
|
|
139
|
+
1
|
|
140
|
+
) AS "Hyväksymisprosentti"
|
|
141
|
+
FROM offers
|
|
142
|
+
LEFT JOIN user u ON u._id = offers.owner
|
|
143
|
+
GROUP BY "Omistaja"
|
|
144
|
+
ORDER BY "Hyväksymisprosentti" DESC NULLS LAST
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
**Why `plm_X IS NOT NULL`, not `phaseId = X`:** An offer that went Sent → Accepted → Archived has `phaseId = Archived` now, but `plm_accepted` is non-null. `phaseId = Accepted` only catches activities currently parked there — it misses every accepted offer that moved on.
|
|
148
|
+
|
|
149
|
+
**Common mistakes:**
|
|
150
|
+
- Using `CASE WHEN phaseId = X` for conversion — misses activities that passed through X
|
|
151
|
+
- Trying `movedTo<PhaseName>` — that column doesn't exist; declare a `phaseLastMove` source field per phase instead
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Recipe 6 — Date Truncation for Time Series
|
|
156
|
+
|
|
157
|
+
**When to use:** Group rows by month/week/quarter/year to produce trend charts.
|
|
158
|
+
|
|
159
|
+
**For a millisecond timestamp** (activity meta like `d.created`):
|
|
160
|
+
|
|
161
|
+
```sql
|
|
162
|
+
SELECT
|
|
163
|
+
strftime('%Y-%m', d.created / 1000, 'unixepoch') AS month,
|
|
164
|
+
COUNT(*) AS deal_count,
|
|
165
|
+
COALESCE(SUM(d.value), 0) AS revenue
|
|
166
|
+
FROM deals d
|
|
167
|
+
WHERE d.phaseId = '${Deals_PhaseIds.won_mno}'
|
|
168
|
+
GROUP BY month
|
|
169
|
+
ORDER BY month
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**For a seconds timestamp** (most custom date fields):
|
|
173
|
+
|
|
174
|
+
```sql
|
|
175
|
+
strftime('%Y-%m', d.closeDate, 'unixepoch') AS month
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Truncation format codes:**
|
|
179
|
+
- `'%Y'` → year (e.g. `2026`)
|
|
180
|
+
- `'%Y-%m'` → month (e.g. `2026-04`)
|
|
181
|
+
- `'%Y-%W'` → ISO week
|
|
182
|
+
- `'%Y-%m-%d'` → day
|
|
183
|
+
|
|
184
|
+
**Common mistakes:**
|
|
185
|
+
- Forgetting to divide by 1000 when field is ms → year 57259
|
|
186
|
+
- Forgetting the `'unixepoch'` modifier → garbage output
|
|
187
|
+
- Forgetting ORDER BY — time-series charts need sorted output
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Recipe 7 — Age / Days-Since
|
|
192
|
+
|
|
193
|
+
**When to use:** "Stale deals" reports without relying on a function field.
|
|
194
|
+
|
|
195
|
+
**"Now" in SQLite is `strftime('%s', 'now')`** — returns seconds. `NOW()` and `UNIX_TIMESTAMP()` are not available.
|
|
196
|
+
|
|
197
|
+
**If comparing to a millisecond field** (e.g. `meta.created`):
|
|
198
|
+
|
|
199
|
+
```sql
|
|
200
|
+
SELECT
|
|
201
|
+
d.name AS deal,
|
|
202
|
+
CAST((strftime('%s', 'now') * 1000 - d.created) / 86400000 AS INTEGER) AS days_old
|
|
203
|
+
FROM deals d
|
|
204
|
+
WHERE d.phaseId NOT IN ('${Deals_PhaseIds.won_mno}', '${Deals_PhaseIds.lost_pqr}')
|
|
205
|
+
AND (strftime('%s', 'now') * 1000 - d.created) > (30 * 86400000)
|
|
206
|
+
ORDER BY days_old DESC
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
**If comparing to a seconds field** (custom date field):
|
|
210
|
+
|
|
211
|
+
```sql
|
|
212
|
+
CAST((strftime('%s', 'now') - d.closeDate) / 86400 AS INTEGER) AS days_until_close
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**strftime('%s','now') returns TEXT — affects MIN/MAX and comparisons.** Always wrap in `CAST(... AS INTEGER)` before mixing into numeric MIN/MAX/comparison.
|
|
216
|
+
|
|
217
|
+
```sql
|
|
218
|
+
-- Dangerous — string ordering, wrong result
|
|
219
|
+
MIN(strftime('%s','now'), 9999999999)
|
|
220
|
+
|
|
221
|
+
-- Safe
|
|
222
|
+
MIN(CAST(strftime('%s','now') AS INTEGER), 9999999999)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Recipe 8 — Top-N per Group
|
|
228
|
+
|
|
229
|
+
**When to use:** "Top 3 deals per owner", "Largest 5 accounts per industry."
|
|
230
|
+
|
|
231
|
+
```sql
|
|
232
|
+
SELECT owner, deal, value
|
|
233
|
+
FROM (
|
|
234
|
+
SELECT
|
|
235
|
+
COALESCE(u.name, 'Unassigned') AS owner,
|
|
236
|
+
d.name AS deal,
|
|
237
|
+
d.value,
|
|
238
|
+
ROW_NUMBER() OVER (PARTITION BY d.owner ORDER BY d.value DESC) AS rn
|
|
239
|
+
FROM deals d
|
|
240
|
+
LEFT JOIN user u ON u._id = d.owner
|
|
241
|
+
) ranked
|
|
242
|
+
WHERE rn <= 3
|
|
243
|
+
ORDER BY owner, value DESC
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
**Fallback — correlated subquery if no window functions:**
|
|
247
|
+
|
|
248
|
+
```sql
|
|
249
|
+
SELECT COALESCE(u.name, 'Unassigned') AS owner, d.name AS deal, d.value
|
|
250
|
+
FROM deals d
|
|
251
|
+
LEFT JOIN user u ON u._id = d.owner
|
|
252
|
+
WHERE (
|
|
253
|
+
SELECT COUNT(*) FROM deals d2 WHERE d2.owner = d.owner AND d2.value > d.value
|
|
254
|
+
) < 3
|
|
255
|
+
ORDER BY owner, d.value DESC
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## Recipe 9 — Function Fields in WHERE/SELECT
|
|
261
|
+
|
|
262
|
+
Function field values are materialized on each activity and queryable in all SQL positions.
|
|
263
|
+
|
|
264
|
+
| Usage | Works? |
|
|
265
|
+
|---|---|
|
|
266
|
+
| `SELECT d.my_ff` | Yes — returns stored FF value (NULL if FF returned null) |
|
|
267
|
+
| `WHERE d.my_ff > 30` | Yes |
|
|
268
|
+
| `ORDER BY d.my_ff` | Yes |
|
|
269
|
+
| `SUM(d.my_ff)` / `AVG(d.my_ff)` | Yes |
|
|
270
|
+
|
|
271
|
+
**Type note:** FFs declared numeric arrive as `real` (float). If a value is `0.0`, the run_insight text renderer may display it as blank — use `CAST(d.my_ff AS TEXT)` to confirm.
|
|
272
|
+
|
|
273
|
+
**Inline re-computation is still recommended when:**
|
|
274
|
+
- You don't trust the FF compute to be current (e.g. after bulk phase moves, FFs may be stale until next write)
|
|
275
|
+
- The calculation is simple enough to express inline
|
|
276
|
+
|
|
277
|
+
---
|
|
278
|
+
|
|
279
|
+
## Recipe 10 — Helsinki / Finnish DST Offset
|
|
280
|
+
|
|
281
|
+
**When to use:** Any insight that formats timestamps to local Finnish time.
|
|
282
|
+
|
|
283
|
+
**Offsets:** EEST (UTC+3, late March → late October), EET (UTC+2, the rest of the year).
|
|
284
|
+
|
|
285
|
+
**Critical — wrap all `strftime` DST comparisons in `CAST(... AS INTEGER)`** — `strftime('%s', ...)` returns TEXT; comparing a numeric unix column against it inside CASE falls back to string ordering.
|
|
286
|
+
|
|
287
|
+
```sql
|
|
288
|
+
WITH dst_bounds AS (
|
|
289
|
+
SELECT
|
|
290
|
+
CAST(strftime('%s',
|
|
291
|
+
date(strftime('%Y', ts, 'unixepoch') || '-03-31', 'weekday 0', '-7 days')
|
|
292
|
+
|| ' 03:00:00'
|
|
293
|
+
) AS INTEGER) AS summer_start,
|
|
294
|
+
CAST(strftime('%s',
|
|
295
|
+
date(strftime('%Y', ts, 'unixepoch') || '-10-31', 'weekday 0', '-7 days')
|
|
296
|
+
|| ' 04:00:00'
|
|
297
|
+
) AS INTEGER) AS summer_end
|
|
298
|
+
FROM src LIMIT 1
|
|
299
|
+
)
|
|
300
|
+
SELECT
|
|
301
|
+
strftime(
|
|
302
|
+
'%d.%m.%Y %H:%M', ts, 'unixepoch',
|
|
303
|
+
CASE
|
|
304
|
+
WHEN CAST(ts AS INTEGER) >= (SELECT summer_start FROM dst_bounds)
|
|
305
|
+
AND CAST(ts AS INTEGER) < (SELECT summer_end FROM dst_bounds)
|
|
306
|
+
THEN '+3 hours'
|
|
307
|
+
ELSE '+2 hours'
|
|
308
|
+
END
|
|
309
|
+
) AS local_time
|
|
310
|
+
FROM src
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
**For millisecond-unit timestamps:** divide by 1000 before all `strftime` calls.
|
|
314
|
+
|
|
315
|
+
---
|
|
316
|
+
|
|
317
|
+
## Recipe 11 — Show All Entities Even with No Activity (Cross-Join Pattern)
|
|
318
|
+
|
|
319
|
+
**When to use:** You want a complete grid — every entity × every time period — even when some combinations have zero activity. Without this, GROUP BY silently omits zero-count rows.
|
|
320
|
+
|
|
321
|
+
```sql
|
|
322
|
+
WITH entities AS (
|
|
323
|
+
SELECT _id AS entity_id, activityName AS entity_name
|
|
324
|
+
FROM entity_workflow
|
|
325
|
+
),
|
|
326
|
+
periods AS (
|
|
327
|
+
SELECT DISTINCT strftime('%Y-%m', booking_start, 'unixepoch') AS period
|
|
328
|
+
FROM bookings
|
|
329
|
+
),
|
|
330
|
+
aggregated AS (
|
|
331
|
+
SELECT entity_link AS entity_id, strftime('%Y-%m', booking_start, 'unixepoch') AS period, COUNT(*) AS booking_count
|
|
332
|
+
FROM bookings
|
|
333
|
+
GROUP BY entity_link, period
|
|
334
|
+
)
|
|
335
|
+
SELECT
|
|
336
|
+
e.entity_id, e.entity_name, p.period,
|
|
337
|
+
COALESCE(a.booking_count, 0) AS booking_count
|
|
338
|
+
FROM entities e
|
|
339
|
+
CROSS JOIN periods p
|
|
340
|
+
LEFT JOIN aggregated a ON a.entity_id = e.entity_id AND a.period = p.period
|
|
341
|
+
ORDER BY e.entity_name, p.period
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**Key mechanics:**
|
|
345
|
+
- `CROSS JOIN` produces every entity × period combination
|
|
346
|
+
- `LEFT JOIN aggregated` brings in actual data; unmatched combinations stay as NULL
|
|
347
|
+
- `COALESCE(..., 0)` converts NULL to 0
|
|
348
|
+
|
|
349
|
+
**Diagnostic reminder:** if row count is lower than expected and data "feels like it should be there", check whether you're using INNER JOIN or plain GROUP BY — both silently drop zero-activity entities.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## Debugging Checklist (Silent-Wrong-Answer Mode)
|
|
354
|
+
|
|
355
|
+
Before trusting an insight's numbers:
|
|
356
|
+
|
|
357
|
+
1. **Row count check** — `SELECT COUNT(*) FROM wf` with no filters matches expected workspace count
|
|
358
|
+
2. **Spot-check a known row** — pick one activity you know the value of, `WHERE _id = 'known'`, assert returned value matches
|
|
359
|
+
3. **Denominator exists** — any `X / Y` has `NULLIF(Y, 0)` on Y
|
|
360
|
+
4. **Float division** — any percentage multiplies by `100.0` not `100`
|
|
361
|
+
5. **NULL handling** — outer aggregates wrapped in `COALESCE(..., 0)` or similar default
|
|
362
|
+
6. **Phase IDs not names** — all phase filters use `phaseId` (rename-safe)
|
|
363
|
+
7. **Preview with real data** — never ship an insight whose `run_insight` output you haven't read row by row
|
|
364
|
+
8. **Row count lower than expected?** — check for INNER JOIN or plain GROUP BY silently dropping zero-activity entities; use CROSS JOIN + LEFT JOIN (Recipe 11)
|