@agent-native/core 0.146.3 → 0.146.5

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 (283) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +13 -10
  3. package/corpus/templates/analytics/.agents/skills/self-modifying-code/SKILL.md +7 -3
  4. package/corpus/templates/analytics/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  5. package/corpus/templates/analytics/actions/compose-dashboard.ts +53 -6
  6. package/corpus/templates/analytics/actions/dashboard-mutation-api.ts +8 -2
  7. package/corpus/templates/analytics/actions/migrate-first-party-analytics-to-bigquery.ts +3 -1
  8. package/corpus/templates/analytics/actions/mutate-dashboard.ts +48 -9
  9. package/corpus/templates/analytics/actions/update-dashboard.ts +80 -6
  10. package/corpus/templates/analytics/app/components/layout/CommandPalette.tsx +1 -1
  11. package/corpus/templates/analytics/app/components/layout/Layout.tsx +1 -1
  12. package/corpus/templates/analytics/app/pages/Settings.tsx +5 -1
  13. package/corpus/templates/analytics/app/routes/agent.tsx +7 -5
  14. package/corpus/templates/analytics/changelog/2026-08-07-dashboard-creation-and-edits-now-save-faster-with-reliable-s.md +6 -0
  15. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +19 -19
  16. package/corpus/templates/analytics/server/jobs/analytics-bigquery-backfill.ts +21 -2
  17. package/corpus/templates/analytics/server/lib/bigquery.ts +59 -24
  18. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +44 -8
  19. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +3 -5
  20. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +33 -69
  21. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +90 -24
  22. package/corpus/templates/analytics/server/lib/real-data-actions.ts +1 -1
  23. package/corpus/templates/analytics/server/plugins/agent-chat.ts +17 -2
  24. package/corpus/templates/analytics/shared/dashboard-report-timeouts.ts +2 -0
  25. package/corpus/templates/assets/.agents/skills/self-modifying-code/SKILL.md +7 -3
  26. package/corpus/templates/assets/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  27. package/corpus/templates/assets/app/components/layout/Layout.tsx +1 -1
  28. package/corpus/templates/assets/app/routes/agent.tsx +7 -5
  29. package/corpus/templates/assets/app/routes/settings.tsx +7 -2
  30. package/corpus/templates/assets/netlify.toml +1 -1
  31. package/corpus/templates/assets/package.json +1 -0
  32. package/corpus/templates/assets/scripts/migrate-production.ts +24 -0
  33. package/corpus/templates/assets/server/plugins/creative-context.ts +1 -1
  34. package/corpus/templates/assets/server/plugins/db.ts +1 -1
  35. package/corpus/templates/assets/tsconfig.json +1 -0
  36. package/corpus/templates/brain/.agents/skills/self-modifying-code/SKILL.md +7 -3
  37. package/corpus/templates/brain/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  38. package/corpus/templates/brain/app/components/layout/Layout.tsx +1 -1
  39. package/corpus/templates/brain/app/lib/brain.ts +4 -2
  40. package/corpus/templates/brain/app/root.tsx +1 -1
  41. package/corpus/templates/brain/app/routes/agent.tsx +10 -2
  42. package/corpus/templates/brain/netlify.toml +1 -1
  43. package/corpus/templates/brain/package.json +2 -0
  44. package/corpus/templates/brain/scripts/migrate-production.ts +24 -0
  45. package/corpus/templates/brain/server/plugins/db.ts +1 -1
  46. package/corpus/templates/brain/tsconfig.json +1 -0
  47. package/corpus/templates/calendar/.agents/skills/self-modifying-code/SKILL.md +7 -3
  48. package/corpus/templates/calendar/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  49. package/corpus/templates/calendar/app/components/layout/AppLayout.tsx +1 -1
  50. package/corpus/templates/calendar/app/root.tsx +1 -1
  51. package/corpus/templates/calendar/app/routes/_app.agent.tsx +8 -16
  52. package/corpus/templates/calendar/netlify.toml +1 -1
  53. package/corpus/templates/calendar/package.json +1 -0
  54. package/corpus/templates/calendar/scripts/migrate-production.ts +24 -0
  55. package/corpus/templates/calendar/server/plugins/db.ts +1 -1
  56. package/corpus/templates/calendar/tsconfig.json +1 -0
  57. package/corpus/templates/chat/.agents/skills/self-modifying-code/SKILL.md +7 -3
  58. package/corpus/templates/chat/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  59. package/corpus/templates/chat/app/hooks/use-navigation-state.ts +5 -2
  60. package/corpus/templates/chat/app/routes/agent.tsx +7 -31
  61. package/corpus/templates/chat/netlify.toml +1 -1
  62. package/corpus/templates/chat/package.json +1 -0
  63. package/corpus/templates/chat/scripts/migrate-production.ts +31 -0
  64. package/corpus/templates/chat/tsconfig.json +1 -0
  65. package/corpus/templates/clips/.agents/skills/self-modifying-code/SKILL.md +7 -3
  66. package/corpus/templates/clips/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  67. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  68. package/corpus/templates/clips/app/root.tsx +1 -1
  69. package/corpus/templates/clips/app/routes/_app.agent.tsx +9 -7
  70. package/corpus/templates/clips/netlify.toml +1 -1
  71. package/corpus/templates/clips/package.json +1 -0
  72. package/corpus/templates/clips/scripts/migrate-production.ts +24 -0
  73. package/corpus/templates/clips/server/plugins/db.ts +1 -1
  74. package/corpus/templates/clips/tsconfig.json +1 -0
  75. package/corpus/templates/content/.agents/skills/self-modifying-code/SKILL.md +7 -3
  76. package/corpus/templates/content/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  77. package/corpus/templates/content/app/components/layout/Layout.tsx +1 -1
  78. package/corpus/templates/content/app/root.tsx +1 -1
  79. package/corpus/templates/content/app/routes/_app.agent.tsx +9 -8
  80. package/corpus/templates/content/app/routes/_app.settings.tsx +7 -2
  81. package/corpus/templates/content/netlify.toml +1 -1
  82. package/corpus/templates/content/package.json +2 -0
  83. package/corpus/templates/content/scripts/migrate-production.ts +28 -0
  84. package/corpus/templates/content/server/plugins/creative-context.ts +1 -1
  85. package/corpus/templates/content/server/plugins/db.ts +2 -2
  86. package/corpus/templates/crm/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  87. package/corpus/templates/crm/app/components/crm/RecordActions.tsx +4 -1
  88. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +1 -1
  89. package/corpus/templates/crm/app/routes/agent.tsx +10 -2
  90. package/corpus/templates/crm/netlify.toml +1 -1
  91. package/corpus/templates/crm/package.json +2 -0
  92. package/corpus/templates/crm/scripts/migrate-production.ts +24 -0
  93. package/corpus/templates/crm/server/plugins/db.ts +1 -1
  94. package/corpus/templates/crm/tsconfig.json +1 -0
  95. package/corpus/templates/design/.agents/skills/self-modifying-code/SKILL.md +7 -3
  96. package/corpus/templates/design/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  97. package/corpus/templates/design/app/components/layout/Layout.tsx +1 -1
  98. package/corpus/templates/design/app/root.tsx +1 -1
  99. package/corpus/templates/design/app/routes/agent.tsx +9 -7
  100. package/corpus/templates/design/app/routes/settings.tsx +7 -2
  101. package/corpus/templates/design/netlify.toml +1 -1
  102. package/corpus/templates/design/package.json +1 -0
  103. package/corpus/templates/design/scripts/migrate-production.ts +24 -0
  104. package/corpus/templates/design/server/plugins/creative-context.ts +1 -1
  105. package/corpus/templates/design/server/plugins/db.ts +1 -1
  106. package/corpus/templates/design/tsconfig.json +1 -0
  107. package/corpus/templates/dispatch/.agents/skills/self-modifying-code/SKILL.md +7 -3
  108. package/corpus/templates/dispatch/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  109. package/corpus/templates/dispatch/app/root.tsx +5 -2
  110. package/corpus/templates/dispatch/app/routes/agent.tsx +7 -7
  111. package/corpus/templates/dispatch/netlify.toml +1 -1
  112. package/corpus/templates/dispatch/package.json +1 -0
  113. package/corpus/templates/dispatch/scripts/migrate-production.ts +23 -0
  114. package/corpus/templates/dispatch/tsconfig.json +1 -0
  115. package/corpus/templates/factory/.agents/skills/self-modifying-code/SKILL.md +7 -3
  116. package/corpus/templates/factory/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  117. package/corpus/templates/factory/app/components/layout/Layout.tsx +1 -1
  118. package/corpus/templates/factory/app/hooks/use-navigation-state.ts +5 -2
  119. package/corpus/templates/factory/app/root.tsx +1 -1
  120. package/corpus/templates/factory/app/routes/agent.tsx +9 -14
  121. package/corpus/templates/factory/netlify.toml +1 -1
  122. package/corpus/templates/factory/package.json +1 -0
  123. package/corpus/templates/factory/scripts/migrate-production.ts +31 -0
  124. package/corpus/templates/factory/server/plugins/factory-scheduler-job.ts +2 -0
  125. package/corpus/templates/factory/tsconfig.json +1 -0
  126. package/corpus/templates/forms/.agents/skills/self-modifying-code/SKILL.md +7 -3
  127. package/corpus/templates/forms/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  128. package/corpus/templates/forms/app/components/layout/Layout.tsx +1 -1
  129. package/corpus/templates/forms/app/root.tsx +1 -1
  130. package/corpus/templates/forms/app/routes/_app.agent.tsx +9 -7
  131. package/corpus/templates/forms/netlify.toml +1 -1
  132. package/corpus/templates/forms/package.json +2 -0
  133. package/corpus/templates/forms/scripts/migrate-production.ts +24 -0
  134. package/corpus/templates/forms/server/plugins/db.ts +1 -1
  135. package/corpus/templates/forms/tsconfig.json +1 -0
  136. package/corpus/templates/macros/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  137. package/corpus/templates/macros/app/components/layout/AppLayout.tsx +5 -3
  138. package/corpus/templates/macros/app/root.tsx +1 -1
  139. package/corpus/templates/macros/app/routes/agent.tsx +9 -6
  140. package/corpus/templates/macros/netlify.toml +1 -1
  141. package/corpus/templates/macros/package.json +1 -0
  142. package/corpus/templates/macros/scripts/migrate-production.ts +24 -0
  143. package/corpus/templates/macros/server/plugins/db.ts +1 -1
  144. package/corpus/templates/macros/tsconfig.json +1 -0
  145. package/corpus/templates/mail/.agents/skills/self-modifying-code/SKILL.md +7 -3
  146. package/corpus/templates/mail/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  147. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +8 -2
  148. package/corpus/templates/mail/app/components/layout/CommandPalette.tsx +1 -1
  149. package/corpus/templates/mail/app/routes/agent.tsx +9 -6
  150. package/corpus/templates/mail/netlify.toml +1 -1
  151. package/corpus/templates/mail/package.json +1 -0
  152. package/corpus/templates/mail/scripts/migrate-production.ts +24 -0
  153. package/corpus/templates/mail/server/plugins/db.ts +1 -1
  154. package/corpus/templates/mail/tsconfig.json +1 -0
  155. package/corpus/templates/plan/.agents/skills/self-modifying-code/SKILL.md +7 -3
  156. package/corpus/templates/plan/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  157. package/corpus/templates/plan/app/components/layout/Layout.tsx +1 -1
  158. package/corpus/templates/plan/app/root.tsx +1 -1
  159. package/corpus/templates/plan/app/routes/agent.tsx +9 -6
  160. package/corpus/templates/plan/netlify.toml +1 -1
  161. package/corpus/templates/plan/package.json +1 -0
  162. package/corpus/templates/plan/scripts/migrate-production.ts +24 -0
  163. package/corpus/templates/plan/server/plugins/00-mcp.ts +3 -2
  164. package/corpus/templates/plan/server/plugins/db.ts +1 -1
  165. package/corpus/templates/plan/tsconfig.json +1 -0
  166. package/corpus/templates/slides/.agents/skills/self-modifying-code/SKILL.md +7 -3
  167. package/corpus/templates/slides/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  168. package/corpus/templates/slides/app/components/editor/ImageGenPanel.tsx +8 -12
  169. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +39 -2
  170. package/corpus/templates/slides/app/components/layout/Layout.tsx +1 -1
  171. package/corpus/templates/slides/app/i18n/en-US.ts +1 -0
  172. package/corpus/templates/slides/app/lib/create-deck-generation.ts +22 -5
  173. package/corpus/templates/slides/app/pages/Index.tsx +20 -5
  174. package/corpus/templates/slides/app/root.tsx +1 -1
  175. package/corpus/templates/slides/app/routes/agent.tsx +6 -9
  176. package/corpus/templates/slides/app/routes/settings.tsx +7 -2
  177. package/corpus/templates/slides/netlify.toml +1 -1
  178. package/corpus/templates/slides/package.json +1 -0
  179. package/corpus/templates/slides/scripts/migrate-production.ts +24 -0
  180. package/corpus/templates/slides/server/plugins/creative-context.ts +1 -1
  181. package/corpus/templates/slides/server/plugins/db.ts +1 -1
  182. package/corpus/templates/slides/tsconfig.json +1 -0
  183. package/corpus/templates/tasks/.agents/skills/self-modifying-code/SKILL.md +7 -3
  184. package/corpus/templates/tasks/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  185. package/corpus/templates/tasks/app/components/layout/Layout.tsx +1 -1
  186. package/corpus/templates/tasks/app/routes/agent.tsx +10 -2
  187. package/corpus/templates/tasks/netlify.toml +1 -1
  188. package/corpus/templates/tasks/package.json +1 -0
  189. package/corpus/templates/tasks/scripts/migrate-production.ts +24 -0
  190. package/corpus/templates/tasks/server/plugins/db.ts +1 -1
  191. package/corpus/templates/tasks/tsconfig.json +1 -0
  192. package/dist/action.d.ts +2 -0
  193. package/dist/agent/production-agent.d.ts +1 -0
  194. package/dist/agent/production-agent.js +2 -0
  195. package/dist/agent/thread-data-builder.d.ts +4 -0
  196. package/dist/agent/thread-data-builder.js +44 -0
  197. package/dist/automations/service.d.ts +1 -0
  198. package/dist/automations/service.js +21 -2
  199. package/dist/cli/create.js +20 -2
  200. package/dist/client/agent-page/AgentJobsTab.js +3 -1
  201. package/dist/client/agent-page/AutomationDetailsDialog.js +9 -2
  202. package/dist/client/index.d.ts +1 -1
  203. package/dist/client/navigation/index.d.ts +1 -1
  204. package/dist/client/navigation/index.js +1 -1
  205. package/dist/client/org/OrgSwitcher.js +1 -1
  206. package/dist/client/settings/SettingsPanel.d.ts +8 -0
  207. package/dist/client/settings/SettingsPanel.js +16 -1
  208. package/dist/client/settings/index.d.ts +1 -1
  209. package/dist/collab/struct-routes.d.ts +1 -1
  210. package/dist/db/migrations.js +7 -13
  211. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  212. package/dist/jobs/actions/list-automation-runs.d.ts +1 -0
  213. package/dist/jobs/actions/list-automation-runs.js +9 -2
  214. package/dist/jobs/actions/list-recurring-jobs.js +3 -1
  215. package/dist/jobs/actions/manage-recurring-job.js +1 -1
  216. package/dist/jobs/actions/run-automation-now.js +1 -0
  217. package/dist/jobs/background-automation-runner.js +2 -0
  218. package/dist/jobs/frontmatter.d.ts +2 -0
  219. package/dist/jobs/frontmatter.js +4 -0
  220. package/dist/jobs/run-history.d.ts +4 -0
  221. package/dist/jobs/run-history.js +28 -8
  222. package/dist/jobs/run-now.d.ts +4 -1
  223. package/dist/jobs/run-now.js +4 -3
  224. package/dist/jobs/scheduler.js +18 -0
  225. package/dist/jobs/tools.d.ts +3 -2
  226. package/dist/jobs/tools.js +26 -12
  227. package/dist/localization/default-messages.d.ts +1 -0
  228. package/dist/localization/default-messages.js +1 -0
  229. package/dist/navigation/index.d.ts +1 -0
  230. package/dist/navigation/index.js +34 -0
  231. package/dist/notifications/routes.d.ts +1 -1
  232. package/dist/observability/routes.d.ts +1 -1
  233. package/dist/resources/handlers.d.ts +1 -1
  234. package/dist/server/action-routes.d.ts +2 -0
  235. package/dist/server/action-routes.js +1 -0
  236. package/dist/server/agent-chat-plugin.js +3 -2
  237. package/dist/server/builder-browser.js +24 -8
  238. package/dist/templates/chat/.agents/skills/self-modifying-code/SKILL.md +7 -3
  239. package/dist/templates/chat/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  240. package/dist/templates/chat/app/hooks/use-navigation-state.ts +5 -2
  241. package/dist/templates/chat/app/routes/agent.tsx +7 -31
  242. package/dist/templates/chat/netlify.toml +1 -1
  243. package/dist/templates/chat/package.json +1 -0
  244. package/dist/templates/chat/scripts/migrate-production.ts +31 -0
  245. package/dist/templates/chat/tsconfig.json +1 -0
  246. package/dist/templates/default/.agents/skills/self-modifying-code/SKILL.md +7 -3
  247. package/dist/templates/default/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  248. package/dist/templates/default/app/routes/_index.tsx +1 -1
  249. package/dist/templates/default/app/routes/agent.tsx +7 -25
  250. package/dist/templates/default/package.json +1 -0
  251. package/dist/templates/default/scripts/migrate-production.ts +31 -0
  252. package/dist/templates/default/tsconfig.json +1 -0
  253. package/dist/templates/headless/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  254. package/dist/templates/workspace-core/.agents/skills/self-modifying-code/SKILL.md +7 -3
  255. package/dist/templates/workspace-core/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  256. package/dist/templates/workspace-root/AGENTS.md +2 -1
  257. package/dist/triggers/actions/list-automations.js +1 -1
  258. package/dist/triggers/actions/manage-automation.js +5 -1
  259. package/dist/triggers/actions.d.ts +1 -1
  260. package/dist/triggers/actions.js +21 -15
  261. package/dist/triggers/dispatcher.js +17 -2
  262. package/dist/triggers/routes.d.ts +1 -0
  263. package/dist/triggers/routes.js +1 -0
  264. package/package.json +3 -3
  265. package/src/templates/chat/.agents/skills/self-modifying-code/SKILL.md +7 -3
  266. package/src/templates/chat/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  267. package/src/templates/chat/app/hooks/use-navigation-state.ts +5 -2
  268. package/src/templates/chat/app/routes/agent.tsx +7 -31
  269. package/src/templates/chat/netlify.toml +1 -1
  270. package/src/templates/chat/package.json +1 -0
  271. package/src/templates/chat/scripts/migrate-production.ts +31 -0
  272. package/src/templates/chat/tsconfig.json +1 -0
  273. package/src/templates/default/.agents/skills/self-modifying-code/SKILL.md +7 -3
  274. package/src/templates/default/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  275. package/src/templates/default/app/routes/_index.tsx +1 -1
  276. package/src/templates/default/app/routes/agent.tsx +7 -25
  277. package/src/templates/default/package.json +1 -0
  278. package/src/templates/default/scripts/migrate-production.ts +31 -0
  279. package/src/templates/default/tsconfig.json +1 -0
  280. package/src/templates/headless/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  281. package/src/templates/workspace-core/.agents/skills/self-modifying-code/SKILL.md +7 -3
  282. package/src/templates/workspace-core/.agents/skills/upgrade-agent-native/SKILL.md +5 -2
  283. package/src/templates/workspace-root/AGENTS.md +2 -1
