@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.
Files changed (57) hide show
  1. package/.claude/skills/create-and-publish-app/SKILL.md +44 -56
  2. package/.claude/skills/hailer-app-builder/SKILL.md +414 -970
  3. package/.claude/skills/hailer-app-primitives/SKILL.md +742 -0
  4. package/.claude/skills/hailer-apps-pictures/SKILL.md +191 -87
  5. package/.claude/skills/hailer-design-patterns/SKILL.md +317 -0
  6. package/.claude/skills/hailer-design-system/SKILL.md +202 -149
  7. package/.claude/skills/hailer-workflow-archetypes/SKILL.md +301 -0
  8. package/.claude/skills/insight-join-patterns/SKILL.md +313 -0
  9. package/.claude/skills/publish-hailer-app/SKILL.md +211 -0
  10. package/.claude/skills/sdk-activity-patterns/SKILL.md +257 -105
  11. package/.claude/skills/sdk-function-fields/SKILL.md +253 -492
  12. package/.claude/skills/sdk-insight-calculations/SKILL.md +364 -0
  13. package/.claude/skills/sdk-insight-queries/SKILL.md +192 -397
  14. package/.claude/skills/sdk-ws-config-skill/SKILL.md +265 -720
  15. package/.claude/skills/tool-response-verification/SKILL.md +143 -0
  16. package/CLAUDE.md +50 -19
  17. package/dist/bot/bot.d.ts.map +1 -1
  18. package/dist/bot/bot.js +26 -34
  19. package/dist/bot/bot.js.map +1 -1
  20. package/dist/bot/services/helper-prompt.d.ts +1 -1
  21. package/dist/bot/services/helper-prompt.d.ts.map +1 -1
  22. package/dist/bot/services/helper-prompt.js +62 -82
  23. package/dist/bot/services/helper-prompt.js.map +1 -1
  24. package/dist/bot/services/system-prompt.js +4 -4
  25. package/dist/config.d.ts +1 -0
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +6 -0
  28. package/dist/config.js.map +1 -1
  29. package/dist/mcp/hailer-rpc.d.ts +1 -0
  30. package/dist/mcp/hailer-rpc.d.ts.map +1 -1
  31. package/dist/mcp/hailer-rpc.js +4 -0
  32. package/dist/mcp/hailer-rpc.js.map +1 -1
  33. package/dist/mcp/tools/activity.d.ts.map +1 -1
  34. package/dist/mcp/tools/activity.js +8 -2
  35. package/dist/mcp/tools/activity.js.map +1 -1
  36. package/dist/mcp/tools/app-core.d.ts.map +1 -1
  37. package/dist/mcp/tools/app-core.js +6 -3
  38. package/dist/mcp/tools/app-core.js.map +1 -1
  39. package/dist/mcp/tools/app-marketplace.d.ts.map +1 -1
  40. package/dist/mcp/tools/app-marketplace.js +5 -1
  41. package/dist/mcp/tools/app-marketplace.js.map +1 -1
  42. package/dist/mcp/webhook-handler.d.ts.map +1 -1
  43. package/dist/mcp/webhook-handler.js +27 -0
  44. package/dist/mcp/webhook-handler.js.map +1 -1
  45. package/dist/public-chat/graduate.d.ts.map +1 -1
  46. package/dist/public-chat/graduate.js +153 -53
  47. package/dist/public-chat/graduate.js.map +1 -1
  48. package/dist/public-chat/rate-limit.js +1 -1
  49. package/dist/public-chat/rate-limit.js.map +1 -1
  50. package/dist/public-chat/studio-prewarm.js +2 -2
  51. package/dist/public-chat/studio-prewarm.js.map +1 -1
  52. package/dist/public-chat/system-prompt.d.ts.map +1 -1
  53. package/dist/public-chat/system-prompt.js +41 -25
  54. package/dist/public-chat/system-prompt.js.map +1 -1
  55. package/package.json +1 -1
  56. package/.claude/skills/hailer-project-protocol/SKILL.md +0 -398
  57. package/.opencode/package-lock.json +0 -117
@@ -1,509 +1,304 @@
1
1
  ---
2
2
  name: sdk-insight-queries
3
- description: SQL query patterns for Hailer insights - aggregations, filtering, dates, CASE statements, cross-workflow JOINs
4
- version: 1.6.0
5
- triggers: Insight queries, GROUP BY, COUNT, SUM, date formatting, CASE WHEN, JOIN query errors, missing columns, NULL results in insight queries
3
+ description: SQL query patterns for Hailer insights aggregations, filtering, dates, CASE statements, cross-workflow JOINs, and push workflow. Load when designing, writing, or debugging any insight query. Also use for questions about timestamp units (seconds vs ms), reserved column names, source field declarations, phaseLastMove, activityId sidebar wiring, and run_insight ad-hoc vs saved behavior.
6
4
  ---
7
5
 
