@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
|
@@ -1,509 +1,304 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sdk-insight-queries
|
|
3
|
-
description: SQL query patterns for Hailer insights
|
|
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
|
-
##
|
|
11
|
+
## TIMESTAMP UNITS: PROBE BEFORE ASSUMING
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The insight engine is **SQLite**. `strftime('%s', 'now')` returns **seconds** (10-digit unix).
|
|
14
14
|
|
|
15
|
-
**
|
|
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
|
-
|
|
21
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
34
|
+
**If field is milliseconds (e.g. `meta.created`):**
|
|
39
35
|
```sql
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
**
|
|
46
|
+
**Silent failure mode:** assuming seconds when field is ms → `strftime` returns year ~57259 or empty.
|
|
65
47
|
|
|
66
|
-
**
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
55
|
+
---
|
|
74
56
|
|
|
75
|
-
|
|
57
|
+
## Source Metadata — Every Column Must Be Declared
|
|
76
58
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
+
### Canonical Fixture
|
|
97
64
|
|
|
98
|
-
|
|
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
|
-
|
|
67
|
+
```javascript
|
|
68
|
+
// sources[0].fields[] — declare every column you'll SELECT
|
|
108
69
|
fields: [
|
|
109
|
-
{ name: 'activityId',
|
|
110
|
-
{ name: '
|
|
111
|
-
{ name: '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
+
---
|
|
140
115
|
|
|
141
|
-
|
|
116
|
+
## Insight Design — Pick the Shape Deliberately
|
|
142
117
|
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
137
|
+
| Source `name` | Result |
|
|
138
|
+
|---|---|
|
|
139
|
+
| `date`, `store`, `time`, `user` | ✅ safe |
|
|
140
|
+
| `order`, `group`, `select`, `where`, `from` | ❌ breaks table creation |
|
|
190
141
|
|
|
191
|
-
|
|
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
|
-
##
|
|
146
|
+
## Aggregate NULL Behavior
|
|
213
147
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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: '
|
|
167
|
+
{ name: 'title', meta: 'name' },
|
|
302
168
|
{ name: 'id', meta: '_id' },
|
|
303
|
-
{ name: '
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
---
|
|
327
180
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
--
|
|
342
|
-
|
|
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
|
-
--
|
|
346
|
-
|
|
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
|
-
|
|
196
|
+
### Range Fields (daterange, datetimerange, timerange)
|
|
351
197
|
|
|
352
|
-
|
|
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
|
-
|
|
200
|
+
```sql
|
|
201
|
+
-- In sources: just the field name
|
|
202
|
+
{ name: 'ajankohta', fieldId: Event_FieldIds.ajankohta_abc }
|
|
355
203
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
###
|
|
210
|
+
### 1970-01 Diagnostic Checklist
|
|
363
211
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
-
|
|
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
|
-
##
|
|
219
|
+
## Filtering
|
|
374
220
|
|
|
375
|
-
### Monthly Trend (with seconds-based date)
|
|
376
221
|
```sql
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
##
|
|
241
|
+
## CASE Statements with strftime — Type Mismatch Trap
|
|
431
242
|
|
|
432
|
-
|
|
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
|
-
```
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
253
|
+
Same trap applies to `MIN(strftime('%s','now'), some_integer)` — picks wrong winner via string ordering.
|
|
442
254
|
|
|
443
255
|
---
|
|
444
256
|
|
|
445
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
279
|
+
SELECT phaseName, COUNT(*) as count
|
|
280
|
+
FROM tasks
|
|
281
|
+
GROUP BY phaseName
|
|
480
282
|
|
|
481
|
-
|
|
482
|
-
|
|
283
|
+
SELECT customerId, COUNT(*) as order_count
|
|
284
|
+
FROM orders
|
|
285
|
+
GROUP BY customerId
|
|
286
|
+
HAVING COUNT(*) > 5
|
|
483
287
|
```
|
|
484
288
|
|
|
485
|
-
|
|
486
|
-
```sql
|
|
487
|
-
-- WRONG (thinking timerange is minutes from midnight)
|
|
488
|
-
WHERE tyoaikaStart < 480 -- "before 8am"
|
|
289
|
+
---
|
|
489
290
|
|
|
490
|
-
|
|
491
|
-
WHERE strftime('%H', tyoaikaStart, 'unixepoch') < '08'
|
|
492
|
-
```
|
|
291
|
+
## GROUP BY Month
|
|
493
292
|
|
|
494
|
-
### Mistake 4: Timezone confusion
|
|
495
293
|
```sql
|
|
496
|
-
--
|
|
497
|
-
strftime('%
|
|
498
|
-
|
|
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
|
-
--
|
|
501
|
-
strftime('%
|
|
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
|