@@ -1,4 +1,9 @@
1
+ import { loadDashboardSeed } from "./dashboard-seeds";
1
2
  import {
3
+ buildPanel,
4
+ FIRST_PARTY_DASHBOARD_ID,
5
+ FIRST_PARTY_TEMPLATE_SCOPED_METRIC_KEYS,
6
+ firstPartyTemplateFilter,
2
7
  LEGACY_SIGNUPS_OVER_TIME_SQL,
3
8
  LEGACY_SEED_SIGNUPS_OVER_TIME_SQL,
4
9
  SIGNUPS_OVER_TIME_SQL,
@@ -12,14 +17,18 @@ const LEGACY_NEW_VS_RECURRING_USERS_DESCRIPTION =
12
17
  const BOUNDED_NEW_VS_RECURRING_USERS_SQL = `WITH first_seen AS (SELECT NULLIF(user_key, '') AS user_key, MIN(event_date) AS first_date FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')) <> 'docs' AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') GROUP BY 1), activity AS (SELECT NULLIF(user_key, '') AS user_key, event_date FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')) <> 'docs' AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))), daily AS (SELECT a.event_date AS date, CASE WHEN a.event_date = f.first_date THEN 'New' ELSE 'Recurring' END AS user_type, COUNT(DISTINCT a.user_key) AS users FROM activity a JOIN first_seen f ON f.user_key = a.user_key GROUP BY 1, 2) SELECT date, user_type, users FROM daily ORDER BY date, CASE WHEN user_type = 'Recurring' THEN 0 ELSE 1 END`;
13
18
  const MARKETING_SITE_TEMPLATE_FILTER =
14
19
  "lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')) <> 'www'";
20
+ const NEW_VS_TEMPLATE_EXPRESSION =
21
+ "COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')";
22
+ const FIRST_PARTY_NEW_VS_TEMPLATE_FILTER = firstPartyTemplateFilter(
23
+ NEW_VS_TEMPLATE_EXPRESSION,
24
+ );
15
25
  export const DEPLOYED_NEW_VS_RECURRING_USERS_SQL =
16
- BOUNDED_NEW_VS_RECURRING_USERS_SQL.replace(
17
- " <> 'docs' AND ",
18
- ` <> 'docs' AND ${MARKETING_SITE_TEMPLATE_FILTER} AND `,
26
+ BOUNDED_NEW_VS_RECURRING_USERS_SQL.split(" <> 'docs' AND ").join(
27
+ ` <> 'docs' AND ${FIRST_PARTY_NEW_VS_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND `,
19
28
  );
20
29
  // Keep cohort classification in one raw-table scan. Separate first-seen and
21
30
  // activity scans double the random heap reads on the growing event table.
22
- const NEW_VS_RECURRING_USERS_SQL = `WITH activity AS (SELECT NULLIF(user_key, '') AS user_key, event_date, MIN(event_date) OVER (PARTITION BY NULLIF(user_key, '')) AS first_date FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')) <> 'docs' AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), daily AS (SELECT event_date AS date, CASE WHEN event_date = first_date THEN 'New' ELSE 'Recurring' END AS user_type, COUNT(DISTINCT user_key) AS users FROM activity WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) GROUP BY 1, 2) SELECT date, user_type, users FROM daily ORDER BY date, CASE WHEN user_type = 'Recurring' THEN 0 ELSE 1 END`;
31
+ const NEW_VS_RECURRING_USERS_SQL = `WITH activity AS (SELECT NULLIF(user_key, '') AS user_key, event_date, MIN(event_date) OVER (PARTITION BY NULLIF(user_key, '')) AS first_date FROM analytics_events WHERE event_name = 'session status' AND signed_in = 'true' AND NULLIF(user_key, '') IS NOT NULL AND lower(COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), 'unknown')) <> 'docs' AND ${FIRST_PARTY_NEW_VS_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ('{{emailFilter}}' IN ('', 'all') OR ('{{emailFilter}}' = 'exclude_builder' AND lower(coalesce(user_id, '')) NOT LIKE '%@builder.io') OR ('{{emailFilter}}' = 'only_builder' AND lower(coalesce(user_id, '')) LIKE '%@builder.io')) AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')), daily AS (SELECT event_date AS date, CASE WHEN event_date = first_date THEN 'New' ELSE 'Recurring' END AS user_type, COUNT(DISTINCT user_key) AS users FROM activity WHERE ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND event_date >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) GROUP BY 1, 2) SELECT date, user_type, users FROM daily ORDER BY date, CASE WHEN user_type = 'Recurring' THEN 0 ELSE 1 END`;
23
32
  const NEW_VS_RECURRING_USERS_DESCRIPTION =
24
33
  "Daily signed-in visitors split by first active day observed in the previous 365 days (New) vs return visit (Recurring), stacked with Recurring on the bottom and New on top. Docs and marketing-site traffic are excluded.";
25
34
 
@@ -46,11 +55,38 @@ const CANONICAL_CUSTOM_PANEL_REPLACEMENTS: readonly ExactFirstPartyPanelReplacem
46
55
  },
47
56
  ];