6
+ <!-- augmentation-guard: shrink-ok -->
7
+
8
8
  # Insight Query Patterns
9
9
 
10
10
  <critical-rules>
11
- ## ⚠️ TIMESTAMPS IN INSIGHTS ARE SECONDS (NOT MILLISECONDS!)
11
+ ## TIMESTAMP UNITS: PROBE BEFORE ASSUMING
12
12
 
13
- **IMPORTANT:** While the Hailer API uses milliseconds, **insights expose datetime fields as SECONDS** (10-digit numbers like `1768291200`).
13
+ The insight engine is **SQLite**. `strftime('%s', 'now')` returns **seconds** (10-digit unix).
14
14
 
15
- **For date comparisons in WHERE clauses:**
16
- ```sql
17
- -- CORRECT (comparing seconds to seconds)
18
- WHERE ajankohtaEnd > strftime('%s', 'now')
15
+ **But insight field values can be seconds OR milliseconds:**
19
16
 
20
- -- WRONG (comparing seconds to milliseconds - will fail!)
21
- WHERE ajankohtaEnd > strftime('%s', 'now') * 1000
22
- ```
17
+ | Field source | Unit | How to tell |
18
+ |---|---|---|
19
+ | Plain date/datetime custom fields (fieldId-based) | **seconds** (10-digit) — insight engine's `convert()` divides ALL `date`/`datetime`/`time`/`daterange`/`datetimerange`/`timerange` field values by 1000 unconditionally before inserting into SQLite | 10-digit |
20
+ | Range auto-suffix columns (`<field>Start`, `<field>End`) | **seconds** | 10-digit |
21
+ | `phaseLastMove` (declared with `phaseId`) | **seconds** | 10-digit |
22
+ | Activity meta (`created`, `updated`) | **milliseconds** (13-digit) — meta columns bypass `convert()` | 13-digit |
23
+ | Numeric function fields derived from a datetimerange | **milliseconds** — function fields bypass `convert()` | 13-digit |
23
24
 
24
- **For formatting dates with strftime:**
25
- ```sql
26
- -- CORRECT (field is already in seconds)
27
- strftime('%Y-%m-%d', dateField, 'unixepoch')
25
+ **Probe first, then write math:**
28
26
 
29
- -- WRONG (double-dividing - will show wrong dates)
30
- strftime('%Y-%m-%d', dateField / 1000, 'unixepoch')
27
+ ```sql
28
+ SELECT src.created AS meta_created, src.myDateField AS custom_date, strftime('%s','now') AS now_s
29
+ FROM src LIMIT 1
31
30
  ```
32
31
 
33
- **Quick rules for insights:**
34
- - `dateField` → already in seconds, use directly with strftime
35
- - `strftime('%s', 'now')` → returns seconds, compare directly with dateField
36
- - Do NOT multiply or divide by 1000!
32
+ Compare digit counts: 13 = ms, 10 = s.
37
33
 
38
- **Test before deploying:** Create a diagnostic query to verify:
34
+ **If field is milliseconds (e.g. `meta.created`):**
39
35
  ```sql
40
- SELECT dateField, strftime('%s', 'now') as now_sec,
41
- (dateField > strftime('%s', 'now')) as is_future
42
- FROM source LIMIT 5
36
+ WHERE src.created > strftime('%s','now') * 1000
37
+ strftime('%Y-%m-%d', src.created / 1000, 'unixepoch')
43
38
  ```
44
- </critical-rules>
45
-
46
- ---
47
-
48
- ## Production Insight Guidelines
49
-
50
- **If an insight is used by an app, document it:**
51
39
 
52
- ```typescript
53
- {
54
- _id: "...",
55
- name: "Tilavaraukset - Public", // Add (APP) suffix if used by app
56
- // Or: name: "Tilavaraukset - Public (tilavaraus-app)",
57
- query: `SELECT /* Used by tilavaraus-app - do not change field names! */
58
- id, name, varattu_tila, ajankohtaStart, ajankohtaEnd
59
- FROM varaukset
60
- WHERE phaseId = '...'`
61
- }
40
+ **If field is seconds (most custom date fields):**
41
+ ```sql
42
+ WHERE dateField > strftime('%s','now')
43
+ strftime('%Y-%m-%d', dateField, 'unixepoch')
62
44
  ```
63
45
 
64
- **Why:** Prevents breaking production apps when modifying insights.
46
+ **Silent failure mode:** assuming seconds when field is ms → `strftime` returns year ~57259 or empty.
65
47
 
66
- **Before modifying production insights:**
67
- 1. Create a TEST duplicate with same sources
68
- 2. Test changes on the duplicate
69
- 3. Apply working changes to production
48
+ **Engine-specific function inventory (SQLite):**
49
+ - Available: `strftime`, `CURRENT_TIMESTAMP`, `COALESCE`, `CASE`, `NULLIF`, `ROUND`, `CAST`
50
+ - Not available: `NOW()`, `UNIX_TIMESTAMP()`, `DATE_TRUNC`, `DATE_FORMAT`
70
51
 
