@clipboard-health/ai-rules 2.29.2 → 2.29.3
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/package.json +1 -1
- package/skills/metabase-to-hex/SKILL.md +444 -0
- package/skills/metabase-to-hex/references/cbh_stack.md +123 -0
- package/skills/metabase-to-hex/references/gotchas.md +454 -0
- package/skills/metabase-to-hex/references/yaml_schemas.md +281 -0
- package/skills/metabase-to-hex/scripts/audit_vars.sh +26 -0
- package/skills/metabase-to-hex/scripts/build_layout.py +227 -0
- package/skills/metabase-to-hex/scripts/create_cells.sh.tmpl +41 -0
- package/skills/metabase-to-hex/scripts/inventory.py +120 -0
- package/skills/metabase-to-hex/scripts/rename_cells.py +88 -0
package/package.json
CHANGED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: metabase-to-hex
|
|
3
|
+
description: >
|
|
4
|
+
Migrate a Metabase dashboard or single card/question to a Hex app at Clipboard Health — extract
|
|
5
|
+
the source structure via the Metabase MCP, rewrite SQL onto core dbt models, scaffold the Hex
|
|
6
|
+
project with the CLI, and finish the Input cells / markdown / appLayout via a YAML round-trip.
|
|
7
|
+
Use this whenever the user asks to migrate, port, rebuild, or recreate a Metabase dashboard or
|
|
8
|
+
card in Hex, asks to make a Hex version of a Metabase question, mentions a Metabase dashboard
|
|
9
|
+
or `/question/` URL alongside Hex, wants to recreate Metabase parameter filtering as Hex Input
|
|
10
|
+
cells, or wants to generalize multiple migrated cards into one parametrized app. Also use when
|
|
11
|
+
the user references a previous migration ("like the WOPs Tool one", "same pattern as the
|
|
12
|
+
Payments Dashboard") and wants the playbook applied to a new dashboard or card.
|
|
13
|
+
allowed-tools: Bash, Read, Edit, Write
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Metabase → Hex Migration Playbook
|
|
17
|
+
|
|
18
|
+
Migrate a Metabase dashboard to a Hex app following the pattern that worked for the WOPs Tool dashboard (1898 → Hex `019e08cb-79c0-7000-84c7-003f187d2669`). The Payments Team dashboard (1242 → Hex `019e275f-50bf-7000-8043-ad906145f55c`) is a partial reference — its Logic view is solid but its App view is blank because the bootstrap step (6b) was skipped. The three hardest parts are programmatically building the App view (which requires a one-time manual UI step — see Phase 6b), avoiding silent Jinja failures from undefined variables, and reusing rewritten SQL across migrations without column drift.
|
|
19
|
+
|
|
20
|
+
Most of this skill is about working around quirks of the Hex CLI and YAML import path. Read the relevant references when you hit each phase.
|
|
21
|
+
|
|
22
|
+
## Phases
|
|
23
|
+
|
|
24
|
+
1. **Inventory** — pull the source dashboard, save the JSON, parse tabs/parameters/cards
|
|
25
|
+
2. **SQL extraction** — for MBQL cards, compile to SQL via `execute_card`
|
|
26
|
+
3. **SQL rewrites** — prefer `DBT_PRODUCTION_CORE` core models over legacy `APP_*_STG` staging
|
|
27
|
+
4. **Project scaffold** — `hex project create`, capture the projectId
|
|
28
|
+
5. **SQL cells** — `hex cell create` for each query card
|
|
29
|
+
6a. **YAML round-trip — Inputs & Markdown** — add INPUT / MARKDOWN cells via `hex project import` (still works for adding cells; layout import alone won't make them appear in the App view)
|
|
30
|
+
6b. **App canvas bootstrap (manual UI step, required)** — ask the user to open the Hex App builder and drag any one cell onto the canvas. Without this, every subsequent `appLayout` import is silently ignored and the App view stays blank. See gotcha 9.
|
|
31
|
+
6c. **YAML round-trip — appLayout** — only after 6b, export, build the real `appLayout`, re-import. Hex now treats the import as an _update_ to an initialized canvas and applies it.
|
|
32
|
+
6. **Verify** — `hex project run`, then UI review (CLI does not surface per-cell errors)
|
|
33
|
+
|
|
34
|
+
Work in a dedicated directory like `/home/alex/pii-migrations/dashboard_<id>/` with subfolders `queries/`, `hex_cells/`, `artifacts/`. Save intermediate JSON/YAML so you can resume after errors.
|
|
35
|
+
|
|
36
|
+
## Phase 1 — Inventory the source dashboard
|
|
37
|
+
|
|
38
|
+
Pull the dashboard. Save the raw response to a working file because the JSON is large (~500KB for a 20-tab dashboard) — don't try to keep it in conversation context.
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
# Call mcp__metabase-cognitionai__get_dashboard with dashboard_id=<n>.
|
|
42
|
+
# The MCP saves the full response to a file when it exceeds the context budget.
|
|
43
|
+
# Copy that file into your working dir as dashboard_<id>.json.
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
If `metabase-cognitionai` returns 401 across the board, the credential is having one of its periodic refresh hiccups — wait a few minutes or use `metabase-server` as fallback.
|
|
47
|
+
|
|
48
|
+
Then parse:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
import json
|
|
52
|
+
with open('dashboard_1242.json') as f:
|
|
53
|
+
d = json.load(f)
|
|
54
|
+
|
|
55
|
+
tabs = {t['id']: t['name'] for t in d.get('tabs', [])}
|
|
56
|
+
# parameters[]: slug, name, type, default
|
|
57
|
+
# dashcards[]: card.id, card.name, card.display, dashboard_tab_id,
|
|
58
|
+
# card.dataset_query.type ('native' or 'query'),
|
|
59
|
+
# parameter_mappings[].parameter_id (map to parameters[].slug)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For each dashcard, you want: `card.id`, `card.name`, `tab_id`, `display`, query type, table refs, parameter slugs. Also collect text cards (the ones without `card.id`) — those have markdown in `visualization_settings.text`.
|
|
63
|
+
|
|
64
|
+
Use `scripts/inventory.py` as a starting point — it walks the JSON and prints the structured per-tab card list.
|
|
65
|
+
|
|
66
|
+
## Phase 2 — Extract SQL for MBQL cards
|
|
67
|
+
|
|
68
|
+
Native cards have `dataset_query.native.query` directly. MBQL cards don't — you need the compiled SQL from `execute_card`.
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
# mcp__metabase-server__execute_card with card_id=<n>.
|
|
72
|
+
# Read data.native_form.query from the response.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Schema bug:** the `parameters` field is typed as an object in the MCP schema but the server insists on a list. If the MCP UI requires a value, pass `[]`. Otherwise omit. Calling with `{}` returns "Assert failed: (u/maybe? sequential? parameters)".
|
|
76
|
+
|
|
77
|
+
Save each as `queries/<card_id>_<slug>.sql` for reference.
|
|
78
|
+
|
|
79
|
+
## Phase 3 — Rewrite SQL onto core dbt models
|
|
80
|
+
|
|
81
|
+
The Metabase native*form points at the table the original card was built on — frequently the legacy `DBT_PRODUCTION.APP*\*\_STG` tables. Rewrite these to prefer:
|
|
82
|
+
|
|
83
|
+
| Legacy / staging | Replacement |
|
|
84
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
85
|
+
| `APP_EXCLUSIONS_STG` | `DBT_PRODUCTION_CORE.FCT_EXCLUSIONS` |
|
|
86
|
+
| `APP_AGENTPROFILES_STG`, `APP_PII_AGENTPROFILES_STG` | `DBT_PRODUCTION_CORE.DIM_WORKERS` |
|
|
87
|
+
| `STG_APP__FACILITY_PROFILES`, facility lookups | `DBT_PRODUCTION_CORE.DIM_WORKPLACES` |
|
|
88
|
+
| `APP_SHIFTLOGS_STG` / shift state changes | `DBT_PRODUCTION_CORE.FCT_SHIFT_LOGS` |
|
|
89
|
+
| `APP_SHIFTGEOFENCEEVENTS_STG` (arrival/exit) | `DBT_PRODUCTION_CORE.FCT_SHIFTS` (`ARRIVED_FACILITY_GEOFENCE_AT`, `LEFT_FACILITY_GEOFENCE_AT`, `CLOCK_OUT_AT`) |
|
|
90
|
+
| `APP_FACILITYCANCELLEDMEREQUESTS_STG` | `DBT_PRODUCTION.STG_APP__FACILITY_CANCELLED_ME_REQUESTS` (column renames: `DELETED→IS_DELETED`, `AT_FACILITY→IS_AT_FACILITY`, `ID→FACILITY_CANCELLED_ME_REQUEST_ID`; no `FACILITY_ID`) |
|
|
91
|
+
| `APP_BONUSESPAYMENTS_STG` | `DBT_PRODUCTION.STG_APP__BONUSES_PAYMENTS` (preserves `AGENT_ID`, `REASON`, `SHIFT_ID`) |
|
|
92
|
+
|
|
93
|
+
When there's no core fact/dim, fall back to the newer `DBT_PRODUCTION.STG_APP__*` staging models — not the legacy `APP_*_STG` ones.
|
|
94
|
+
|
|
95
|
+
Before pushing, **verify column existence in Snowflake** via `INFORMATION_SCHEMA.COLUMNS` — the Metabase MCP doesn't surface dbt column changes, so guessed columns will break silently. See `references/cbh_stack.md` for the schemas list and connection IDs.
|
|
96
|
+
|
|
97
|
+
**Reuse SQL across migrations.** If a previous migration (e.g. WOPs Tool at `/home/alex/wops_migration/hex_cells_v3/`) already has a rewritten version of the same card, copy it — but audit its Jinja variables against the new project's inputs before reusing (see gotcha 1 below).
|
|
98
|
+
|
|
99
|
+
## Phase 4 — Scaffold the Hex project
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
hex project create "<title>" -d "<description>" --json
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Capture the returned `id` (the projectId). Save it to `artifacts/project.env` for the rest of the run.
|
|
106
|
+
|
|
107
|
+
Hex workspace: `e673b166-274e-4db9-972d-badc91dbfe1b`. Dev Snowflake connection: `snowflake analytics` (id `530b70b8-b300-43c9-9b3e-e4b98ded0379`). The PII service account silently returns empty when run as alex — only use it at publish time, never for draft work. See `references/cbh_stack.md`.
|
|
108
|
+
|
|
109
|
+
## Phase 5 — Create SQL cells
|
|
110
|
+
|
|
111
|
+
Loop over your rewritten `.sql` files and call `hex cell create` for each:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
hex cell create "$PROJECT_ID" \
|
|
115
|
+
-t sql \
|
|
116
|
+
-s "$(cat path/to/card.sql)" \
|
|
117
|
+
-l "Card label exactly as it should appear" \
|
|
118
|
+
--data-connection-id "530b70b8-b300-43c9-9b3e-e4b98ded0379" \
|
|
119
|
+
--output-dataframe "snake_case_slug" \
|
|
120
|
+
--json
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
A working bash loop with label and dataframe maps is in `scripts/create_cells.sh.tmpl` — adapt it per dashboard.
|
|
124
|
+
|
|
125
|
+
`hex cell update` requires `-t <type>` even when only changing the connection. There is **no `--label` flag** — to rename, use the cellId swap in Phase 6. To delete a scratch/debug cell, use `hex cell delete <dynamic-id>`.
|
|
126
|
+
|
|
127
|
+
## Phase 6 — YAML round-trip (Inputs, Markdown, appLayout)
|
|
128
|
+
|
|
129
|
+
The Hex CLI cannot create INPUT cells, place cells in the App view, or order tabs. Two parts of this must go through YAML import, and **one part requires a manual UI step before YAML import will work**. Don't skip 6b — it's the failure mode that ate ~45 minutes on the dashboard 2769 migration.
|
|
130
|
+
|
|
131
|
+
### Phase 6a — Inputs & Markdown via YAML
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
hex project export "$PROJECT_ID" -o exported.yaml
|
|
135
|
+
# ...edit YAML in python: add INPUT cells, MARKDOWN cells...
|
|
136
|
+
hex project import import_ready.yaml
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
A complete builder script template is at `scripts/build_layout.py`. The high-level shape:
|
|
140
|
+
|
|
141
|
+
1. Load the YAML with `yaml.safe_load`.
|
|
142
|
+
2. Build a `cellLabel → cellId` map from the existing SQL cells (so layout can reference them).
|
|
143
|
+
3. Append new cells to `doc['cells']`:
|
|
144
|
+
- **N INPUT cells** — one per dashboard parameter. Schema must be exact (see `references/yaml_schemas.md`).
|
|
145
|
+
- **N MARKDOWN cells** — one per text/instruction card from the Metabase dashboard.
|
|
146
|
+
4. **Sort `doc['cells']` so `INPUT` cells come first, then `MARKDOWN`, then `SQL`.** This is critical for Jinja resolution — see gotcha 2.
|
|
147
|
+
5. `yaml.safe_dump(..., allow_unicode=True, width=4096, sort_keys=False)`. Don't change `meta.projectId` or `meta.sourceVersionId`.
|
|
148
|
+
6. Import. Re-list cells if you need their new IDs — they change on every import.
|
|
149
|
+
|
|
150
|
+
At this point the Logic view should be fully populated. The App view will still be blank. That's expected — proceed to 6b.
|
|
151
|
+
|
|
152
|
+
**Dropdown backed by a SQL cell.** When the Metabase parameter was a "values from a query" parameter (or you just want the Hex equivalent), the Hex INPUT shape is a single-select `DROPDOWN` whose options reference another SQL cell's output dataframe by name:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
- cellType: INPUT
|
|
156
|
+
cellId: <uuidv7>
|
|
157
|
+
cellLabel: MSA list
|
|
158
|
+
config:
|
|
159
|
+
inputType: DROPDOWN
|
|
160
|
+
name: msa_list
|
|
161
|
+
outputType: DYNAMIC
|
|
162
|
+
options:
|
|
163
|
+
valueOptions: { dfName: msa_options, variableName: null }
|
|
164
|
+
optional: false
|
|
165
|
+
defaultValue: null
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Create the options SQL cell first (it must produce a dataframe whose first column becomes the dropdown values — e.g. `SELECT DISTINCT MSA FROM ... ORDER BY MSA`). Then add the INPUT cell pointing at it via `dfName: <that_cell's_resultVariableName>`. Run the project once after import so the options dataframe materializes — until it does, the dropdown is empty.
|
|
169
|
+
|
|
170
|
+
**Multi-select is not settable via YAML.** Hex's import API rejects every multi-select variant I've tried (`MULTI_SELECT`, `MULTI_SELECT_INPUT`, `DROPDOWN + multiSelect: true`, `DROPDOWN + multipleSelect: true`) with the generic "malformed" error. The working pattern is: push this exact single-select `DROPDOWN` shape, then ask the user to flip the multi-select toggle in the cell's right-sidebar config. The SQL is forward-compatible if you use `WHERE col IN ({{ var | array }})` — see gotcha 10. The `| array` filter is a no-op for single-select, so the SQL doesn't need to change when the toggle flips.
|
|
171
|
+
|
|
172
|
+
**Date and date-range inputs follow the same UI-flip pattern.** YAML import also rejects every date variant (`DATE_PICKER`, `DATE_INPUT`, `DATE`, `DATE_RANGE`, `DATE_RANGE_PICKER`, `DATERANGE`). Push a `TEXT_INPUT` placeholder with the snake*case `name` the SQL will reference, then ask the user to open the cell and change **Input type** to `Date` (and toggle **Allow date range** for ranges). For a \_date-range* input, Hex doesn't bind the cell's `name` — it injects two separate variables suffixed `_start` and `_end` (both `datetime.date`). Reference them as `{{ <name>_start }}` and `{{ <name>_end }}`. Referencing the base `name` (or `.start`/`.end`/`[0]`/`[1]` on it) fails with `Undefined variable(s): <name>` because the base is literally not in scope. See gotcha 18 for the diagnostic-cell recipe that took 2 minutes to confirm vs. an hour of guessing. Plain `TEXT_INPUT` + `CAST({{ var }} AS TIMESTAMP_NTZ)` still works if the user doesn't need a calendar UX.
|
|
173
|
+
|
|
174
|
+
### Phase 6b — App canvas bootstrap (manual, user must do this)
|
|
175
|
+
|
|
176
|
+
**This step is required for any project that has never had its App view manually touched.** `hex project import` will not create an App canvas from scratch — it can only update one that already exists.
|
|
177
|
+
|
|
178
|
+
Stop and tell the user:
|
|
179
|
+
|
|
180
|
+
> Open `https://app.hex.tech/<workspace>/hex/<project-id>/draft/logic?view=app` in your browser. Drag any one cell from the cell tray on the left onto the canvas. This initializes Hex's draft-app state. Let me know when you've done that and I'll import the rest of the layout.
|
|
181
|
+
|
|
182
|
+
Do not proceed to 6c until the user confirms. The cell they drag can be any cell; it doesn't need to land in its final position — you'll overwrite the canvas with the real layout in 6c.
|
|
183
|
+
|
|
184
|
+
**How to recognize you're hitting this gotcha:** if you've imported a YAML with a structurally valid `appLayout` (verified by re-exporting and checking the cellIds resolve), `hex project run` completes successfully, the Logic view shows all cells, but `?view=app` is blank — it's almost always missing the 6b bootstrap. See gotcha 9.
|
|
185
|
+
|
|
186
|
+
### Phase 6c — appLayout via YAML round-trip
|
|
187
|
+
|
|
188
|
+
Only after 6b is done:
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
hex project export "$PROJECT_ID" -o exported.yaml
|
|
192
|
+
# ...edit YAML in python: build appLayout.tabs[].rows...
|
|
193
|
+
hex project import import_ready.yaml
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Build `doc['appLayout']['tabs']` — one tab per Metabase tab, with:
|
|
197
|
+
|
|
198
|
+
- **Top row**: only the Input cells _this tab's cards use_ (mirrors Metabase's per-tab parameter mapping). Side-by-side: columns span 0-120 evenly.
|
|
199
|
+
- **Body rows**: markdown + SQL output cells in dashcard `(row, col)` order, full-width (`start: 0, end: 120`).
|
|
200
|
+
|
|
201
|
+
Include `visibleMetadataFields` and `fullWidth: true` at the top of `appLayout` (Hex's export emits these — keep them):
|
|
202
|
+
|
|
203
|
+
```yaml
|
|
204
|
+
appLayout:
|
|
205
|
+
visibleMetadataFields: [NAME, DESCRIPTION, AUTHOR, LAST_EDITED, LAST_RUN, CATEGORIES, STATUS, TABLE_OF_CONTENTS]
|
|
206
|
+
fullWidth: true
|
|
207
|
+
tabs:
|
|
208
|
+
- name: Overview
|
|
209
|
+
rows: [...]
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Tell the user to hard-refresh the App tab after the import — Hex's UI caches the prior draft-app state.
|
|
213
|
+
|
|
214
|
+
## Phase 7 — Verify
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
hex project run "$PROJECT_ID" --json
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The CLI does **not** surface per-cell errors. You must open the project in the browser to see which cells errored. The URL is in the run response. Common failure: "Undefined variable(s): X" — see gotchas 1 and 2.
|
|
221
|
+
|
|
222
|
+
If a few cells need surgical fixes, edit the local `.sql`, then push individual cells with `hex cell update <dynamic-id> -t sql -s "$(cat path/to/card.sql)"`. Get the current dynamic ids from `hex cell list --json`; for larger projects (or as ground truth) prefer `hex project export` since `hex cell list` is paginated. The `cellId` in the export is the _staticId_, not the dynamic id — see gotcha 6.
|
|
223
|
+
|
|
224
|
+
Also run the SQL manually in Snowflake with substituted variable values for at least one card per tab — `mcp__snowflake-mcp__sql_exec_tool` works for this. Validates the rewrite before the user discovers it in the UI.
|
|
225
|
+
|
|
226
|
+
## Critical gotchas (read before you start)
|
|
227
|
+
|
|
228
|
+
These are the failure modes that will eat the most time if you don't internalize them.
|
|
229
|
+
|
|
230
|
+
### 1. Every Jinja variable in SQL must exist as a project Input
|
|
231
|
+
|
|
232
|
+
Hex Jinja errors with `Undefined variable(s): X` if `{% if X %}` or `{{ X }}` references a variable that isn't defined by an Input cell in the project. This bites hard when you copy SQL from another Hex project — the variable names from the old project (e.g., `worker_id`, `workplace_id`, `msa`) leak in.
|
|
233
|
+
|
|
234
|
+
**Audit before importing:**
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
for f in hex_cells/*.sql; do
|
|
238
|
+
vars=$(grep -oE '\{[%{][- ]*(if +[a-z_]+|[a-z_]+) [- ]*[%}]\}' "$f" \
|
|
239
|
+
| grep -oE '[a-z_]+' \
|
|
240
|
+
| grep -v -E '^(if|else|elif|endif|endfor|for|in)$' \
|
|
241
|
+
| sort -u | tr '\n' ',' | sed 's/,$//')
|
|
242
|
+
echo "$(basename $f): $vars"
|
|
243
|
+
done
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Cross-reference against your project's Input cell `config.name` values. Rename or drop any that don't match. Empty input values are fine — Hex substitutes `NULL` for `{{ var }}` when the input is empty.
|
|
247
|
+
|
|
248
|
+
### 2. Cell order: INPUT must come before SQL in `doc['cells']`
|
|
249
|
+
|
|
250
|
+
Even with correct `config.name`, INPUT cells placed below SQL cells in the notebook produce `Undefined variable(s)` errors at run time — Hex resolves Jinja top-to-bottom. After all your YAML edits, stable-sort by type:
|
|
251
|
+
|
|
252
|
+
```python
|
|
253
|
+
priority = {'INPUT': 0, 'MARKDOWN': 1, 'SQL': 2}
|
|
254
|
+
doc['cells'].sort(key=lambda c: priority.get(c.get('cellType'), 3))
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
The WOPs Tool project keeps inputs at the very top, sometimes wrapped in a `COLLAPSIBLE`. The simple sort above is sufficient.
|
|
258
|
+
|
|
259
|
+
### 3. Hex SQL Jinja — never quote `{{ var }}`
|
|
260
|
+
|
|
261
|
+
Hex auto-wraps string inputs in single quotes during substitution. Writing `WHERE col = '{{ shift_id }}'` confuses the parser and emits a Snowflake `?` placeholder (string literal) — the query runs but matches nothing, silently. Always write `WHERE col = {{ shift_id }}`.
|
|
262
|
+
|
|
263
|
+
For Metabase optional clauses `[[AND col = {{var}}]]`, translate to `{% if var %}AND col = {{ var }}{% endif %}` — again, no quotes around `{{ var }}`.
|
|
264
|
+
|
|
265
|
+
### 4. INPUT cell YAML schema — `options: null` is required
|
|
266
|
+
|
|
267
|
+
```yaml
|
|
268
|
+
- cellType: INPUT
|
|
269
|
+
cellId: <uuidv7>
|
|
270
|
+
cellLabel: Shift ID
|
|
271
|
+
config:
|
|
272
|
+
inputType: TEXT_INPUT
|
|
273
|
+
name: shift_id
|
|
274
|
+
outputType: STRING
|
|
275
|
+
options: null # required — omitting it makes import fail with generic "malformed"
|
|
276
|
+
defaultValue: ""
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
Do **not** add a `label:` key inside `config` — only `cellLabel` at the cell level. The full cell shape reference is in `references/yaml_schemas.md`.
|
|
280
|
+
|
|
281
|
+
### 5. `cellLabel` is immutable on YAML import — rename via cellId swap
|
|
282
|
+
|
|
283
|
+
Changing a `cellLabel` in the YAML and re-importing is silently ignored, even if you also modify the cell's `source`. The CLI has no `--label` flag either. To actually rename:
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
# in your YAML editor script
|
|
287
|
+
old_id = c['cellId']
|
|
288
|
+
new_id = uuidv7() # fresh UUID
|
|
289
|
+
c['cellId'] = new_id
|
|
290
|
+
c['cellLabel'] = new_label
|
|
291
|
+
# remove the cell with old_id from doc['cells'] (or skip it during rebuild)
|
|
292
|
+
# rewrite every appLayout element that referenced old_id:
|
|
293
|
+
for tab in doc['appLayout']['tabs']:
|
|
294
|
+
for row in tab.get('rows', []):
|
|
295
|
+
for col in row.get('columns', []):
|
|
296
|
+
for el in col.get('elements', []):
|
|
297
|
+
if el.get('cellId') == old_id:
|
|
298
|
+
el['cellId'] = new_id
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Then import once. Hex sees the old cellId vanish (deletes it) and the new cellId appear (creates it with the new label). A complete script is at `scripts/rename_cells.py`.
|
|
302
|
+
|
|
303
|
+
### 6. `hex cell update` wants the dynamic id from `hex cell list`, not the staticId
|
|
304
|
+
|
|
305
|
+
Hex tracks every cell with two IDs that are easy to mix up:
|
|
306
|
+
|
|
307
|
+
- **`staticId`** — the canonical ID. Returned in the `staticId` field of `hex cell create`'s JSON response, and shown as `cellId:` in `hex project export`. Stable across imports.
|
|
308
|
+
- **`id`** (the dynamic id) — the per-version handle. Returned in the `id` field of `hex cell create` and listed by `hex cell list`. **Changes on every YAML import.**
|
|
309
|
+
|
|
310
|
+
`hex cell update <id>` only accepts the dynamic `id`. Passing the `staticId` (or any stale dynamic id from before the last import) returns a misleading `Forbidden` — it's effectively a 404. The fix is always: run `hex cell list <project-id> --json` to fetch fresh dynamic ids, then update.
|
|
311
|
+
|
|
312
|
+
Anything you cached from `hex cell create` _before_ a YAML import is also dead — the create call's dynamic `id` was invalidated by the subsequent import. Re-list, then update.
|
|
313
|
+
|
|
314
|
+
### 7. Don't change `meta.projectId` or `meta.sourceVersionId`
|
|
315
|
+
|
|
316
|
+
These are how Hex matches the import to the existing project. If you change them, Hex creates a new project (or refuses). Leave them alone.
|
|
317
|
+
|
|
318
|
+
### 9. App view stays blank after YAML import — needs UI bootstrap
|
|
319
|
+
|
|
320
|
+
**Symptom:** You imported a YAML with a well-formed `appLayout` (all cellIds resolve, structurally matches working dashboards). `hex project run` completes successfully. Logic view shows all cells. But `?view=app` is blank — no inputs, no charts, no markdown.
|
|
321
|
+
|
|
322
|
+
**Cause:** `hex project import` can only _update_ an existing App canvas — it can't bootstrap one from scratch. If no human has ever dragged a cell onto the canvas in this project, every `appLayout` import is silently ignored. The Payments Team dashboard demonstrates this: the YAML has a valid `appLayout`, the import succeeds, but the App view is permanently blank.
|
|
323
|
+
|
|
324
|
+
**Confirmed by experiment** (dashboard 2769 migration, 2026-05-15):
|
|
325
|
+
|
|
326
|
+
1. Cloned the WOPs Tool YAML into a brand new Hex project — App view blank.
|
|
327
|
+
2. Cloned the Payments YAML into a brand new project — App view blank.
|
|
328
|
+
3. Original Payments project — App view _also_ blank (never bootstrapped).
|
|
329
|
+
4. Only the WOPs Tool's original project renders, because its canvas was first built by hand and the YAML round-trip only _added_ SQL cells to the existing layout.
|
|
330
|
+
|
|
331
|
+
**Fix:** Stop after the cells are imported. Tell the user to open the App builder (`?view=app`) and drag any single cell onto the canvas. After they confirm, re-export, build the real `appLayout`, and re-import. See Phase 6b.
|
|
332
|
+
|
|
333
|
+
### 8. `yaml.safe_dump` — `allow_unicode=True, width=4096`
|
|
334
|
+
|
|
335
|
+
Em-dashes and other unicode in SQL comments / markdown / SOP text get escaped without `allow_unicode=True`, which Hex's parser then rejects. Without `width=4096`, long SQL lines wrap, which also breaks the parser. Use both flags every time:
|
|
336
|
+
|
|
337
|
+
```python
|
|
338
|
+
yaml.safe_dump(doc, f, allow_unicode=True, width=4096, sort_keys=False)
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### 10. `| array` Jinja filter required for multi-select inputs in SQL
|
|
342
|
+
|
|
343
|
+
**Symptom:** A SQL cell that references a multi-select dropdown's variable errors at run time with:
|
|
344
|
+
|
|
345
|
+
```text
|
|
346
|
+
MissingArrayFilterError: Got a list, tuple, or set.
|
|
347
|
+
Did you forget to apply '| array' to your query?
|
|
348
|
+
For example: `{{ list_of_values | array }}`
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
**Cause:** Hex auto-quotes scalar string inputs but refuses to silently choose a serialization for lists — multi-select outputs are lists, and the developer must opt in to the `array` filter, which expands the list to comma-separated quoted scalars (`'A', 'B', 'C'`).
|
|
352
|
+
|
|
353
|
+
**Fix:** Use the filter inside an `IN (...)` clause:
|
|
354
|
+
|
|
355
|
+
```sql
|
|
356
|
+
WHERE w.MSA IN ({{ msa_list | array }})
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
This is the same `{{ var }}` slot that gotcha 3 says _not_ to quote — the parens belong to the SQL (`IN (...)`), not around the Jinja. The `| array` filter is a no-op for single-select scalars, so the SQL stays forward-compatible if the input later gets toggled between single- and multi-select.
|
|
360
|
+
|
|
361
|
+
**Edge — empty selection:** `{% if msa_list %}…{% else %}AND 1=0{% endif %}` still works — Jinja treats both `None` and an empty list `[]` as falsy.
|
|
362
|
+
|
|
363
|
+
**Confirmed by experiment** on the generalized supply drilldown (`019e64b0-a730-7000-807c-5debd510361e`) on 2026-05-26.
|
|
364
|
+
|
|
365
|
+
## Layout shape cheatsheet
|
|
366
|
+
|
|
367
|
+
```yaml
|
|
368
|
+
appLayout:
|
|
369
|
+
fullWidth: true
|
|
370
|
+
tabs:
|
|
371
|
+
- name: Main
|
|
372
|
+
rows:
|
|
373
|
+
# Inputs side-by-side at top
|
|
374
|
+
- columns:
|
|
375
|
+
- { start: 0, end: 40, elements: [<cell-element>] } # shift_id input
|
|
376
|
+
- { start: 40, end: 80, elements: [<cell-element>] } # hcp_id input
|
|
377
|
+
- { start: 80, end: 120, elements: [<cell-element>] } # facility_id input
|
|
378
|
+
# Full-width body row
|
|
379
|
+
- columns:
|
|
380
|
+
- { start: 0, end: 120, elements: [<cell-element>] }
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Cell element shape (use exactly this):
|
|
384
|
+
|
|
385
|
+
```python
|
|
386
|
+
{
|
|
387
|
+
"showSource": False, "hideOutput": False, "type": "CELL",
|
|
388
|
+
"cellId": "<id>", "sharedFilterId": None, "height": None,
|
|
389
|
+
"showLabel": True, "explorable": None,
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Columns span 0–120. Common splits: full width (0–120), halves (0–60, 60–120), thirds (0–40, 40–80, 80–120).
|
|
394
|
+
|
|
395
|
+
## Per-tab Inputs (the why)
|
|
396
|
+
|
|
397
|
+
Each Hex tab should expose **only** the Input cells used by SQL cells on that tab. This mirrors Metabase's per-tab parameter mapping and keeps the UI tight — agents don't get a global filter bar with 11 inputs when the tab only uses 1.
|
|
398
|
+
|
|
399
|
+
In your YAML editor, build `TAB_INPUTS` from each Metabase tab's `dashcards[].parameter_mappings[]`. Don't hardcode a global input set.
|
|
400
|
+
|
|
401
|
+
## Workflow variants
|
|
402
|
+
|
|
403
|
+
The playbook above assumes a multi-tab dashboard. Two adjacent workflows reuse most of the same pieces but compress or extend specific phases.
|
|
404
|
+
|
|
405
|
+
### Single-card migrations (one Metabase question → one Hex app)
|
|
406
|
+
|
|
407
|
+
When the user points at a Metabase _question/card_ URL (`/question/<id>-...`) instead of a _dashboard_ URL:
|
|
408
|
+
|
|
409
|
+
- **Phase 1** collapses to one `mcp__metabase-cognitionai__get_card` call. No tabs to enumerate.
|
|
410
|
+
- **Phase 2** is trivial when `query_type` is `"native"` — read `dataset_query.stages[0].native` directly. Still call `execute_card` for MBQL cards.
|
|
411
|
+
- **Phase 6a** often has no INPUT cells (single cards usually have `parameters: []`). Just a markdown header + the SQL cell.
|
|
412
|
+
- **Phase 6b** (canvas bootstrap) is still required — every brand-new project hits it.
|
|
413
|
+
- **Phase 6c** layout: one tab, two rows (markdown, then SQL), both full-width.
|
|
414
|
+
|
|
415
|
+
Reference migrations (2026-05-26): card `90900` (LA Supply Drill-down) → Hex `019e6479-9c45-7000-8f53-c658658aadc9`; card `91302` (Chicago Supply Drill-down) → Hex `019e6495-6075-7000-84bd-8e0c7812bad7`.
|
|
416
|
+
|
|
417
|
+
### Generalizing from multiple cards into one parametrized app
|
|
418
|
+
|
|
419
|
+
When two or more migrated cards share structure differing only by hardcoded filters (an MSA, a state, a role), promote the filter(s) to INPUT cells and build one app:
|
|
420
|
+
|
|
421
|
+
- Use the **dynamic-dropdown pattern** from Phase 6a (SQL options cell + `DROPDOWN` input + user flips multi-select toggle) so the filter feels native rather than asking users to paste delimited text.
|
|
422
|
+
- SQL references the filter via `WHERE col IN ({{ var | array }})` (gotcha 10).
|
|
423
|
+
- Prefer **per-worker (or per-row) scoping over per-card scoping** when the original cards baked the filter into the joined set. Example: the LA card and Chicago card both hardcoded a "required reqs" set at the MSA level; the generalized version joins each worker against the reqs that apply to _their own_ state (`DIM_WORKERS.STATE`). More accurate (a Wisconsin-side Chicago worker isn't held to IL-specific reqs) but counts will differ from the source cards — call this out in the markdown header so consumers don't think the generalized app is broken.
|
|
424
|
+
- Often you'll want a **summary table** (1 row per entity) plus a **detail table** (long-format, 1 row per entity × dimension). The detail is what makes per-row scoping inspectable.
|
|
425
|
+
|
|
426
|
+
Reference: project `019e64b0-a730-7000-807c-5debd510361e` (generalized supply drilldown from cards 90900 + 91302, 2026-05-26).
|
|
427
|
+
|
|
428
|
+
## Reference files
|
|
429
|
+
|
|
430
|
+
For deep dives — read these only when you hit the relevant phase or gotcha:
|
|
431
|
+
|
|
432
|
+
- `references/gotchas.md` — extended gotcha catalog with the incident that produced each one
|
|
433
|
+
- `references/yaml_schemas.md` — exact cell shapes (INPUT, SQL, MARKDOWN), appLayout, and the meta block
|
|
434
|
+
- `references/cbh_stack.md` — Clipboard Health Snowflake schemas, Hex workspace/connection IDs, table-swap cheatsheet
|
|
435
|
+
|
|
436
|
+
## External memory pointers
|
|
437
|
+
|
|
438
|
+
If you need richer context (or this skill is being used by an agent without conversation history), these memory files capture the migrations this skill is distilled from:
|
|
439
|
+
|
|
440
|
+
- `/home/alex/.claude/projects/-home-alex/memory/feedback_hex_app_layout.md` — YAML round-trip pattern and gotchas
|
|
441
|
+
- `/home/alex/.claude/projects/-home-alex/memory/reference_data_stack.md` — Hex/Metabase/Snowflake host info and MCP gotchas
|
|
442
|
+
- `/home/alex/.claude/projects/-home-alex/memory/project_wops_tool.md` — WOPs Tool migration log (dashboard 1898)
|
|
443
|
+
- `/home/alex/.claude/projects/-home-alex/memory/project_payments_dashboard.md` — Payments Team migration log (dashboard 1242)
|
|
444
|
+
- `/home/alex/.claude/projects/-home-alex-pii-migrations/memory/feedback_hex_app_layout_via_yaml.md` — App canvas bootstrap requirement (dashboard 2769 incident, 2026-05-15)
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Clipboard Health data stack — IDs and schemas
|
|
2
|
+
|
|
3
|
+
Quick reference for the Metabase / Hex / Snowflake setup. These IDs are stable
|
|
4
|
+
and reused across every migration.
|
|
5
|
+
|
|
6
|
+
## Metabase
|
|
7
|
+
|
|
8
|
+
- Host: `metabase.cbh.rocks`
|
|
9
|
+
- Dashboard URL pattern: `https://metabase.cbh.rocks/dashboard/<id>`
|
|
10
|
+
- Two MCPs available:
|
|
11
|
+
- `mcp__metabase-cognitionai__*` — richer (has `get_dashboard`, `get_card`,
|
|
12
|
+
`list_tables`). Periodically returns 401 across all endpoints when its
|
|
13
|
+
credential refresh hiccups.
|
|
14
|
+
- `mcp__metabase-server__*` — basic CRUD plus `execute_query` / `execute_card`.
|
|
15
|
+
Use as fallback. `execute_card` requires `parameters` as a list `[]`, not
|
|
16
|
+
`{}` (schema bug).
|
|
17
|
+
|
|
18
|
+
## Hex
|
|
19
|
+
|
|
20
|
+
- Workspace: `e673b166-274e-4db9-972d-badc91dbfe1b`
|
|
21
|
+
- CLI: `hex` (alex already authenticated)
|
|
22
|
+
- Project URL pattern: `https://app.hex.tech/<workspace>/hex/<slug>/draft/logic`
|
|
23
|
+
|
|
24
|
+
### Snowflake data connections
|
|
25
|
+
|
|
26
|
+
The one you choose matters because of the workspace member permission model.
|
|
27
|
+
|
|
28
|
+
| Connection | ID | When to use |
|
|
29
|
+
| --------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
30
|
+
| `snowflake analytics` | `530b70b8-b300-43c9-9b3e-e4b98ded0379` | **Default for dev/draft.** Workspace members have QUERY access — alex can run cells. |
|
|
31
|
+
| `Hex Apps: Snowflake PII Service Account` | `019bb2d8-d867-7001-bb8a-ccb8b6d16e8a` | Publish-time only — workspace members get VIEW_RESULTS, not QUERY. Cells silently return empty when alex runs them. The published app runs as the service account. |
|
|
32
|
+
| `Hex Apps: Snowflake Non-PII Service Account` | `019da967-268c-700b-a1b1-f8f81f0e8c29` | Similar to PII — verify access before using. |
|
|
33
|
+
|
|
34
|
+
Default to `snowflake analytics` for the migration. If the dashboard needs PII
|
|
35
|
+
columns at publish time, switch the cells to the PII service account just before
|
|
36
|
+
publishing (the user can do this in the UI).
|
|
37
|
+
|
|
38
|
+
## Snowflake (database: `ANALYTICS`)
|
|
39
|
+
|
|
40
|
+
### Schemas
|
|
41
|
+
|
|
42
|
+
- `DBT_PRODUCTION_CORE` — fact and dim tables. **Prefer for migrations.**
|
|
43
|
+
- `DBT_PRODUCTION` — staging models. `STG_APP__*` is the newer naming; `APP_*_STG`
|
|
44
|
+
is legacy. Prefer the former.
|
|
45
|
+
- `INTERVENTIONS` — ops intervention tracking.
|
|
46
|
+
- `SEGMENT_*` — Segment event data.
|
|
47
|
+
- `PII_REPORTING` — PII-restricted, usually accessed via the PII service account.
|
|
48
|
+
|
|
49
|
+
### Common core fact/dim tables
|
|
50
|
+
|
|
51
|
+
- `FCT_SHIFTS` — primary shift fact (includes clock in/out, geofence, pay,
|
|
52
|
+
verification status, departure timestamps)
|
|
53
|
+
- `FCT_SHIFT_LOGS` — shift state changes / audit log (cancellations, rate
|
|
54
|
+
changes, time changes)
|
|
55
|
+
- `FCT_SHIFT_OFFERS` — ~4B rows; always filter by `SHIFT_ID` or `WORKER_ID` AND
|
|
56
|
+
a recent date range to keep micro-partition pruning effective
|
|
57
|
+
- `FCT_EXCLUSIONS` — DNR / facility exclusion records, joined with worker /
|
|
58
|
+
workplace names
|
|
59
|
+
- `DIM_WORKERS` — worker dimension (FULL_NAME, EMAIL, QUALIFICATION, STATE, MSA,
|
|
60
|
+
REFERRER_ID, REFERRAL_CODE, ACCOUNT_STAGE, CREATED_AT)
|
|
61
|
+
- `DIM_WORKPLACES` — workplace dimension (NAME, TYPE, MSA, STATE, STATE_CODE,
|
|
62
|
+
VERIFICATION_PREFERENCE, REQUIRES_LUNCH_BREAK, PARENT_FACILITY_ID,
|
|
63
|
+
SALESFORCE_PARENT_ACCOUNT_NAME, ACCOUNT_STAGE)
|
|
64
|
+
|
|
65
|
+
### Common staging tables (when core doesn't have what you need)
|
|
66
|
+
|
|
67
|
+
- `DBT_PRODUCTION.STG_APP__FACILITY_CANCELLED_ME_REQUESTS` — FCM workflow.
|
|
68
|
+
Columns: `FACILITY_CANCELLED_ME_REQUEST_ID`, `SHIFT_ID`, `WORKER_ID`,
|
|
69
|
+
`REASON_TYPE`, `IS_DELETED`, `IS_APPROVED`, `IS_AT_FACILITY`,
|
|
70
|
+
`PERFORMED_BY[_ROLE]`, `RESPONSE_DESCRIPTION`, `REQUEST/RESPONSE_LEAD_TIME`,
|
|
71
|
+
`CREATED_AT`, `UPDATED_AT`. NB: no `FACILITY_ID`.
|
|
72
|
+
- `DBT_PRODUCTION.STG_APP__BONUSES_PAYMENTS` — bonus payment ledger. Columns
|
|
73
|
+
include `AGENT_ID`, `SHIFT_ID`, `REASON`, `BONUS_AMOUNT_IN_DOLLARS`,
|
|
74
|
+
`STATUS`, `TYPE`, `BONUS_CATEGORY`. The `REASON` field is text and contains
|
|
75
|
+
patterns like `IPR Shift SOP Courtesy`, `PA Rate SOP Courtesy`,
|
|
76
|
+
`SelfCancel Courtesy` used in the 1st-incident eligibility tools.
|
|
77
|
+
- `DBT_PRODUCTION.STG_APP__GEOFENCE_EVENTS` — when you need raw geofence events;
|
|
78
|
+
prefer derived columns on FCT_SHIFTS first.
|
|
79
|
+
- `DBT_PRODUCTION.STG_APP__SHIFT_LOGS` — raw shift logs (use FCT_SHIFT_LOGS first).
|
|
80
|
+
- `DBT_PRODUCTION.STG_APP__EXTRA_TIME_PAY_SETTINGS_CHANGE_LOGS` — ETP settings
|
|
81
|
+
history (no core table).
|
|
82
|
+
|
|
83
|
+
### Legacy → core swap cheatsheet
|
|
84
|
+
|
|
85
|
+
When you see Metabase native_form referencing these, rewrite to the right column.
|
|
86
|
+
|
|
87
|
+
| Legacy table | Replacement |
|
|
88
|
+
| ---------------------------------------------------- | ----------------------------------------------------------------- |
|
|
89
|
+
| `APP_SHIFTLOGS_STG` | `DBT_PRODUCTION_CORE.FCT_SHIFT_LOGS` |
|
|
90
|
+
| `APP_EXCLUSIONS_STG` | `DBT_PRODUCTION_CORE.FCT_EXCLUSIONS` |
|
|
91
|
+
| `APP_AGENTPROFILES_STG`, `APP_PII_AGENTPROFILES_STG` | `DBT_PRODUCTION_CORE.DIM_WORKERS` |
|
|
92
|
+
| `STG_APP__FACILITY_PROFILES` | `DBT_PRODUCTION_CORE.DIM_WORKPLACES` |
|
|
93
|
+
| `APP_FACILITYCANCELLEDMEREQUESTS_STG` | `DBT_PRODUCTION.STG_APP__FACILITY_CANCELLED_ME_REQUESTS` |
|
|
94
|
+
| `APP_BONUSESPAYMENTS_STG` | `DBT_PRODUCTION.STG_APP__BONUSES_PAYMENTS` |
|
|
95
|
+
| `APP_SHIFTGEOFENCEEVENTS_STG` | `DBT_PRODUCTION_CORE.FCT_SHIFTS` (clock_out, geofence_at columns) |
|
|
96
|
+
| `STG_APP__SHIFT_LOGS` | `DBT_PRODUCTION_CORE.FCT_SHIFT_LOGS` |
|
|
97
|
+
|
|
98
|
+
For tables not in this list: search `INFORMATION_SCHEMA.TABLES` for `STG_APP__*`
|
|
99
|
+
or core equivalents before assuming a swap.
|
|
100
|
+
|
|
101
|
+
```sql
|
|
102
|
+
SELECT TABLE_SCHEMA, TABLE_NAME
|
|
103
|
+
FROM ANALYTICS.INFORMATION_SCHEMA.TABLES
|
|
104
|
+
WHERE TABLE_SCHEMA IN ('DBT_PRODUCTION', 'DBT_PRODUCTION_CORE')
|
|
105
|
+
AND TABLE_NAME ILIKE '%<keyword>%'
|
|
106
|
+
ORDER BY TABLE_SCHEMA, TABLE_NAME;
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Past migrations (for reference / SQL reuse)
|
|
110
|
+
|
|
111
|
+
- **WOPs Tool dashboard 1898** → Hex `019e08cb-79c0-7000-84c7-003f187d2669`.
|
|
112
|
+
Local v3 SQL at `/home/alex/wops_migration/hex_cells_v3/`. 47 SQL cells
|
|
113
|
+
covering DNR, GWG, Lateness/LEPs, Favorites, Shift Logs, Pay Rate Changes,
|
|
114
|
+
Worker/Workplace Details, Urgent Shift, Shift Radar, Worker↔Workplace
|
|
115
|
+
Messages, Referrals, PSST, Notification Settings, ETP Change Logs, Payment
|
|
116
|
+
Main, CA Shift Verification, Extra Worked Time.
|
|
117
|
+
|
|
118
|
+
- **Payments Team dashboard 1242** → Hex `019e275f-50bf-7000-8043-ad906145f55c`.
|
|
119
|
+
20 SQL cells. Heavy overlap with WOPs Tool — 13 of 20 reused v3 SQL.
|
|
120
|
+
|
|
121
|
+
If the new migration includes any of those topics, check the v3 SQL folder first
|
|
122
|
+
before writing new SQL. Audit reused SQL for stale variable references (see
|
|
123
|
+
gotcha 2).
|