48
57
 
58
+ const CANONICAL_CATALOG_PANEL_REPLACEMENTS: readonly ExactFirstPartyPanelReplacement[] =
59
+ (() => {
60
+ const seed = loadDashboardSeed(FIRST_PARTY_DASHBOARD_ID);
61
+ if (!seed || !Array.isArray(seed.panels)) return [];
62
+ const scopedMetricKeys = new Set<string>(
63
+ FIRST_PARTY_TEMPLATE_SCOPED_METRIC_KEYS,
64
+ );
65
+ return seed.panels.flatMap((rawPanel) => {
66
+ if (!rawPanel || typeof rawPanel !== "object") return [];
67
+ const panel = rawPanel as Record<string, unknown>;
68
+ const id = typeof panel.id === "string" ? panel.id : "";
69
+ const legacySql = typeof panel.sql === "string" ? panel.sql : "";
70
+ if (!scopedMetricKeys.has(id)) return [];
71
+ const catalogPanel = id ? buildPanel(id) : null;
72
+ if (!catalogPanel || !legacySql || catalogPanel.sql === legacySql) {
73
+ return [];
74
+ }
75
+ return [
76
+ {
77
+ id,
78
+ legacySql: [legacySql],
79
+ sql: catalogPanel.sql,
80
+ },
81
+ ];
82
+ });
83
+ })();
84
+
49
85
  export function repairCanonicalFirstPartyDashboardQueries(
50
86
  config: Record<string, unknown>,
51
87
  ) {
52
- return repairFirstPartyObservedRetentionPanels(
53
- config,
54
- CANONICAL_CUSTOM_PANEL_REPLACEMENTS,
55
- );
88
+ return repairFirstPartyObservedRetentionPanels(config, [
89
+ ...CANONICAL_CUSTOM_PANEL_REPLACEMENTS,
90
+ ...CANONICAL_CATALOG_PANEL_REPLACEMENTS,
91
+ ]);
56
92
  }