71
- ---
52
+ **Two gotchas:** (1) Don't alias `activityId` via `AS` — frontend sidebar wires by literal column name. (2) Range fields require explicit `<name>Start` / `<name>End` — bare field name is not selectable.
53
+ </critical-rules>
72
54
 
73
- ## Query Basics
55
+ ---
74
56
 
75
- Hailer insights use **SQLite syntax**. Queries run over sources you define.
57
+ ## Source Metadata Every Column Must Be Declared
76
58
 
77
- ```typescript
78
- {
79
- sources: [
80
- {
81
- workflowId: WorkflowIds.tasks_abc,
82
- name: 't', // Alias for queries
83
- fields: [
84
- { name: 'title', meta: 'name' },
85
- { name: 'id', meta: '_id' },
86
- { name: 'priority', fieldId: Tasks_FieldIds.priority_def }
87
- ]
88
- }
89
- ],
90
- query: 'SELECT title, priority FROM t WHERE priority = "High"'
91
- }
92
- ```
59
+ **No auto-exposed metadata columns.** Every column referenced in SQL must be declared in `sources[].fields[]` with either `fieldId` or `meta`. Selecting without a matching declaration returns SQLite `no such column` (code 127).
93
60
 
94
- ### Source Field Naming Convention
61
+ **Naming convention keep `activityId` literal.** The Hailer frontend wires row-click → activity sidebar by the literal output column name `activityId`. Any rename (e.g. `SELECT activityId AS id`) silently breaks the sidebar link.
95
62
 
96
- **Use real field names** in source definitions, not generic names:
63
+ ### Canonical Fixture
97
64
 
98
- ```typescript
99
- // GOOD - Real field names, matches Hailer UI
100
- fields: [
101
- { name: 'nro', fieldId: FieldIds.invoiceNumber },
102
- { name: 'pvm', fieldId: FieldIds.invoiceDate },
103
- { name: 'asiakas', fieldId: FieldIds.customer }
104
- ]
105
- // Query: SELECT nro, pvm, asiakas FROM invoices
65
+ Gold copy — paste from this block (testspace insight `_id: 6a16d769d9d2a360ab104d30`):
106
66
 
107
- // BAD - Generic names, confusing
67
+ ```javascript
68
+ // sources[0].fields[] — declare every column you'll SELECT
108
69
  fields: [
109
- { name: 'activityId', fieldId: FieldIds.invoiceNumber },
110
- { name: 'date', fieldId: FieldIds.invoiceDate },
111
- { name: 'link', fieldId: FieldIds.customer }
70
+ { name: 'activityId', meta: '_id' }, // unaliased in SELECT — sidebar wiring
71
+ { name: 'activityName', meta: 'name' },
72
+ { name: 'createdByUserId', meta: 'createdBy' },
73
+ { name: 'createdTime', meta: 'created' }, // unix ms (13-digit)
74
+ { name: 'updatedTime', meta: 'updated' }, // unix ms (13-digit)
75
+ { name: 'priority', meta: 'priority' },
76
+ { name: 'phaseId', meta: 'phaseId' },
77
+ { name: 'phaseName', meta: 'phaseName' },
78
+ { name: 'workflowId', meta: 'workflowId' },
79
+ { name: 'workflowName', meta: 'workflowName' },
80
+
81
+ // Workflow fields by fieldId — daterange/datetimerange/timerange auto-expose <name>Start / <name>End
82
+ { name: 'aika', fieldId: FieldIds.aika }, // datetime → seconds
83
+ { name: 'aikaväli', fieldId: FieldIds.aikavali }, // timerange → aikaväliStart / aikaväliEnd (seconds)
84
+ { name: 'ajanjakso',fieldId: FieldIds.ajanjakso }, // daterange → ajanjaksoStart / ajanjaksoEnd (seconds)
85
+
86
+ // One phaseLastMove declaration per phase you care about
87
+ { name: 'movedToLastPhase', meta: 'phaseLastMove', phaseId: PhaseIds.last_phase },
88
+ { name: 'movedToNewPhase', meta: 'phaseLastMove', phaseId: PhaseIds.new_phase },
112
89
  ]