@@ -1,14 +1,12 @@
1
1
  import type { SqlDashboardConfig } from "../../app/pages/adhoc/sql-dashboard/types";
2
+ import { repairCanonicalFirstPartyDashboardQueries } from "./canonical-first-party-dashboard-repair";
2
3
  import { loadDashboardSeed } from "./dashboard-seeds";
3
4
  import {
4
5
  listDashboardSummaries,
5
6
  type DashboardRecord,
6
7
  type DashboardSummaryRecord,
7
8
  } from "./dashboards-store";
8
- import {
9
- FIRST_PARTY_DASHBOARD_ID,
10
- repairFirstPartyObservedRetentionPanels,
11
- } from "./first-party-metric-catalog";
9
+ import { FIRST_PARTY_DASHBOARD_ID } from "./first-party-metric-catalog";
12
10
 
13
11
  export type DashboardTemplateCategory =
14
12
  | "Acquisition"
@@ -68,7 +66,7 @@ function seedConfig(id: string): SqlDashboardConfig {
68
66
  if (id !== FIRST_PARTY_DASHBOARD_ID) {
69
67
  return seed as unknown as SqlDashboardConfig;
70
68
  }
71
- return repairFirstPartyObservedRetentionPanels(seed)
69
+ return repairCanonicalFirstPartyDashboardQueries(seed)
72
70
  .config as unknown as SqlDashboardConfig;
73
71
  }
74
72
 