113
90
  ```
114
91
 
115
- ---
92
+ **Empirical magnitudes (2026-05-27):** `createdTime`/`updatedTime` = 13-digit ms; `aika`/`*Start`/`*End`/`movedTo*` = 10-digit seconds.
116
93
 
117
- ## Available Meta Fields
118
-
119
- | Meta | Description | Example |
120
- |------|-------------|---------|
121
- | `_id` | Activity ID | Required for JOINs |
122
- | `name` | Activity name | Display name |
123
- | `created` | Created timestamp | Unix seconds |
124
- | `updated` | Updated timestamp | Unix seconds |
125
- | `phaseId` | Current phase ID | For phase filtering |
126
- | `phaseName` | Current phase name | Human readable |
127
- | `workflowId` | Workflow ID | |
128
- | `workflowName` | Workflow name | |
129
- | `createdBy` | Creator user ID | |
130
- | `uid` | User ID | |
131
- | `team` | Team ID | |
132
- | `priority` | Priority value | |
133
- | `phaseLastMove` | Last phase change | Unix seconds |
94
+ ### Declarable Meta Values
134
95
 
135
- ---
96
+ | `meta` value | SQL type | Notes |
97
+ |---|---|---|
98
+ | `_id` | TEXT | Activity ID — required for cross-workflow JOINs |
99
+ | `name` | TEXT | Activity display name |
100
+ | `created` | INTEGER | Created timestamp — **milliseconds (13-digit)** |
101
+ | `updated` | INTEGER | Updated timestamp — **milliseconds (13-digit)** |
102
+ | `phaseId` | TEXT | Current phase ID |
103
+ | `phaseName` | TEXT | Current phase display name |
104
+ | `priority` | INTEGER | Activity priority |
105
+ | `team` | TEXT | Team ID |
106
+ | `uid` | TEXT | BROKEN — always NULL at runtime. Use `createdBy` instead. |
107
+ | `createdBy` | TEXT | Creator user ID |
108
+ | `workflowId` | TEXT | Workflow ID |
109
+ | `workflowName` | TEXT | Workflow name |
110
+ | `phaseLastMove` | INTEGER | Timestamp the activity entered the phase — **seconds**. Requires `phaseId` sub-key. A declaration without `phaseId` returns `null` for every row. Declare one per phase you want to track. |
136
111
 
137
- ## Date Handling
112
+ **`completedOn` is NOT a valid insight `meta:` value** — server rejects it. Workaround for "when did this activity reach the endpoint phase": declare a `phaseLastMove` source field with the endpoint phase's `phaseId`.
138
113
 
139
- **CRITICAL:** In Hailer insights, all date/time fields are exposed as **Unix timestamps in SECONDS** (10-digit numbers like `1770050954`).
114
+ ---
140
115
 
141
- This is different from the Hailer API which uses milliseconds! The insight layer converts automatically.
116
+ ## Insight Design Pick the Shape Deliberately
142
117
 
143
- ### Range Fields (daterange, datetimerange, timerange)
118
+ **Shape A Dimensional rows (consumer aggregates)**
119
+ - SELECT every dimension the consumer might group or filter by
120
+ - No `GROUP BY` (or only fine grain)
121
+ - Fits: custom dashboard apps, flexible drill-down
144
122
 
145
- For range field types, Hailer automatically exposes **Start** and **End** suffixes.
123
+ **Shape B Pre-aggregated summary (SQL groups, consumer plots as-is)**
124
+ - `GROUP BY` in the query
125
+ - Fits: fixed charts, simple reports, KPI cards
146
126
 
147
- | Field Type | In Insight SQL |
148
- |------------|----------------|
149
- | `date` | Seconds (10-digit) |
150
- | `datetime` | Seconds (10-digit) |
151
- | `time` | Seconds (10-digit) |
152
- | `daterange` | Start/End in seconds |
153
- | `datetimerange` | Start/End in seconds |
154
- | `timerange` | Start/End in seconds |
127
+ **run_insight ad-hoc cap:** `run_insight` in ad-hoc mode caps at ~200 source rows. Aggregations in ad-hoc are based on at most 200 rows. Create the insight and call `run_insight` with `insightId` to get full results.
155
128
 
156
- **Note:** `timerange` stores full timestamps, not just time. To extract just the time portion, use SQLite time functions.
129
+ **run_insight ad-hoc returns null/0 for ActivityLink columns and JOINs.** Real-world example: a WHERE on an ActivityLink column returned 0 rows ad-hoc, 13,135 rows via saved `run_insight`. Never trust ad-hoc for row counts or column values when the query uses ActivityLink fields.
157
130
 
158
- **In sources:** Use just the field name:
159
- ```typescript
160
- fields: [
161
- { name: 'ajankohta', fieldId: Event_FieldIds.ajankohta_abc }, // daterange/datetimerange
162
- { name: 'tyoaika', fieldId: Event_FieldIds.tyoaika_def } // timerange
163
- ]
164
- ```
131
+ ---
165
132
 
166
- **In queries:** Access with Start/End suffixes (already in seconds):
167
- ```sql
168
- SELECT
169
- ajankohtaStart,
170
- ajankohtaEnd,
171
- strftime('%d.%m.%Y', ajankohtaStart, 'unixepoch') as start_date,
172
- strftime('%d.%m.%Y', ajankohtaEnd, 'unixepoch') as end_date,
173
-
174
- -- Timerange: extract just the time portion
175
- strftime('%H:%M', tyoaikaStart, 'unixepoch') as start_time,
176
- strftime('%H:%M', tyoaikaEnd, 'unixepoch') as end_time,
177
-
178
- -- Duration in seconds (both are already seconds)
179
- (tyoaikaEnd - tyoaikaStart) / 60 as duration_minutes
180
- FROM events
181
- WHERE ajankohtaStart >= strftime('%s', 'now')
182
- ```
133
+ ## Reserved Words as Source Field `name`
183
134
 
184
- **Naming convention:** `{fieldName}Start` and `{fieldName}End` are auto-generated.
135
+ Reserved SQL keywords in source `name` cause **loud `SQLITE_ERROR: near "<word>": syntax error` (code 127)** — not silent NULL. The failure is at `CREATE TABLE` time.
185
136
 
186
- **IMPORTANT: When to convert vs keep raw:**
187
- - **Display only** (human-readable reports) → Use `strftime()` to format
188
- - **Data for Hailer apps** App receives seconds from insight, may need to multiply by 1000 for API calls
189
- - **Comparisons** Compare directly with `strftime('%s', ...)` which also returns seconds
137
+ | Source `name` | Result |
138
+ |---|---|
139
+ | `date`, `store`, `time`, `user` | safe |
140
+ | `order`, `group`, `select`, `where`, `from` | breaks table creation |
190
141
 
191
- ### Timezone Handling (Finland: +2/+3)
192
- ```sql
193
- strftime(
194
- '%d.%m.%Y %H:%M',
195
- created,
196
- 'unixepoch',
197
- CASE
198
- WHEN created >= (
199
- SELECT strftime('%s', date(strftime('%Y', created, 'unixepoch') || '-03-31', 'weekday 0', '-7 days', '03:00'))
200
- )
201
- AND created < (
202
- SELECT strftime('%s', date(strftime('%Y', created, 'unixepoch') || '-10-31', 'weekday 0', '-7 days', '04:00'))
203
- )
204
- THEN '+3 hours'
205
- ELSE '+2 hours'
206
- END
207
- ) AS local_time
208
- ```
142
+ Quoting does not save you — rename the source `name`.
209
143
 
210
144
  ---
211
145
 
212
- ## CASE Statements
146
+ ## Aggregate NULL Behavior
213
147
 
214
- ### CASE in Aggregation (count by status)
215
- ```sql
216
- SELECT
217
- SUM(CASE WHEN phaseName = 'Done' THEN 1 ELSE 0 END) as completed,
218
- SUM(CASE WHEN phaseName != 'Done' THEN 1 ELSE 0 END) as pending,
219
- COUNT(*) as total
220
- FROM tasks
221
- ```
148
+ | Function | NULL behavior |
149
+ |---|---|
150
+ | `COUNT(*)` | counts every row (NULLs included) |
151
+ | `COUNT(col)` | excludes rows where `col IS NULL` |
152
+ | `COUNT(DISTINCT col)` | excludes NULLs and duplicates |
153
+ | `SUM(col)` | NULLs contribute nothing |
154
+ | `AVG(col)` | excludes NULLs from both numerator and denominator |
222
155
 
223
156
  ---
224
157
 
225
- ## JOINs
226
-
227
- When joining workflows via ActivityLink fields, you must:
228
- 1. Include `_id` meta field in **both** source definitions
229
- 2. Join ON the activitylink field value equals target `_id`
230
- 3. Use the activitylink `fieldId` (NOT the key) in the source definition
231
-
232
- ### Rules
233
- - Both workflows need `{ name: 'id', meta: '_id' }` in their fields array
234
- - JOIN condition: `source1.activityLinkFieldName = source2.id`
235
- - Use LEFT JOIN for optional relationships (activitylink can be null)
236
- - Use INNER JOIN only when the relationship must exist
237
-
238
- ### Correct Example
239
- ```javascript
240
- // Players workflow has "club" field (activitylink to Clubs workflow)
241
- {
242
- sources: [
243
- {
244
- name: 'p',
245
- workflowId: '68446dc05b30685f67c6fcd4',
246
- fields: [
247
- { name: 'player_name', meta: 'name' },
248
- { name: 'id', meta: '_id' }, // Required!
249
- { name: 'club', fieldId: '684d5e45...' } // ActivityLink field
250
- ]
251
- },
252
- {
253
- name: 'c',
254
- workflowId: '691ea936ccb6bdeebc0cbf77',
255
- fields: [
256
- { name: 'club_name', meta: 'name' },
257
- { name: 'id', meta: '_id' } // Required!
258
- ]
259
- }
260
- ],
261
- query: 'SELECT p.player_name, c.club_name FROM p LEFT JOIN c ON p.club = c.id'
262
- }
263
- ```
264
-
265
- ### Wrong Example
266
- ```javascript
267
- // WRONG - Missing _id in clubs source
268
- {
269
- sources: [
270
- {
271
- name: 'c',
272
- workflowId: 'clubs-id',
273
- fields: [
274
- { name: 'club_name', meta: 'name' }
275
- // Missing: { name: 'id', meta: '_id' }
276
- ]
277
- }
278
- ],
279
- query: '... LEFT JOIN c ON p.club = c.id'
280
- }
281
- // Error: "no such column: c.id"
282
- ```
158
+ ## Query Basics
283
159
 
284
- ### Three-Way JOIN (Tasks -> Topics -> Projects)
285
- ```javascript
160
+ ```typescript
286
161
  {
287
162
  sources: [
288
163
  {
164
+ workflowId: WorkflowIds.tasks_abc,
289
165
  name: 't',
290
- workflowId: 'tasks-workflow-id',
291
- fields: [
292
- { name: 'task_name', meta: 'name' },
293
- { name: 'id', meta: '_id' },
294
- { name: 'topic', fieldId: 'topic-field-id' }
295
- ]
296
- },
297
- {
298
- name: 'top',
299
- workflowId: 'topics-workflow-id',
300
166
  fields: [
301
- { name: 'topic_name', meta: 'name' },
167
+ { name: 'title', meta: 'name' },
302
168
  { name: 'id', meta: '_id' },
303
- { name: 'project', fieldId: 'project-field-id' }
304
- ]
305
- },
306
- {
307
- name: 'p',
308
- workflowId: 'projects-workflow-id',
309
- fields: [
310
- { name: 'project_name', meta: 'name' },
311
- { name: 'id', meta: '_id' }
169
+ { name: 'priority', fieldId: Tasks_FieldIds.priority_def }
312
170
  ]
313
171
  }
314
172
  ],
315
- query: `
316
- SELECT t.task_name, top.topic_name, p.project_name
317
- FROM t
318
- LEFT JOIN top ON t.topic = top.id
319
- LEFT JOIN p ON top.project = p.id
320
- `
173
+ query: 'SELECT title, priority FROM t WHERE priority = "High"'
321
174
  }
322
175
  ```