@@ -1031,7 +1031,7 @@ function backfillBranchSql(
1031
1031
  ? [cursor.receivedAt, cursor.receivedAt, cursor.id]
1032
1032
  : [];
1033
1033
  return {
1034
- sql: `SELECT id, received_at
1034
+ sql: `SELECT ${FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ")}
1035
1035
  FROM analytics_events
1036
1036
  WHERE ${predicate}${cursorSql ? ` AND ${cursorSql}` : ""}
1037
1037
  ORDER BY received_at ASC, id ASC LIMIT ?`,
@@ -1039,20 +1039,32 @@ function backfillBranchSql(
1039
1039
  };
1040
1040
  }
1041
1041
 
1042
- function backfillRowsByIdsSql(ids: string[]): {
1043
- sql: string;
1044
- args: string[];
1045
- } {
1042
+ function backfillScopedRowsSql(
1043
+ branches: Array<{ predicate: string; args: string[] }>,
1044
+ cursor: FirstPartyAnalyticsBackfillCursor,
1045
+ limit: number,
1046
+ ): { sql: string; args: Array<string | number> } {
1047
+ const branchQueries = branches.map((branch) =>
1048
+ backfillBranchSql(branch.predicate, branch.args, cursor),
1049
+ );
1050
+ const columns = FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ");
1046
1051
  return {
1047
- sql: `SELECT ${FIRST_PARTY_ANALYTICS_BACKFILL_COLUMNS.join(", ")}
1048
- FROM analytics_events
1049
- WHERE id IN (${ids.map(() => "?").join(", ")})`,
1050
- args: ids,
1052
+ sql: `WITH ${branchQueries
1053
+ .map(
1054
+ (_branch, index) =>
1055
+ `backfill_branch_${index} AS (${branchQueries[index]!.sql})`,
1056
+ )
1057
+ .join(", ")}
1058
+ SELECT ${columns}
1059
+ FROM (${branchQueries.map((_branch, index) => `SELECT ${columns} FROM backfill_branch_${index}`).join(" UNION ALL ")}) AS selected_events
1060
+ ORDER BY received_at ASC, id ASC LIMIT ?`,
1061
+ args: [
1062
+ ...branchQueries.flatMap((branch) => [...branch.args, limit]),
1063
+ limit,
1064
+ ],
1051
1065
  };
1052
1066
  }
1053
1067
 
1054
- const MAX_SQLITE_BIND_VARIABLES = 900;
1055
-
1056
1068
  function backfillRowCursor(
1057
1069
  row: Record<string, unknown>,
1058
1070
  ): FirstPartyAnalyticsBackfillCursor {
@@ -1069,18 +1081,6 @@ function backfillRowCursor(
1069
1081
  return { receivedAt, id };
1070
1082
  }
1071
1083
 
1072
- function compareBackfillRows(
1073
- left: Record<string, unknown>,
1074
- right: Record<string, unknown>,
1075
- ): number {
1076
- const leftCursor = backfillRowCursor(left);
1077
- const rightCursor = backfillRowCursor(right);
1078
- return (
1079
- leftCursor.receivedAt.localeCompare(rightCursor.receivedAt) ||
1080
- leftCursor.id.localeCompare(rightCursor.id)
1081
- );
1082
- }
1083
-
1084
1084
  export async function backfillFirstPartyAnalyticsBatch(
1085
1085
  scope: FirstPartyAnalyticsScope,
1086
1086
  cursor: string | null,
@@ -1108,22 +1108,15 @@ export async function backfillFirstPartyAnalyticsBatch(
1108
1108
  },
1109
1109
  ];
1110
1110
  const rows: Record<string, unknown>[] = [];
1111
- for (const branch of branches) {
1112
- const scoped = backfillBranchSql(
1113
- branch.predicate,
1114
- branch.args,
1115
- parsedCursor,
1116
- );
1117
- const result = await db.execute({
1118
- sql: scoped.sql,
1119
- args: [...scoped.args, boundedLimit],
1120
- timeoutMs: 20_000,
1121
- maxAttempts: 1,
1122
- });
1123
- rows.push(...(result.rows as Record<string, unknown>[]));
1124
- }
1125
- rows.sort(compareBackfillRows);
1126
- const selectedRows = rows.slice(0, boundedLimit);
1111
+ const scoped = backfillScopedRowsSql(branches, parsedCursor, boundedLimit);
1112
+ const result = await db.execute({
1113
+ sql: scoped.sql,
1114
+ args: scoped.args,
1115
+ timeoutMs: 20_000,
1116
+ maxAttempts: 1,
1117
+ });
1118
+ rows.push(...(result.rows as Record<string, unknown>[]));
1119
+ const selectedRows = rows;
1127
1120
  if (!selectedRows.length) {
1128
1121
  return {
1129
1122
  nextCursor: serializeBackfillCursor(parsedCursor),
@@ -1132,36 +1125,7 @@ export async function backfillFirstPartyAnalyticsBatch(
1132
1125
  };
1133
1126
  }
1134
1127
 
1135
- const selectedIds = selectedRows.map((row) => backfillRowCursor(row).id);
1136
- const hydratedRows: Record<string, unknown>[] = [];
1137
- for (
1138
- let offset = 0;
1139
- offset < selectedIds.length;
1140
- offset += MAX_SQLITE_BIND_VARIABLES
1141
- ) {
1142
- const hydratedQuery = backfillRowsByIdsSql(
1143
- selectedIds.slice(offset, offset + MAX_SQLITE_BIND_VARIABLES),
1144
- );
1145
- const hydratedResult = await db.execute({
1146
- sql: hydratedQuery.sql,
1147
- args: hydratedQuery.args,
1148
- timeoutMs: 20_000,
1149
- maxAttempts: 1,
1150
- });
1151
- hydratedRows.push(...(hydratedResult.rows as Record<string, unknown>[]));
1152
- }
1153
- const hydratedById = new Map(
1154
- hydratedRows.map((row) => [backfillRowCursor(row).id, row]),
1155
- );
1156
- const selectedEvents = selectedIds.map((id) => {
1157
- const row = hydratedById.get(id);
1158
- if (!row) {
1159
- throw new Error(
1160
- `First-party analytics backfill row ${id} disappeared before hydration`,
1161
- );
1162
- }
1163
- return row;
1164
- });
1128
+ const selectedEvents = selectedRows;
1165
1129
  await insertFirstPartyAnalyticsRows(selectedEvents, configuredTable);
1166
1130
  const lastCursor = backfillRowCursor(selectedRows[selectedRows.length - 1]!);
1167
1131
  return {
@@ -115,10 +115,64 @@ function fixed(sql: string): (window?: MetricWindow) => string {
115
115
 
116
116
  const TEMPLATE_EXPR =
117
117
  "COALESCE(NULLIF(template, ''), NULLIF(properties::jsonb ->> 'templateId', ''), NULLIF(properties::jsonb ->> 'agent_native_template', ''), NULLIF(properties::jsonb ->> 'agentNativeTemplate', ''), NULLIF(app, ''), NULLIF(properties::jsonb ->> 'agent_native_app', ''), NULLIF(properties::jsonb ->> 'agentNativeApp', ''), 'unknown')";
118
+ /**
119
+ * The first-party catalog is intentionally narrower than every value that can
120
+ * appear in shared browser telemetry. Preview hosts and marketing traffic can
121
+ * use the same event table, but they are not product-template series.
122
+ */
123
+ export const FIRST_PARTY_TEMPLATE_NAMES = [
124
+ "analytics",
125
+ "assets",
126
+ "brain",
127
+ "calendar",
128
+ "chat",
129
+ "clips",
130
+ "content",
131
+ "design",
132
+ "dispatch",
133
+ "forms",
134
+ "mail",
135
+ "plan",
136
+ "slides",
137
+ ] as const;
138
+ export const FIRST_PARTY_TEMPLATE_SCOPED_METRIC_KEYS = [
139
+ "total-signups",
140
+ "signups-over-time",
141
+ "signups-by-template",
142
+ "total-template-clicks",
143
+ "total-demo-clicks",
144
+ "total-cli-copies",
145
+ "template-interest-over-time",
146
+ "clicks-by-template",
147
+ "demo-clicks-over-time",
148
+ "cli-copies-by-template",
149
+ "cli-copies-over-time",
150
+ "pageviews-over-time",
151
+ "sessions-by-app",
152
+ "repeat-users",
153
+ "recurring-users-by-template",
154
+ "recurring-users-by-template-bar",
155
+ "retention-over-time",
156
+ "one-day-retention-by-template",
157
+ "seven-day-retention-by-template",
158
+ "dau-over-time",
159
+ "wau-over-time",
160
+ ] as const;
161
+ const FIRST_PARTY_TEMPLATE_SQL_LIST = FIRST_PARTY_TEMPLATE_NAMES.map(
162
+ (name) => `'${name}'`,
163
+ ).join(", ");
164
+ export function firstPartyTemplateFilter(
165
+ templateExpression = TEMPLATE_EXPR,
166
+ ): string {
167
+ return `lower(${templateExpression}) IN (${FIRST_PARTY_TEMPLATE_SQL_LIST})`;
168
+ }
169
+ const FIRST_PARTY_TEMPLATE_FILTER = firstPartyTemplateFilter();
118
170
  const KNOWN_TEMPLATE_FILTER = `${TEMPLATE_EXPR} <> 'unknown'`;
119
171
  const PRODUCT_ACTIVITY_TEMPLATE_FILTER = `lower(${TEMPLATE_EXPR}) <> 'docs'`;
120
172
  const MARKETING_SITE_TEMPLATE_FILTER = `lower(${TEMPLATE_EXPR}) <> 'www'`;
121
173
  const KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER = `${KNOWN_TEMPLATE_FILTER} AND ${PRODUCT_ACTIVITY_TEMPLATE_FILTER}`;
174
+ const FIRST_PARTY_PRODUCT_ACTIVITY_TEMPLATE_FILTER = `${PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`;
175
+ const FIRST_PARTY_KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER = `${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`;
122
176
  const EVENT_DATE_SQL = "event_date";
123
177
  const EVENT_DATE_FILTER_SQL = EVENT_DATE_SQL;
124
178
  const USER_KEY_SQL = "NULLIF(user_key, '')";
@@ -221,11 +275,11 @@ export function usesFirstPartyDashboardFilters(sql: string): boolean {
221
275
  return sql.includes("{{timeRange}}") || sql.includes("{{emailFilter}}");
222
276
  }
223
277
 
224
- const TOTAL_SIGNUPS_SQL = `SELECT COUNT(*) AS signups FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`;
278
+ const TOTAL_SIGNUPS_SQL = `SELECT COUNT(*) AS signups FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`;
225
279
  const LEGACY_SEED_SIGNUPS_TIME_RANGE_FILTER = `('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${EVENT_DATE_SQL} >= ${daysAgoSql(7)}) OR ('{{timeRange}}' = '30d' AND ${EVENT_DATE_SQL} >= ${daysAgoSql(30)}) OR ('{{timeRange}}' = '90d' AND ${EVENT_DATE_SQL} >= ${daysAgoSql(90)}) OR ('{{timeRange}}' = '180d' AND ${EVENT_DATE_SQL} >= ${daysAgoSql(180)}) OR ('{{timeRange}}' = '365d' AND ${EVENT_DATE_SQL} >= ${daysAgoSql(365)}))`;
226
280
  export const LEGACY_SEED_SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY ${EVENT_DATE_SQL}) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${LEGACY_SEED_SIGNUPS_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
227
281
  export const LEGACY_SIGNUPS_OVER_TIME_SQL = `WITH offsets AS (SELECT (ROW_NUMBER() OVER (ORDER BY ${EVENT_DATE_SQL}) - 1)::int AS n FROM analytics_events LIMIT 800), signup_events AS (SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
228
- export const SIGNUPS_OVER_TIME_SQL = `WITH digits AS (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9), offsets AS (SELECT ones.n + tens.n * 10 + hundreds.n * 100 AS n FROM digits ones CROSS JOIN digits tens CROSS JOIN digits hundreds WHERE hundreds.n < 8), signup_events AS (SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
282
+ export const SIGNUPS_OVER_TIME_SQL = `WITH digits AS (SELECT 0 AS n UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9), offsets AS (SELECT ones.n + tens.n * 10 + hundreds.n * 100 AS n FROM digits ones CROSS JOIN digits tens CROSS JOIN digits hundreds WHERE hundreds.n < 8), signup_events AS (SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}), bounds AS (SELECT MIN(date::date) AS start_date, MAX(date::date) AS end_date FROM signup_events), dates AS (SELECT to_char(bounds.start_date + offsets.n, 'YYYY-MM-DD') AS date FROM bounds CROSS JOIN offsets WHERE bounds.start_date IS NOT NULL AND bounds.start_date + offsets.n <= bounds.end_date), templates AS (SELECT DISTINCT template FROM signup_events), daily AS (SELECT date, template, COUNT(*) AS count FROM signup_events GROUP BY date, template) SELECT dates.date, templates.template, COALESCE(daily.count, 0) AS count FROM dates CROSS JOIN templates LEFT JOIN daily ON daily.date = dates.date AND daily.template = templates.template ORDER BY dates.date, templates.template`;
229
283
  export const LEGACY_RETENTION_OVER_TIME_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS cohort_date FROM base GROUP BY user_key), anchor_dates AS (SELECT DISTINCT cohort_date AS date FROM first_seen WHERE cohort_date <= ${daysAgoSql(14)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_windows AS (SELECT a.date, f.user_key, f.cohort_date FROM anchor_dates a JOIN first_seen f ON f.cohort_date >= ${rollingWindowStartSql()} AND f.cohort_date <= a.date), cohort_sizes AS (SELECT date, COUNT(DISTINCT user_key) AS users FROM cohort_windows GROUP BY date), periods AS (SELECT '1-7d return' AS period UNION ALL SELECT '7-14d return' AS period), retained AS (SELECT cw.date, '1-7d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date > cw.cohort_date AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY cw.date UNION ALL SELECT cw.date, '7-14d return' AS period, COUNT(DISTINCT cw.user_key) AS retained FROM cohort_windows cw JOIN base b ON b.user_key = cw.user_key AND b.event_date >= to_char(cw.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(cw.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY cw.date) SELECT cs.date, p.period, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs CROSS JOIN periods p LEFT JOIN retained r ON r.date = cs.date AND r.period = p.period WHERE cs.users >= ${RETENTION_MIN_COHORT_SIZE} ORDER BY cs.date, p.period`;
230
284
  export const LEGACY_ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(7)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date > c.cohort_date AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
231
285
  export const LEGACY_SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen AS (SELECT user_key, template, event_date AS cohort_date, ROW_NUMBER() OVER (PARTITION BY user_key ORDER BY event_date, template) AS rn FROM base), first_seen AS (SELECT user_key, template, cohort_date FROM ranked_first_seen WHERE rn = 1), cohorts AS (SELECT user_key, template, cohort_date FROM first_seen WHERE cohort_date <= ${daysAgoSql(14)} AND ${dashboardTimeRangeFilter("cohort_date")}), cohort_sizes AS (SELECT template, COUNT(DISTINCT user_key) AS users FROM cohorts GROUP BY template), retained AS (SELECT c.template, COUNT(DISTINCT c.user_key) AS retained FROM cohorts c JOIN base b ON b.user_key = c.user_key AND b.event_date >= to_char(c.cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') AND b.event_date <= to_char(c.cohort_date::date + INTERVAL '14 days', 'YYYY-MM-DD') GROUP BY c.template) SELECT cs.template, COALESCE(r.retained, 0) AS retained_users, cs.users AS cohort_users, COALESCE(r.retained::float / NULLIF(cs.users, 0), 0) AS rate FROM cohort_sizes cs LEFT JOIN retained r ON r.template = cs.template WHERE cs.users >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cs.users DESC, cs.template`;
@@ -259,7 +313,7 @@ export const LEGACY_V0_ONE_DAY_RETENTION_BY_TEMPLATE_SQL =
259
313
  "CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))))",
260
314
  "CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD')))",
261
315
  );
262
- const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
316
+ const SIGNUPS_BY_TEMPLATE_SQL = `SELECT ${TEMPLATE_EXPR} AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'signup' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${TEMPLATE_EXPR} ORDER BY count DESC`;
263
317
  export const LEGACY_RECURRING_USERS_BY_TEMPLATE_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
264
318
  export const LEGACY_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH all_users AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL}, user_id, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER}), first_seen AS (SELECT user_key, MIN(event_date) AS first_date FROM all_users GROUP BY user_key) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM all_users a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' AND ${dashboardTimeRangeFilter("a.event_date")} GROUP BY 1, 2 ORDER BY date, template`;
265
319
  const OBSERVED_ACTIVITY_LOOKBACK_FILTER = `${EVENT_DATE_SQL} >= ${daysAgoSql(OBSERVED_ACTIVITY_LOOKBACK_DAYS)}`;
@@ -291,7 +345,7 @@ export const PRE_MARKETING_SITE_RETENTION_OVER_TIME_SQL =
291
345
  const RETENTION_OVER_TIME_SQL =
292
346
  PRE_MARKETING_SITE_RETENTION_OVER_TIME_SQL.replace(
293
347
  PRODUCT_ACTIVITY_TEMPLATE_FILTER,
294
- `${PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
348
+ `${FIRST_PARTY_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
295
349
  );
296
350
  export const PRE_MARKETING_SITE_ONE_DAY_RETENTION_BY_TEMPLATE_SQL =
297
351
  LEGACY_ONE_DAY_RETENTION_BY_TEMPLATE_SQL.replace(
@@ -303,7 +357,7 @@ export const MATERIALIZED_ONE_DAY_RETENTION_BY_TEMPLATE_SQL =
303
357
  KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER,
304
358
  `${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
305
359
  );
306
- const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} GROUP BY 1, 2, 3), observed AS (SELECT user_key, event_date, FIRST_VALUE(template) OVER (PARTITION BY user_key ORDER BY event_date, template) AS starting_template, MIN(event_date) OVER (PARTITION BY user_key ORDER BY event_date, template) AS cohort_date FROM base), cohorts AS (SELECT user_key, starting_template AS template, cohort_date, MAX(CASE WHEN event_date > cohort_date AND event_date <= to_char(cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') THEN 1 ELSE 0 END) AS retained FROM observed WHERE cohort_date <= ${daysAgoSql(7)} AND ${dashboardTimeRangeFilter("cohort_date")} GROUP BY user_key, starting_template, cohort_date) SELECT template, SUM(retained) AS retained_users, COUNT(*) AS cohort_users, SUM(retained)::float / NULLIF(COUNT(*), 0) AS rate FROM cohorts GROUP BY template HAVING COUNT(*) >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cohort_users DESC, template`;
360
+ const ONE_DAY_RETENTION_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} GROUP BY 1, 2, 3), observed AS (SELECT user_key, event_date, FIRST_VALUE(template) OVER (PARTITION BY user_key ORDER BY event_date, template) AS starting_template, MIN(event_date) OVER (PARTITION BY user_key ORDER BY event_date, template) AS cohort_date FROM base), cohorts AS (SELECT user_key, starting_template AS template, cohort_date, MAX(CASE WHEN event_date > cohort_date AND event_date <= to_char(cohort_date::date + INTERVAL '7 days', 'YYYY-MM-DD') THEN 1 ELSE 0 END) AS retained FROM observed WHERE cohort_date <= ${daysAgoSql(7)} AND ${dashboardTimeRangeFilter("cohort_date")} GROUP BY user_key, starting_template, cohort_date) SELECT template, SUM(retained) AS retained_users, COUNT(*) AS cohort_users, SUM(retained)::float / NULLIF(COUNT(*), 0) AS rate FROM cohorts GROUP BY template HAVING COUNT(*) >= ${PER_TEMPLATE_RETENTION_MIN_COHORT_SIZE} ORDER BY rate DESC, cohort_users DESC, template`;
307
361
  export const PRE_MARKETING_SITE_SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL =
308
362
  LEGACY_SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL.replace(
309
363
  `${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER}), ranked_first_seen`,
@@ -312,7 +366,7 @@ export const PRE_MARKETING_SITE_SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL =
312
366
  const SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL =
313
367
  PRE_MARKETING_SITE_SEVEN_DAY_RETENTION_BY_TEMPLATE_SQL.replace(
314
368
  KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER,
315
- `${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
369
+ `${FIRST_PARTY_KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
316
370
  );
317
371
  export const PRE_MARKETING_SITE_RECURRING_USERS_BY_TEMPLATE_SQL = `WITH first_seen AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, MIN(${EVENT_DATE_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} GROUP BY 1), activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER}) SELECT a.event_date AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM activity a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' GROUP BY 1, 2 ORDER BY date, template`;
318
372
  export const DOUBLE_SCAN_RECURRING_USERS_BY_TEMPLATE_SQL =
@@ -320,14 +374,14 @@ export const DOUBLE_SCAN_RECURRING_USERS_BY_TEMPLATE_SQL =
320
374
  PRODUCT_ACTIVITY_TEMPLATE_FILTER,
321
375
  `${PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
322
376
  );
323
- const RECURRING_USERS_BY_TEMPLATE_SQL = `WITH activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template, MIN(${EVENT_DATE_SQL}) OVER (PARTITION BY ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER}) SELECT event_date AS date, template, COUNT(DISTINCT user_key) AS users FROM activity WHERE event_date <> first_date AND template <> 'unknown' AND ${DASHBOARD_TIME_RANGE_FILTER} GROUP BY 1, 2 ORDER BY date, template`;
377
+ const RECURRING_USERS_BY_TEMPLATE_SQL = `WITH activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template, MIN(${EVENT_DATE_SQL}) OVER (PARTITION BY ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${FIRST_PARTY_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER}) SELECT event_date AS date, template, COUNT(DISTINCT user_key) AS users FROM activity WHERE event_date <> first_date AND template <> 'unknown' AND ${DASHBOARD_TIME_RANGE_FILTER} GROUP BY 1, 2 ORDER BY date, template`;
324
378
  export const PRE_MARKETING_SITE_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH first_seen AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, MIN(${EVENT_DATE_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} GROUP BY 1), activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER}) SELECT to_char(date_trunc('week', a.event_date::date), 'YYYY-MM-DD') AS date, a.template AS template, COUNT(DISTINCT a.user_key) AS users FROM activity a JOIN first_seen f ON f.user_key = a.user_key WHERE a.event_date <> f.first_date AND a.template <> 'unknown' GROUP BY 1, 2 ORDER BY date, template`;
325
379
  export const DOUBLE_SCAN_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL =
326
380
  PRE_MARKETING_SITE_RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL.replace(
327
381
  PRODUCT_ACTIVITY_TEMPLATE_FILTER,
328
382
  `${PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
329
383
  );
330
- const RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template, MIN(${EVENT_DATE_SQL}) OVER (PARTITION BY ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER}) SELECT to_char(date_trunc('week', event_date::date), 'YYYY-MM-DD') AS date, template, COUNT(DISTINCT user_key) AS users FROM activity WHERE event_date <> first_date AND template <> 'unknown' AND ${DASHBOARD_TIME_RANGE_FILTER} GROUP BY 1, 2 ORDER BY date, template`;
384
+ const RECURRING_USERS_BY_TEMPLATE_WEEKLY_SQL = `WITH activity AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, ${EVENT_DATE_SQL} AS event_date, ${TEMPLATE_EXPR} AS template, MIN(${EVENT_DATE_SQL}) OVER (PARTITION BY ${SIGNED_IN_ACTIVITY_KEY_SQL}) AS first_date FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${FIRST_PARTY_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${OBSERVED_ACTIVITY_LOOKBACK_FILTER}) SELECT to_char(date_trunc('week', event_date::date), 'YYYY-MM-DD') AS date, template, COUNT(DISTINCT user_key) AS users FROM activity WHERE event_date <> first_date AND template <> 'unknown' AND ${DASHBOARD_TIME_RANGE_FILTER} GROUP BY 1, 2 ORDER BY date, template`;
331
385
  const LEGACY_RECURRING_USERS_DESCRIPTION =
332
386
  "Daily signed-in visitors who are NOT on their all-time first active day (Recurring only), stacked by inferred template/app used that day. Docs traffic and unknown template are excluded.";
333
387
  const LEGACY_RECURRING_USERS_WEEKLY_DESCRIPTION =
@@ -461,10 +515,22 @@ export function repairFirstPartyObservedRetentionPanels(
461
515
  sql: REPEAT_USERS_SQL,
462
516
  },
463
517
  ],
464
- ...additionalReplacements.map(
465
- (replacement) => [replacement.id, replacement] as const,
466
- ),
467
518
  ]);
519
+ for (const replacement of additionalReplacements) {
520
+ const existing = replacements.get(replacement.id);
521
+ replacements.set(
522
+ replacement.id,
523
+ existing
524
+ ? {
525
+ ...existing,
526
+ ...replacement,
527
+ legacySql: Array.from(
528
+ new Set([...existing.legacySql, ...replacement.legacySql]),
529
+ ),
530
+ }
531
+ : replacement,
532
+ );
533
+ }
468
534
  let changed = false;
469
535
  const panels = config.panels.map((rawPanel) => {
470
536
  if (!rawPanel || typeof rawPanel !== "object") return rawPanel;
@@ -509,18 +575,18 @@ export const LEGACY_DAU_BY_TEMPLATE_SQL = `SELECT ${EVENT_DATE_SQL} AS date, ${T
509
575
  export const PRE_MARKETING_SITE_DAU_BY_TEMPLATE_SQL = `SELECT ${EVENT_DATE_SQL} AS date, ${TEMPLATE_EXPR} AS template, COUNT(DISTINCT user_key) AS visitors FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, ${TEMPLATE_EXPR} ORDER BY date, template`;
510
576
  const DAU_BY_TEMPLATE_SQL = PRE_MARKETING_SITE_DAU_BY_TEMPLATE_SQL.replace(
511
577
  ` AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} GROUP BY`,
512
- ` AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} GROUP BY`,
578
+ ` AND ${FIRST_PARTY_KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} GROUP BY`,
513
579
  );
514
580
  export const LEGACY_WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS visitor_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_EVENT_DATE_RANGE_FILTER}) SELECT d.date, b.template, COUNT(DISTINCT b.visitor_key) AS visitors FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
515
581
  export const PRE_MARKETING_SITE_WAU_BY_TEMPLATE_SQL = `WITH base AS (SELECT user_key AS visitor_key, ${TEMPLATE_EXPR} AS template, ${EVENT_DATE_SQL} AS event_date, user_id FROM analytics_events WHERE ${SIGNED_IN_ACTIVITY_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}), days AS (SELECT DISTINCT event_date AS date FROM base WHERE ${DASHBOARD_EVENT_DATE_RANGE_FILTER}) SELECT d.date, b.template, COUNT(DISTINCT b.visitor_key) AS visitors FROM days d JOIN base b ON b.event_date >= to_char(d.date::date - INTERVAL '6 days', 'YYYY-MM-DD') AND b.event_date <= d.date GROUP BY d.date, b.template ORDER BY d.date, b.template`;
516
582
  const WAU_BY_TEMPLATE_SQL = PRE_MARKETING_SITE_WAU_BY_TEMPLATE_SQL.replace(
517
583
  ` AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}`,
518
- ` AND ${KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}`,
584
+ ` AND ${FIRST_PARTY_KNOWN_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER} AND ${DASHBOARD_WAU_BASE_RANGE_FILTER}`,
519
585
  );
520
586
  const PRE_MARKETING_SITE_REPEAT_USERS_SQL = `WITH user_days AS (SELECT ${SIGNED_IN_ACTIVITY_KEY_SQL} AS user_key, COUNT(DISTINCT ${EVENT_DATE_SQL}) AS active_days FROM analytics_events WHERE ${SIGNED_IN_PRODUCT_ACTIVITY_FILTER} AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${SIGNED_IN_ACTIVITY_KEY_SQL}) SELECT COUNT(*) AS count FROM user_days WHERE active_days >= 2`;
521
587
  const REPEAT_USERS_SQL = PRE_MARKETING_SITE_REPEAT_USERS_SQL.replace(
522
588
  PRODUCT_ACTIVITY_TEMPLATE_FILTER,
523
- `${PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
589
+ `${FIRST_PARTY_PRODUCT_ACTIVITY_TEMPLATE_FILTER} AND ${MARKETING_SITE_TEMPLATE_FILTER}`,
524
590
  );
525
591
 
526
592
  /**
@@ -590,7 +656,7 @@ const ENTRIES: FirstPartyMetric[] = [
590
656
  width: 2,
591
657
  windowed: false,
592
658
  buildSql: fixed(
593
- `SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE ${SESSION_STATUS_FILTER} GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20`,
659
+ `SELECT COALESCE(NULLIF(app, ''), 'unknown') AS app, COUNT(*) AS count FROM analytics_events WHERE ${SESSION_STATUS_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY COALESCE(NULLIF(app, ''), 'unknown') ORDER BY count DESC LIMIT 20`,
594
660
  ),
595
661
  config: {
596
662
  xKey: "app",
@@ -698,7 +764,7 @@ const ENTRIES: FirstPartyMetric[] = [
698
764
  width: 1,
699
765
  windowed: false,
700
766
  buildSql: fixed(
701
- `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`,
767
+ `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`,
702
768
  ),
703
769
  config: {
704
770
  yKey: "count",
@@ -714,7 +780,7 @@ const ENTRIES: FirstPartyMetric[] = [
714
780
  width: 1,
715
781
  windowed: false,
716
782
  buildSql: fixed(
717
- `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`,
783
+ `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`,
718
784
  ),
719
785
  config: {
720
786
  yKey: "count",
@@ -730,7 +796,7 @@ const ENTRIES: FirstPartyMetric[] = [
730
796
  width: 1,
731
797
  windowed: false,
732
798
  buildSql: fixed(
733
- `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER}`,
799
+ `SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER}`,
734
800
  ),
735
801
  config: {
736
802
  yKey: "count",
@@ -746,7 +812,7 @@ const ENTRIES: FirstPartyMetric[] = [
746
812
  width: 2,
747
813
  windowed: false,
748
814
  buildSql: fixed(
749
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
815
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
750
816
  ),
751
817
  config: {
752
818
  xKey: "date",
@@ -768,7 +834,7 @@ const ENTRIES: FirstPartyMetric[] = [
768
834
  width: 2,
769
835
  windowed: false,
770
836
  buildSql: fixed(
771
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
837
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
772
838
  ),
773
839
  config: {
774
840
  xKey: "date",
@@ -790,7 +856,7 @@ const ENTRIES: FirstPartyMetric[] = [
790
856
  width: 2,
791
857
  windowed: false,
792
858
  buildSql: fixed(
793
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
859
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
794
860
  ),
795
861
  config: {
796
862
  xKey: "date",
@@ -812,7 +878,7 @@ const ENTRIES: FirstPartyMetric[] = [
812
878
  width: 2,
813
879
  windowed: false,
814
880
  buildSql: fixed(
815
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
881
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
816
882
  ),
817
883
  config: {
818
884
  xKey: "date",
@@ -834,7 +900,7 @@ const ENTRIES: FirstPartyMetric[] = [
834
900
  width: 2,
835
901
  windowed: false,
836
902
  buildSql: fixed(
837
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
903
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template`,
838
904
  ),
839
905
  config: {
840
906
  xKey: "date",
@@ -860,7 +926,7 @@ const ENTRIES: FirstPartyMetric[] = [
860
926
  // Browser telemetry emits explicit `pageview` events with URL context, so
861
927
  // keep pageview panels scoped to that event instead of all tracked events.
862
928
  buildSql: fixed(
863
- `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'pageview' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template`,
929
+ `SELECT ${EVENT_DATE_SQL} AS date, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'pageview' AND ${DASHBOARD_TIME_RANGE_FILTER} AND ${DASHBOARD_EMAIL_FILTER} AND ${FIRST_PARTY_TEMPLATE_FILTER} GROUP BY ${EVENT_DATE_SQL}, COALESCE(NULLIF(template, ''), NULLIF(app, ''), 'unknown') ORDER BY date, template`,
864
930
  ),
865
931
  config: {
866
932
  xKey: "date",
@@ -143,7 +143,7 @@ function isDashboardMutationActionName(name: string): boolean {
143
143
  // dashboard construction, distinct from an analytics-result question. Turns
144
144
  // like this may inspect and clone a template without running a metric query.
145
145
  const DASHBOARD_CONSTRUCTION_INTENT_TERMS =
146
- /\b(build|create|make|clone|copy|duplicate|adapt|update|edit|change|modify|rename|adjust|simplify|switch|template|based (?:off|on)|using .{1,80}? as a template)\b/i;
146
+ /\b(build|create|make|clone|copy|duplicate|adapt|update|edit|change|modify|rename|adjust|refresh|simplify|switch|template|based (?:off|on)|using .{1,80}? as a template)\b/i;
147
147
 
148
148
  const DASHBOARD_CONSTRUCTION_TARGET_TERMS =
149
149
  /\b(dashboard|extension|panel|widget)\b/i;
@@ -61,7 +61,20 @@ function hasSuccessfulDashboardSave(
61
61
  .trim()
62
62
  .toLowerCase()
63
63
  .replace(/[\s_]+/g, "-");
64
- return saveActions.has(name);
64
+ if (!saveActions.has(name)) return false;
65
+ const content = String(result.content ?? "").trim();
66
+ if (!content.startsWith("{")) return true;
67
+ try {
68
+ const parsed = JSON.parse(content) as Record<string, unknown>;
69
+ if (parsed.saved === false) return false;
70
+ if (name === "compose-dashboard" && parsed.changed === false) {
71
+ return false;
72
+ }
73
+ // coercion-ok: malformed structured action output fails closed below.
74
+ } catch {
75
+ return false;
76
+ }
77
+ return true;
65
78
  });
66
79
  }
67
80
 
@@ -713,6 +726,7 @@ export function realDataFinalGuard(
713
726
  }
714
727
  if (
715
728
  hasDashboardMutationAttempt(context.toolResults) &&
729
+ hasSuccessfulDashboardSave(context.toolResults) &&
716
730
  !draftClaimsAnalyticsMetrics(context.text)
717
731
  ) {
718
732
  return null;
@@ -930,7 +944,8 @@ export default createAgentChatPlugin({
930
944
  analyticsSourceGuidanceOpening() +
931
945
  "DASHBOARD CREATION RULE — You may create dashboard artifacts, SQL panels, or other resources only when the user explicitly asks you to (e.g. 'build me a dashboard for...', 'save this analysis', 'add a chart for...'). Treat a requested saved analysis or deep-dive report as a dashboard request. Never create any resource proactively during research, trend analysis, or answering questions. If you think a dashboard would be useful, suggest it and wait for explicit confirmation before creating anything. Never add new items to the sidebar or modify existing dashboards without an explicit user directive. " +
932
946
  "EXECUTION CONTINUITY — An explicit request to build, create, save, or adapt a dashboard or one-off Custom Block authorizes all non-destructive in-app steps required to finish it in the same turn. After querying or scaffolding, continue through extension-data seeding/refresh, dashboard save/embed, and navigation. Do not ask 'want me to proceed?' or stop at an empty shell. Ask one clarification only when metric scope or grain materially changes the result, and pause for destructive changes or external side effects such as sending email or outreach. " +
933
- "DASHBOARD MUTATION RULE — For dashboard edits, default to `mutate-dashboard` with the typed `dashboard.*` script API so the main payload is a string and avoids native-array serialization traps. It can move panels by id, edit titles/SQL/config, insert, duplicate, remove, and patch dashboard fields in one atomic save. The script API is constrained: no variables/imports/loops/functions, only JSON-compatible arguments on documented dashboard methods. Do not count shifting `/panels/<index>` positions for ordinary dashboard edits unless the user specifically asks for low-level JSON-pointer operations. " +
947
+ "APPROVED MUTATION CONTINUITY — If this turn includes an explicitly approved dashboard action with concrete input, execute that exact action and input immediately. Treat the supplied dashboard id as authoritative: do not reinterpret an existing-dashboard edit as a template clone, ask for a template name, or substitute an inspection step. A dashboard mutation is complete only when the action result proves `saved: true` and the requested change is reflected by `changed: true`, refreshed/changed panel ids, or an equivalent non-empty proof field; a natural-language acknowledgement or a no-op with skipped panels is not success. " +
948
+ "DASHBOARD MUTATION RULE — For first-party dashboard creation or catalog refreshes, use `compose-dashboard` with metric keys; set `refreshExisting: true` when updating matching catalog panels so the server regenerates validated SQL without sending a large SQL payload through the prompt. For ordinary existing edits, use `mutate-dashboard` with structured `operations` in one atomic save; use its short `code` form only for compact layout/config edits. Both forms address panels by id, validate the resulting config, and return proof. Never stream a large multi-panel SQL script or count shifting `/panels/<index>` positions unless the user specifically asks for low-level JSON-pointer operations. " +
934
949
  'CUSTOM BLOCK RULE — Analytics can embed sandboxed extensions as dashboard-scoped Custom Blocks, but native panels and Data Programs come first. Do not create one for an ordinary "put X in this dashboard" request. Use `config.extensionId` only for an explicitly requested one-off or bespoke visualization that the native dashboard model cannot represent faithfully. For each new block, set `config.customBlock` with `authoredBy: "agent"`, `intent: "one-off"`, `scope: "dashboard"`, and a categorical `nativeGapReason` of `custom-visualization`, `custom-interaction`, `custom-layout`, or `other`; never store prompt or customer text there. The embed is shared with the dashboard, appears in scheduled reports, and receives dashboard/panel/current-filter context. Use `config.extensionSlotId` only when the user explicitly asks for a personal/per-viewer slot. Slot ids use `analytics.dashboard.<dashboard-id>.panel.<panel-id>` and require `add-extension-slot-target` plus `install-extension`; installs are per-user, so viewers can see different content and report identities may see an empty slot. Use `get-sql-dashboard` panel summaries to inspect an existing Custom Block. ' +
935
950
  'EXTENSION DATA-REPAIR RULE — When fixing data in an existing extension-backed dashboard or migrated surface such as Risk Meeting, inspect the current dashboard and extension first, then call `update-extension` with exactly `id`, `operation="edit"`, and a `payloadJson` string containing focused patches/edits that change only the data-loading seam. Never send empty placeholder fields. Preserve the existing layout, CSS, copy, and interactions; never reconstruct the full HTML body for a data-only fix. A request that combines a visual rewrite such as compacting, removing sections, renaming, or changing padding with a data repair is a broad rewrite; after inspecting the current extension, use `operation="replace"` with the complete replacement in `payloadJson`. If a focused edit fails, change the target instead of retrying identical arguments. ' +
936
951
  'FIRST-PARTY DASHBOARD TIME RULE — AI-generated `source: "first-party"` panels are dashboard-time-bound by default: set `config.timeScope` to `dashboard` and include a matching dashboard time predicate. `{{timeRange}}` requires a matching `filters` entry with `id: "timeRange"` and `type: "select"`; `{{<id>Start}}`/`{{<id>End}}` require a matching `type: "date-range"` filter with that id. Allowed `timeScope` values are `dashboard`, `fixed-window`, `cohort-history`, and `all-time`; use `all-time` only when the user requests full available history and put all-time, lifetime, or historical in the title or description. Server validation rejects unbound first-party SQL. ' +
@@ -3,3 +3,5 @@
3
3
  // giving up first for an unrelated reason.
4
4
  export const FIRST_PARTY_ANALYTICS_QUERY_TIMEOUT_MS = 45_000;
5
5
  export const DASHBOARD_REPORT_ACTION_TIMEOUT_MS = 50_000;
6
+ /** A dashboard save must fail clearly if warehouse SQL validation stalls. */
7
+ export const DASHBOARD_SQL_VALIDATION_TIMEOUT_MS = 10_000;