323
176
 
324
- ### User ID Username Conversion
177
+ **Finnish characters in `AS "..."` aliases are fine** — `AS "Päivämäärä"` works. Only bare identifiers (source `name`, CTE names) must stay ASCII.
325
178
 
326
- **IMPORTANT:** The `user` table is a **pseudo-table** in Hailer insights. You don't need to declare it as a source - just JOIN directly.
179
+ ---
327
180
 
328
- ```sql
329
- -- Convert user ID fields to display names
330
- SELECT
331
- t.title,
332
- COALESCE(u.name, '') AS Assignee_Name,
333
- COALESCE(m.name, '') AS Manager_Name
334
- FROM tasks t
335
- LEFT JOIN user AS u ON u._id = t.assignee
336
- LEFT JOIN user AS m ON m._id = t.manager
337
- ```
181
+ ## Date Handling
182
+
183
+ **Always run the probe first.** Summary:
184
+ - Custom date fields (fieldId-based): **seconds** (10-digit) — `convert()` divides unconditionally
185
+ - Activity meta (`created`, `updated`): **milliseconds** (13-digit)
186
+ - `strftime('%s', 'now')` returns seconds
338
187
 
339
- **Pattern for multiple user fields:**
340
188
  ```sql
341
- -- Each user field needs its own JOIN with unique alias
342
- LEFT JOIN user AS em ON em._id = t.event_manager
343
- LEFT JOIN user AS pm ON pm._id = t.production_manager
189
+ -- Field is seconds use directly
190
+ strftime('%Y-%m-%d', dateField, 'unixepoch') AS date_formatted
344
191
 
345
- -- Then select with COALESCE for clean output
346
- COALESCE(em.name, '') AS Event_Manager,
347
- COALESCE(pm.name, '') AS Production_Manager
192
+ -- Meta created/updated divide by 1000
193
+ strftime('%Y-%m-%d', created / 1000, 'unixepoch') AS date_formatted
348
194
  ```
349
195
 
350
- **Why COALESCE?** User fields can be empty - COALESCE returns empty string instead of NULL for cleaner display.
196
+ ### Range Fields (daterange, datetimerange, timerange)
351
197
 
352
- **Common mistake:** Trying to add `user` as a source - don't do this! It's built-in.
198
+ All range field types auto-expose `<name>Start` / `<name>End` suffixes in seconds. The bare field name is NOT selectable — selecting it produces `no such column`.
353
199
 
354
- ### JOIN Troubleshooting
200
+ ```sql
201
+ -- In sources: just the field name
202
+ { name: 'ajankohta', fieldId: Event_FieldIds.ajankohta_abc }
355
203
 
356
- | Error | Cause | Fix |
357
- |-------|-------|-----|
358
- | `no such column: c.id` | Missing `{ name: 'id', meta: '_id' }` in target source | Add `_id` meta to that source |
359
- | `no such column: p.club` | ActivityLink field not in source fields array | Add field with correct `fieldId` from `get_workflow_schema` |
360
- | NULL results for joined data | ActivityLink is empty for some activities (expected with LEFT JOIN) | Use INNER JOIN if you only want activities with relationships |
204
+ -- In queries: use Start/End suffixes
205
+ SELECT ajankohtaStart, ajankohtaEnd,
206
+ strftime('%d.%m.%Y', ajankohtaStart, 'unixepoch') as start_date
207
+ FROM events
208
+ ```
361
209
 
362
- ### JOIN Checklist
210
+ ### 1970-01 Diagnostic Checklist
363
211
 
364
- Before creating an insight with JOINs:
365
- - [ ] Both workflow sources include `{ name: 'id', meta: '_id' }`
366
- - [ ] ActivityLink field uses `fieldId` (NOT `key`)
367
- - [ ] JOIN condition uses column names from sources (e.g., `p.club = c.id`)
368
- - [ ] Using LEFT JOIN (unless relationship is required)
369
- - [ ] Tested with `preview_insight` first
212
+ 1. Shows `1970-01` dates likely dividing seconds by 1000, or null function-field value
213
+ 2. Correct in preview, wrong live function-field null across most activities (preview hit the few good rows)
214
+ 3. Huge negative numbers in date math mixing ms and seconds units
215
+ 4. Empty-string dates casting to 1970 `CAST('' AS INTEGER) = 0`. Guard: `NULLIF(field, '')`.
370
216
 
371
217
  ---
372
218
 
373
- ## Common Patterns
219
+ ## Filtering
374
220
 
375
- ### Monthly Trend (with seconds-based date)
376
221
  ```sql
377
- SELECT
378
- strftime('%Y-%m', created, 'unixepoch') as month,
379
- COUNT(*) as count,
380
- SUM(amount) as total
381
- FROM orders
382
- WHERE created > strftime('%s', 'now', '-12 months')
383
- GROUP BY month
384
- ORDER BY month
385
- ```
222
+ -- Filter on stable IDs, not human-editable names
223
+ WHERE phaseId = '${PhaseIds.won}' -- stable
224
+ -- NOT: WHERE phaseName = 'Won' -- breaks on rename
386
225
 
387
- ### Completion Rate
388
- ```sql
389
- SELECT
390
- ROUND(
391
- 100.0 * SUM(CASE WHEN phaseName = 'Done' THEN 1 ELSE 0 END) / COUNT(*),
392
- 1
393
- ) as completion_percent
394
- FROM tasks
395
- ```
226
+ -- Text match
227
+ WHERE priority = "High"
396
228
 
397
- ### Percentage of total (window function)
398
- ```sql
399
- SELECT
400
- phaseName,
401
- COUNT(*) as count,
402
- ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) as percent
403
- FROM tasks
404
- GROUP BY phaseName
405
- ```
229
+ -- NULL check
230
+ WHERE assignee IS NOT NULL
406
231
 
407
- ### CTE with Hailer date functions
408
- ```sql
409
- WITH
410
- monthly_sales AS (
411
- SELECT
412
- strftime('%Y-%m', created, 'unixepoch') as month,
413
- SUM(amount) as total
414
- FROM orders
415
- GROUP BY month
416
- ),
417
- monthly_avg AS (
418
- SELECT AVG(total) as avg_total FROM monthly_sales
419
- )
420
- SELECT
421
- ms.month,
422
- ms.total,
423
- CASE WHEN ms.total > ma.avg_total THEN 'Above' ELSE 'Below' END as vs_avg
424
- FROM monthly_sales ms, monthly_avg ma
425
- ORDER BY ms.month
232
+ -- IN list
233
+ WHERE phaseId IN ('${phase1}', '${phase2}')
234
+
235
+ -- LIKE pattern
236
+ WHERE title LIKE "%urgent%"
426
237
  ```
427
238
 
428
239
  ---
429
240
 
430
- ## Testing Queries
241
+ ## CASE Statements with strftime — Type Mismatch Trap
431
242
 
432
- Always preview before adding to insights.ts:
243
+ `strftime('%s', 'now')` returns **TEXT**, not integer. Comparing a numeric unix column against it inside CASE falls back to string ordering — no error, but wrong results.
433
244
 
434
- ```javascript
435
- mcp__hailer__preview_insight({
436
- sources: [...],
437
- query: "SELECT ..."
438
- })
245
+ ```sql
246
+ -- Dangerous: strftime returns TEXT, comparison is string ordering
247
+ CASE WHEN ts >= strftime('%s','now') THEN 'future' ELSE 'past' END
248
+
249
+ -- Safe: CAST to INTEGER first
250
+ CASE WHEN ts >= CAST(strftime('%s','now') AS INTEGER) THEN 'future' ELSE 'past' END
439
251
  ```
440
252
 
441
- If preview returns data without errors, add to `workspace/insights.ts`.
253
+ Same trap applies to `MIN(strftime('%s','now'), some_integer)` picks wrong winner via string ordering.
442
254
 
443
255
  ---
444
256
 
445
- ## Common Mistakes
446
-
447
- | Wrong | Right |
448
- |-------|-------|
449
- | Using `"` for identifiers | Use `"` for strings, backticks for identifiers if needed |
450
- | Forgetting `_id` meta for JOINs | Always include `{ name: 'id', meta: '_id' }` |
451
- | INNER JOIN for optional links | Use LEFT JOIN (links can be null) |
452
- | Raw timestamps in output | Format with `strftime()` |
453
- | Skipping preview | Always test with `preview_insight` first |
454
- | Hardcoding workflow IDs | Use `WorkflowIds` enum |
455
- | Generic field names (`date`, `id`) | Use real names (`pvm`, `nro`) that match Hailer UI |
456
- | Using `/ 1000` for dates | Insight fields are already SECONDS - use directly with `strftime()` |
457
- | Using `* 1000` in WHERE | Both sides are seconds - compare directly |
458
- | `t.ajankohta` for range fields | Use `t.ajankohtaStart` and `t.ajankohtaEnd` (auto-suffixed for daterange, datetimerange, timerange) |
257
+ ## Production Insight Guidelines
459
258
 
460
- ---
259
+ ```typescript
260
+ {
261
+ _id: "...",
262
+ name: "Bookings - Public (booking-app)", // Add (APP) suffix if used by app
263
+ query: `SELECT /* Used by booking-app - do not change field names! */
264
+ id, name, varattu_tila, ajankohtaStart, ajankohtaEnd
265
+ FROM varaukset`
266
+ }
267
+ ```
461
268
 
462
- ## Date Mistakes & What They Look Like
269
+ Before modifying production insights:
270
+ 1. Create a TEST duplicate with same sources
271
+ 2. Test changes on the duplicate
272
+ 3. Apply working changes to production
463
273
 
464
- ### Mistake 1: Using `/ 1000` (OLD HABIT - DON'T DO THIS!)
465
- ```sql
466
- -- WRONG (insight fields are already in seconds!)
467
- strftime('%Y-%m-%d', created / 1000, 'unixepoch')
468
- -- Result: "1970-01-21" (wrong - divided seconds by 1000!)
274
+ ---
469
275
 
470
- -- CORRECT (use field directly)
471
- strftime('%Y-%m-%d', created, 'unixepoch')
472
- -- Result: "2024-01-15"
473
- ```
276
+ ## Aggregations
474
277
 
475
- ### Mistake 2: Using `* 1000` in WHERE
476
278
  ```sql
477
- -- WRONG (multiplying seconds by 1000 makes comparison fail)
478
- WHERE created >= strftime('%s', 'now', '-30 days') * 1000
479
- -- 1705312800 >= 1705312800000 → always false!
279
+ SELECT phaseName, COUNT(*) as count
280
+ FROM tasks
281
+ GROUP BY phaseName
480
282
 
481
- -- CORRECT (both sides are seconds)
482
- WHERE created >= strftime('%s', 'now', '-30 days')
283
+ SELECT customerId, COUNT(*) as order_count
284
+ FROM orders
285
+ GROUP BY customerId
286
+ HAVING COUNT(*) > 5
483
287
  ```
484
288
 
485
- ### Mistake 3: timerange is NOT minutes
486
- ```sql
487
- -- WRONG (thinking timerange is minutes from midnight)
488
- WHERE tyoaikaStart < 480 -- "before 8am"
289
+ ---
489
290
 
490
- -- CORRECT (timerange is timestamp in seconds - extract hour)
491
- WHERE strftime('%H', tyoaikaStart, 'unixepoch') < '08'
492
- ```
291
+ ## GROUP BY Month
493
292
 
494
- ### Mistake 4: Timezone confusion
495
293
  ```sql
496
- -- WRONG (UTC time, Finland is +2/+3)
497
- strftime('%H:%M', created, 'unixepoch')
498
- -- Shows "08:30" when local time is "10:30"
294
+ -- meta created (ms) divide by 1000
295
+ SELECT strftime('%Y-%m', created / 1000, 'unixepoch') as month, COUNT(*) as count
296
+ FROM orders
297
+ GROUP BY month
298
+ ORDER BY month
499
299
 
500
- -- CORRECT (add timezone offset)
501
- strftime('%H:%M', created, 'unixepoch', '+2 hours')
300
+ -- custom date field (seconds) use directly
301
+ SELECT strftime('%Y-%m', closeDate, 'unixepoch') as month, COUNT(*) as count
302
+ FROM orders
303
+ GROUP BY month
502
304
  ```
503
-
504
- ### Quick Debug
505
- If your dates look wrong:
506
- 1. **Years like 1970** → using `/ 1000` when you shouldn't (field is already seconds)
507
- 2. **No rows returned** → using `* 1000` in WHERE (comparing seconds to huge number)
508
- 3. **Times off by 2-3 hours** → timezone not applied
509
- 4. **Duration calculations** → subtract seconds directly: `(endField - startField) / 60` for minutes