@agent-native/core-corpus 0.210.2 → 0.211.0

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 (192) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/.agents/skills/actions/references/action-fields.md +5 -3
  3. package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +41 -2
  4. package/corpus/templates/analytics/.agents/skills/data-querying/SKILL.md +4 -2
  5. package/corpus/templates/analytics/CHANGELOG.md +18 -0
  6. package/corpus/templates/analytics/actions/build-data-index.ts +11 -2
  7. package/corpus/templates/analytics/actions/configure-content-collection.ts +29 -4
  8. package/corpus/templates/analytics/actions/configure-first-party-events-model.ts +9 -7
  9. package/corpus/templates/analytics/actions/create-analytics-public-key.ts +4 -0
  10. package/corpus/templates/analytics/actions/estimate-content-signups-cost.ts +72 -0
  11. package/corpus/templates/analytics/actions/estimate-query-cost.ts +16 -134
  12. package/corpus/templates/analytics/actions/list-sigma-workbooks.ts +123 -42
  13. package/corpus/templates/analytics/actions/migrate-sigma-workbook.ts +20 -17
  14. package/corpus/templates/analytics/actions/revoke-analytics-public-key.ts +4 -0
  15. package/corpus/templates/analytics/app/components/dashboard/SigmaMigrationDialog.tsx +246 -680
  16. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +1 -1
  17. package/corpus/templates/analytics/app/i18n/en-US.ts +54 -55
  18. package/corpus/templates/analytics/app/pages/DashboardOverview.tsx +0 -1
  19. package/corpus/templates/analytics/app/pages/DataSources.tsx +56 -0
  20. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/filter-vars.ts +4 -3
  21. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +26 -8
  22. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/interpolate.ts +3 -1
  23. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/panel-cost-notice.ts +172 -0
  24. package/corpus/templates/analytics/app/pages/data-sources/SearchConsoleDetail.tsx +441 -0
  25. package/corpus/templates/analytics/changelog/2026-10-15-a-content-dashboard-lists-every-blog-article-with-its-author.md +6 -0
  26. package/corpus/templates/analytics/changelog/2026-10-15-data-sources-has-a-google-search-console-card-that-shows-the.md +6 -0
  27. package/corpus/templates/analytics/changelog/2026-10-16-the-content-dashboard-can-show-signups-per-blog-article.md +6 -0
  28. package/corpus/templates/analytics/changelog/2026-10-19-creating-or-revoking-a-public-analytics-key-now-asks-you-to-.md +6 -0
  29. package/corpus/templates/analytics/changelog/2026-10-19-search-sigma-workbooks-by-name-and-open-ones-a-teammate-alre.md +6 -0
  30. package/corpus/templates/analytics/changelog/2026-10-20-blog-signup-attribution-excludes-signups-before-the-first-vi.md +6 -0
  31. package/corpus/templates/analytics/changelog/2026-10-20-collapsed-analytics-navigation-starts-at-the-top.md +6 -0
  32. package/corpus/templates/analytics/changelog/2026-10-20-content-keeps-successful-coverage-dates-and-missing-day-gaps.md +6 -0
  33. package/corpus/templates/analytics/changelog/2026-10-20-private-github-repositories-can-be-indexed-with-your-connect.md +6 -0
  34. package/corpus/templates/analytics/changelog/2026-10-20-search-console-preserves-backfill-and-paused-collection.md +6 -0
  35. package/corpus/templates/analytics/changelog/2026-10-20-search-console-refreshes-historical-coverage-when-properties.md +6 -0
  36. package/corpus/templates/analytics/changelog/2026-10-20-search-console-setup-keeps-saved-properties-readable-and-sup.md +6 -0
  37. package/corpus/templates/analytics/seeds/dashboards/content-view.json +182 -0
  38. package/corpus/templates/analytics/server/db/schema.ts +1 -0
  39. package/corpus/templates/analytics/server/lib/bi-migration/external-dashboards-store.ts +56 -18
  40. package/corpus/templates/analytics/server/lib/bi-migration/unfinished-import.ts +64 -0
  41. package/corpus/templates/analytics/server/lib/builtin-dashboard-pass.ts +17 -2
  42. package/corpus/templates/analytics/server/lib/content-metrics/collect.ts +2 -0
  43. package/corpus/templates/analytics/server/lib/content-metrics/config.ts +92 -3
  44. package/corpus/templates/analytics/server/lib/content-metrics/legacy-content-view.json +172 -0
  45. package/corpus/templates/analytics/server/lib/content-metrics/migration.ts +2 -0
  46. package/corpus/templates/analytics/server/lib/content-metrics/steps/daily-metrics.ts +8 -3
  47. package/corpus/templates/analytics/server/lib/content-metrics/steps/search-console.ts +16 -3
  48. package/corpus/templates/analytics/server/lib/content-metrics/steps/signups.ts +240 -0
  49. package/corpus/templates/analytics/server/lib/content-metrics/store.ts +5 -0
  50. package/corpus/templates/analytics/server/lib/dashboard-catalog.ts +21 -0
  51. package/corpus/templates/analytics/server/lib/dashboard-panel-verification.ts +300 -5
  52. package/corpus/templates/analytics/server/lib/dashboard-seeds.ts +2 -0
  53. package/corpus/templates/analytics/server/lib/dashboards-store.ts +28 -0
  54. package/corpus/templates/analytics/server/lib/first-party-analytics-backend.ts +128 -24
  55. package/corpus/templates/analytics/server/lib/google-analytics.ts +56 -16
  56. package/corpus/templates/analytics/server/lib/index-build-github.ts +5 -0
  57. package/corpus/templates/analytics/server/lib/provider-credentials.ts +47 -2
  58. package/corpus/templates/analytics/server/lib/query-cost-estimate.ts +184 -5
  59. package/corpus/templates/analytics/server/plugins/db.ts +9 -1
  60. package/corpus/templates/analytics/shared/builtin-dashboard-ids.ts +2 -0
  61. package/corpus/templates/analytics/shared/search-console-property.ts +78 -0
  62. package/corpus/templates/assets/.agents/skills/actions/references/action-fields.md +5 -3
  63. package/corpus/templates/brain/.agents/skills/actions/references/action-fields.md +5 -3
  64. package/corpus/templates/calendar/.agents/skills/actions/references/action-fields.md +5 -3
  65. package/corpus/templates/calendar/actions/delete-event.ts +1 -3
  66. package/corpus/templates/calendar/actions/delete-events.ts +1 -2
  67. package/corpus/templates/calendar/actions/event-action-helpers.ts +0 -4
  68. package/corpus/templates/calendar/actions/update-event.ts +2 -6
  69. package/corpus/templates/chat/.agents/skills/actions/references/action-fields.md +5 -3
  70. package/corpus/templates/clips/.agents/skills/actions/references/action-fields.md +5 -3
  71. package/corpus/templates/clips/CHANGELOG.md +11 -24
  72. package/corpus/templates/clips/actions/create-recording-upload.ts +5 -1
  73. package/corpus/templates/clips/app/components/player/chapter-list.tsx +7 -6
  74. package/corpus/templates/clips/app/components/player/comment-composer.tsx +5 -1
  75. package/corpus/templates/clips/app/components/player/player-controls.tsx +1 -0
  76. package/corpus/templates/clips/app/components/player/scrubber.tsx +4 -2
  77. package/corpus/templates/clips/app/i18n/en-US.ts +3 -3
  78. package/corpus/templates/clips/app/lib/extension-auth.ts +70 -0
  79. package/corpus/templates/clips/app/root.tsx +22 -10
  80. package/corpus/templates/clips/app/routes/_app.notifications.tsx +3 -0
  81. package/corpus/templates/clips/app/routes/import.tsx +115 -110
  82. package/corpus/templates/clips/changelog/2026-10-16-agent-uploads-can-use-free-clips-storage.md +6 -0
  83. package/corpus/templates/clips/changelog/2026-10-19-agents-see-a-clip-s-frames-as-soon-as-it-finishes-processing.md +6 -0
  84. package/corpus/templates/clips/changelog/2026-10-19-chapter-list-times-now-match-the-player-s-clock-and-each-cha.md +6 -0
  85. package/corpus/templates/clips/changelog/2026-10-19-editing-a-comment-puts-the-cursor-after-its-text-and-reply-o.md +6 -0
  86. package/corpus/templates/clips/changelog/2026-10-19-youtube-import-followups.md +6 -0
  87. package/corpus/templates/clips/changelog/2026-10-20-locally-saved-desktop-recordings-now-appear-in-the-recovery-.md +6 -0
  88. package/corpus/templates/clips/desktop/src/app.tsx +29 -186
  89. package/corpus/templates/clips/desktop/src/components/RecordingRecovery.tsx +171 -16
  90. package/corpus/templates/clips/desktop/src/lib/record-first.ts +9 -32
  91. package/corpus/templates/clips/desktop/src/lib/recording-recovery.ts +30 -1
  92. package/corpus/templates/clips/server/lib/agent-frame-request.ts +5 -2
  93. package/corpus/templates/clips/server/lib/recording-upload-ticket.ts +8 -0
  94. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +2 -0
  95. package/corpus/templates/content/.agents/skills/actions/references/action-fields.md +5 -3
  96. package/corpus/templates/content/CHANGELOG.md +12 -0
  97. package/corpus/templates/content/actions/_content-database-personal-view.ts +5 -15
  98. package/corpus/templates/content/actions/_database-navigation.ts +3 -3
  99. package/corpus/templates/content/actions/_database-utils.ts +10 -22
  100. package/corpus/templates/content/actions/_database-view-setup.ts +16 -76
  101. package/corpus/templates/content/actions/create-comment-ai-suggestion.ts +93 -44
  102. package/corpus/templates/content/actions/export-document.ts +2 -14
  103. package/corpus/templates/content/actions/suggest-document-edit.ts +5 -7
  104. package/corpus/templates/content/actions/update-content-database-view.ts +2 -14
  105. package/corpus/templates/content/app/components/editor/CommentsSidebar.tsx +86 -30
  106. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +64 -28
  107. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +6 -46
  108. package/corpus/templates/content/app/components/editor/PageDraftRecovery.tsx +72 -7
  109. package/corpus/templates/content/app/components/editor/TableHoverControls.tsx +292 -103
  110. package/corpus/templates/content/app/components/editor/ThreadSuggestion.tsx +147 -40
  111. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +108 -11
  112. package/corpus/templates/content/app/components/editor/database/ContentTable.tsx +2 -1
  113. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +150 -166
  114. package/corpus/templates/content/app/components/editor/database/database-cell-comments.tsx +515 -0
  115. package/corpus/templates/content/app/components/editor/database/database-item-comments.tsx +5 -43
  116. package/corpus/templates/content/app/components/editor/database/filter-sort.ts +8 -783
  117. package/corpus/templates/content/app/components/editor/live-editor-session.ts +5 -1
  118. package/corpus/templates/content/app/components/editor/page-draft-journal.ts +61 -1
  119. package/corpus/templates/content/app/components/editor/property-comments.tsx +35 -4
  120. package/corpus/templates/content/app/components/editor/property-date-format.ts +45 -0
  121. package/corpus/templates/content/app/components/editor/thread-suggestions.ts +30 -0
  122. package/corpus/templates/content/app/components/editor/useDocumentReconcileRecovery.ts +23 -4
  123. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +36 -10
  124. package/corpus/templates/content/app/global.css +23 -0
  125. package/corpus/templates/content/app/i18n-data.ts +1 -0
  126. package/corpus/templates/content/changelog/2026-10-16-opening-a-page-in-a-new-tab-while-another-tab-is-still-savin.md +6 -0
  127. package/corpus/templates/content/changelog/2026-10-16-typing-steadily-in-one-tab-no-longer-makes-another-open-tab-.md +6 -0
  128. package/corpus/templates/content/changelog/2026-10-19-database-views-can-filter-the-same-property-more-than-once-n.md +6 -0
  129. package/corpus/templates/content/changelog/2026-10-19-edits-made-from-a-block-s-menu-or-a-table-s-row-and-column-m.md +6 -0
  130. package/corpus/templates/content/changelog/2026-10-19-starting-a-comment-puts-the-cursor-in-the-comment-box-ready-.md +6 -0
  131. package/corpus/templates/content/changelog/2026-10-19-table-row-and-column-options-now-appear-for-the-row-or-colum.md +6 -0
  132. package/corpus/templates/content/docs/product/capabilities/content.comment.page-owned.md +5 -1
  133. package/corpus/templates/content/docs/product/capabilities/content.revision.suggestions.md +3 -1
  134. package/corpus/templates/content/e2e/helpers.ts +10 -0
  135. package/corpus/templates/content/shared/api.ts +18 -12
  136. package/corpus/templates/content/shared/database-query.ts +107 -13
  137. package/corpus/templates/content/shared/suggestion-diff.ts +35 -0
  138. package/corpus/templates/crm/.agents/skills/actions/references/action-fields.md +5 -3
  139. package/corpus/templates/design/.agents/skills/actions/references/action-fields.md +5 -3
  140. package/corpus/templates/design/CHANGELOG.md +2 -0
  141. package/corpus/templates/design/actions/delete-file.ts +1 -4
  142. package/corpus/templates/design/app/components/design/LayersPanel.tsx +10 -1
  143. package/corpus/templates/design/app/components/visual-editor/ReviewCanvasPins.tsx +24 -4
  144. package/corpus/templates/design/app/pages/design-editor/commands/mode-change.ts +14 -9
  145. package/corpus/templates/design/app/pages/design-editor/domains/use-editor-modes.ts +23 -2
  146. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +29 -0
  147. package/corpus/templates/design/changelog/2026-10-19-reply-and-edit-on-a-comment-in-the-sidebar-or-on-a-canvas-pi.md +6 -0
  148. package/corpus/templates/design/changelog/2026-10-20-comments-return-to-the-mode-they-came-from-and-layers-search.md +6 -0
  149. package/corpus/templates/design/e2e/global-setup.ts +185 -85
  150. package/corpus/templates/dispatch/.agents/skills/actions/references/action-fields.md +5 -3
  151. package/corpus/templates/dispatch/CHANGELOG.md +4 -0
  152. package/corpus/templates/dispatch/actions/apply-workspace-connection-setup.ts +4 -0
  153. package/corpus/templates/dispatch/actions/delete-workspace-connection.ts +4 -0
  154. package/corpus/templates/dispatch/actions/set-workspace-connection-grant.ts +4 -0
  155. package/corpus/templates/dispatch/actions/upsert-workspace-connection.ts +4 -0
  156. package/corpus/templates/dispatch/changelog/2026-10-19-vault-secrets-grants-link-tokens-app-access-and-workspace-co.md +6 -0
  157. package/corpus/templates/factory/.agents/skills/actions/references/action-fields.md +5 -3
  158. package/corpus/templates/factory/.agents/skills/review-latest-feedback/SKILL.md +105 -33
  159. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/channel-inventory.md +128 -74
  160. package/corpus/templates/factory/.agents/skills/review-latest-feedback/references/slack-reaction-replies.md +39 -11
  161. package/corpus/templates/forms/.agents/skills/actions/references/action-fields.md +5 -3
  162. package/corpus/templates/mail/.agents/skills/actions/references/action-fields.md +5 -3
  163. package/corpus/templates/mail/.agents/skills/contacts-and-crm/SKILL.md +22 -17
  164. package/corpus/templates/mail/AGENTS.md +1 -1
  165. package/corpus/templates/mail/CHANGELOG.md +14 -0
  166. package/corpus/templates/mail/actions/provider-api-request.ts +3 -3
  167. package/corpus/templates/mail/app/components/email/IntegrationsSidebar.tsx +211 -54
  168. package/corpus/templates/mail/app/hooks/use-inbox-threads.ts +26 -2
  169. package/corpus/templates/mail/app/i18n/en-US.ts +5 -1
  170. package/corpus/templates/mail/app/lib/gong-connection.ts +27 -0
  171. package/corpus/templates/mail/app/lib/thread-cache.ts +110 -9
  172. package/corpus/templates/mail/app/root.tsx +10 -2
  173. package/corpus/templates/mail/changelog/2026-10-20-connected-gong-settings-now-open-the-shared-integration-mana.md +6 -0
  174. package/corpus/templates/mail/changelog/2026-10-20-gong-connection-details-now-match-the-call-data-available-in.md +6 -0
  175. package/corpus/templates/mail/changelog/2026-10-20-improve-gong-setup-with-a-click-to-connect-flow-and-permissi.md +6 -0
  176. package/corpus/templates/mail/changelog/2026-10-20-mail-lets-you-retry-when-it-cannot-check-your-gong-access.md +6 -0
  177. package/corpus/templates/mail/changelog/2026-10-20-mail-thread-cache-stays-isolated-when-mailbox-scope-changes.md +6 -0
  178. package/corpus/templates/mail/changelog/2026-10-20-the-inbox-and-recently-opened-threads-now-open-instantly-fro.md +6 -0
  179. package/corpus/templates/mail/server/handlers/emails.ts +9 -0
  180. package/corpus/templates/plan/.agents/skills/actions/references/action-fields.md +5 -3
  181. package/corpus/templates/plan/CHANGELOG.md +4 -0
  182. package/corpus/templates/plan/app/pages/PlansPage.tsx +173 -33
  183. package/corpus/templates/plan/changelog/2026-10-19-opening-a-comment-thread-puts-the-cursor-in-the-reply-box-an.md +6 -0
  184. package/corpus/templates/slides/.agents/skills/actions/references/action-fields.md +5 -3
  185. package/corpus/templates/slides/app/components/editor/EditorToolbar.tsx +6 -42
  186. package/corpus/templates/slides/app/components/editor/FloatingToolbar.tsx +3 -7
  187. package/corpus/templates/slides/app/components/editor/SlideEditor.tsx +5 -14
  188. package/corpus/templates/slides/app/i18n/en-US.ts +0 -2
  189. package/corpus/templates/slides/app/pages/DeckEditor.tsx +27 -33
  190. package/corpus/templates/slides/scripts/edit-fidelity/run.ts +1 -3
  191. package/corpus/templates/tasks/.agents/skills/actions/references/action-fields.md +5 -3
  192. package/package.json +1 -1
@@ -0,0 +1,172 @@
1
+ {
2
+ "name": "Content",
3
+ "description": "Blog articles with authors, daily GA4 pageviews, and Search Console clicks and impressions, from Analytics' daily content collection. Reads only Analytics' own tables, so opening it calls no provider.",
4
+ "filters": [
5
+ {
6
+ "id": "timeRange",
7
+ "type": "select",
8
+ "label": "Time range",
9
+ "default": "28d",
10
+ "options": [
11
+ {
12
+ "value": "7d",
13
+ "label": "Last 7 days"
14
+ },
15
+ {
16
+ "value": "28d",
17
+ "label": "Last 28 days"
18
+ },
19
+ {
20
+ "value": "90d",
21
+ "label": "Last 90 days"
22
+ },
23
+ {
24
+ "value": "all",
25
+ "label": "All time"
26
+ }
27
+ ]
28
+ }
29
+ ],
30
+ "panels": [
31
+ {
32
+ "id": "collection-status",
33
+ "title": "Collection",
34
+ "chartType": "table",
35
+ "source": "first-party",
36
+ "width": 3,
37
+ "sql": "WITH latest AS ( SELECT step, status, started_at, window_end, error_message FROM ( SELECT step, status, started_at, window_end, error_message, ROW_NUMBER() OVER (PARTITION BY step ORDER BY started_at DESC) AS rn FROM analytics_content_collection_runs ) ranked WHERE rn = 1 ) SELECT CASE step WHEN 'builder_blog' THEN 'Blog articles' WHEN 'ga4' THEN 'GA4 pageviews' WHEN 'search_console' THEN 'Search Console' ELSE step END AS source, CASE status WHEN 'succeeded' THEN 'Collected' WHEN 'failed' THEN 'Failed' WHEN 'cost_limited' THEN 'Cost-limited' WHEN 'not_configured' THEN 'Not set up' WHEN 'running' THEN 'Running' ELSE status END AS status, started_at AS last_run, window_end AS data_through, error_message AS detail FROM latest ORDER BY CASE step WHEN 'builder_blog' THEN 1 WHEN 'ga4' THEN 2 WHEN 'search_console' THEN 3 ELSE 4 END",
38
+ "config": {
39
+ "timeScope": "fixed-window",
40
+ "description": "Each source's latest daily run. Data through is the last day that source reported; Search Console runs two to three days behind.",
41
+ "columns": [
42
+ {
43
+ "key": "source",
44
+ "label": "Source"
45
+ },
46
+ {
47
+ "key": "status",
48
+ "label": "Status"
49
+ },
50
+ {
51
+ "key": "last_run",
52
+ "label": "Last run",
53
+ "format": "date"
54
+ },
55
+ {
56
+ "key": "data_through",
57
+ "label": "Data through"
58
+ },
59
+ {
60
+ "key": "detail",
61
+ "label": "Detail"
62
+ }
63
+ ]
64
+ }
65
+ },
66
+ {
67
+ "id": "articles",
68
+ "title": "Articles",
69
+ "chartType": "table",
70
+ "source": "first-party",
71
+ "width": 3,
72
+ "sql": "WITH latest AS ( SELECT step, status, started_at, window_end, error_message FROM ( SELECT step, status, started_at, window_end, error_message, ROW_NUMBER() OVER (PARTITION BY step ORDER BY started_at DESC) AS rn FROM analytics_content_collection_runs ) ranked WHERE rn = 1 ), totals AS ( SELECT item_id, SUM(value) FILTER (WHERE metric = 'ga4_pageviews') AS pageviews, COUNT(*) FILTER (WHERE metric = 'ga4_pageviews') AS pageview_rows, SUM(value) FILTER (WHERE metric = 'search_console_clicks') AS clicks, SUM(value) FILTER (WHERE metric = 'search_console_impressions') AS impressions, COUNT(*) FILTER (WHERE metric = 'search_console_clicks') AS search_rows FROM analytics_content_metric_snapshots WHERE (metric_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND metric_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '28d' AND metric_date >= to_char(CURRENT_DATE - INTERVAL '28 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND metric_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = 'custom' AND metric_date >= '{{timeRangeStart}}' AND metric_date <= '{{timeRangeEnd}}'))) GROUP BY item_id ) SELECT title, href, author, published, pageviews, clicks, impressions, NULLIF(COALESCE(ga4_missing, '') || CASE WHEN ga4_missing IS NOT NULL AND search_missing IS NOT NULL THEN ' · ' ELSE '' END || COALESCE(search_missing, ''), '') AS not_collected FROM ( SELECT i.title, i.url AS href, i.author, i.published_at, substr(i.published_at, 1, 10) AS published, t.pageviews, t.clicks, t.impressions, CASE WHEN COALESCE(t.pageview_rows, 0) > 0 THEN NULL ELSE 'GA4: ' || CASE ga4_run.status WHEN 'failed' THEN 'failed' WHEN 'cost_limited' THEN 'cost-limited' WHEN 'not_configured' THEN 'not set up' WHEN 'running' THEN 'running' WHEN 'succeeded' THEN 'still arriving' ELSE 'not collected yet' END END AS ga4_missing, CASE WHEN COALESCE(t.search_rows, 0) > 0 THEN NULL ELSE 'Search Console: ' || CASE search_run.status WHEN 'failed' THEN 'failed' WHEN 'cost_limited' THEN 'cost-limited' WHEN 'not_configured' THEN 'not set up' WHEN 'running' THEN 'running' WHEN 'succeeded' THEN 'still arriving' ELSE 'not collected yet' END END AS search_missing FROM analytics_content_items i LEFT JOIN totals t ON t.item_id = i.id LEFT JOIN latest ga4_run ON ga4_run.step = 'ga4' LEFT JOIN latest search_run ON search_run.step = 'search_console' WHERE i.source = 'builder_blog' ) articles ORDER BY published_at DESC NULLS LAST LIMIT 1000",
73
+ "config": {
74
+ "timeScope": "dashboard",
75
+ "description": "Every collected blog article. A blank number means that source has no data for the article in this window; Not collected says why.",
76
+ "sortable": true,
77
+ "columns": [
78
+ {
79
+ "key": "title",
80
+ "label": "Article",
81
+ "format": "link",
82
+ "linkKey": "href"
83
+ },
84
+ {
85
+ "key": "href",
86
+ "hidden": true
87
+ },
88
+ {
89
+ "key": "author",
90
+ "label": "Author"
91
+ },
92
+ {
93
+ "key": "published",
94
+ "label": "Published"
95
+ },
96
+ {
97
+ "key": "pageviews",
98
+ "label": "Pageviews",
99
+ "format": "number"
100
+ },
101
+ {
102
+ "key": "clicks",
103
+ "label": "Search clicks",
104
+ "format": "number"
105
+ },
106
+ {
107
+ "key": "impressions",
108
+ "label": "Impressions",
109
+ "format": "number"
110
+ },
111
+ {
112
+ "key": "not_collected",
113
+ "label": "Not collected"
114
+ }
115
+ ]
116
+ }
117
+ },
118
+ {
119
+ "id": "daily-trend",
120
+ "title": "Daily pageviews and search clicks",
121
+ "chartType": "line",
122
+ "source": "first-party",
123
+ "width": 3,
124
+ "sql": "SELECT s.metric_date AS date, SUM(s.value) FILTER (WHERE s.metric = 'ga4_pageviews') AS pageviews, SUM(s.value) FILTER (WHERE s.metric = 'search_console_clicks') AS search_clicks FROM analytics_content_metric_snapshots s JOIN analytics_content_items i ON i.id = s.item_id WHERE i.source = 'builder_blog' AND s.metric IN ('ga4_pageviews', 'search_console_clicks') AND (s.metric_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '28d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '28 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = 'custom' AND s.metric_date >= '{{timeRangeStart}}' AND s.metric_date <= '{{timeRangeEnd}}'))) GROUP BY s.metric_date ORDER BY s.metric_date",
125
+ "config": {
126
+ "timeScope": "dashboard",
127
+ "xKey": "date",
128
+ "yKeys": ["pageviews", "search_clicks"],
129
+ "seriesLabels": {
130
+ "pageviews": "Pageviews",
131
+ "search_clicks": "Search clicks"
132
+ },
133
+ "description": "All blog articles. A day a source hasn't reported is a gap, not a zero."
134
+ }
135
+ },
136
+ {
137
+ "id": "search-pages",
138
+ "title": "Other pages in search",
139
+ "chartType": "table",
140
+ "source": "first-party",
141
+ "width": 3,
142
+ "sql": "SELECT i.url AS page, i.url AS href, SUM(s.value) FILTER (WHERE s.metric = 'search_console_clicks') AS clicks, SUM(s.value) FILTER (WHERE s.metric = 'search_console_impressions') AS impressions FROM analytics_content_items i JOIN analytics_content_metric_snapshots s ON s.item_id = i.id WHERE i.source = 'search_console' AND (s.metric_date <= to_char(CURRENT_DATE, 'YYYY-MM-DD') AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '28d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '28 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND s.metric_date >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = 'custom' AND s.metric_date >= '{{timeRangeStart}}' AND s.metric_date <= '{{timeRangeEnd}}'))) GROUP BY i.url ORDER BY clicks DESC NULLS LAST, impressions DESC NULLS LAST LIMIT 200",
143
+ "config": {
144
+ "timeScope": "dashboard",
145
+ "description": "Search Console pages that aren't collected blog articles.",
146
+ "sortable": true,
147
+ "columns": [
148
+ {
149
+ "key": "page",
150
+ "label": "Page",
151
+ "format": "link",
152
+ "linkKey": "href"
153
+ },
154
+ {
155
+ "key": "href",
156
+ "hidden": true
157
+ },
158
+ {
159
+ "key": "clicks",
160
+ "label": "Search clicks",
161
+ "format": "number"
162
+ },
163
+ {
164
+ "key": "impressions",
165
+ "label": "Impressions",
166
+ "format": "number"
167
+ }
168
+ ]
169
+ }
170
+ }
171
+ ]
172
+ }
@@ -65,3 +65,5 @@ export const CONTENT_METRICS_MIGRATION_SQL = [
65
65
  )`,
66
66
  `CREATE INDEX IF NOT EXISTS analytics_content_collection_runs_step_idx ON analytics_content_collection_runs (tenant_key, step, started_at)`,
67
67
  ];
68
+
69
+ export const CONTENT_COVERAGE_CONFIG_MIGRATION_SQL = `ALTER TABLE analytics_content_collection_runs ADD COLUMN IF NOT EXISTS config_fingerprint TEXT`;
@@ -25,14 +25,19 @@ export async function collectionWindow(
25
25
  step: ContentCollectionStepId,
26
26
  now: Date,
27
27
  lookbackDays: number,
28
+ configFingerprint?: string,
28
29
  ): Promise<{ startDate: string; endDate: string }> {
29
30
  const end = new Date(now.getTime() - DAY_MS);
30
31
  const previous = await lastSucceededContentRun(collection, step);
31
32
  const earliest = end.getTime() - (BACKFILL_DAYS - 1) * DAY_MS;
32
33
  const lookback = end.getTime() - (lookbackDays - 1) * DAY_MS;
33
- const uncovered = previous?.windowEnd
34
- ? new Date(`${previous.windowEnd}T00:00:00Z`).getTime() + DAY_MS
35
- : earliest;
34
+ const sameConfig =
35
+ configFingerprint === undefined ||
36
+ previous?.configFingerprint === configFingerprint;
37
+ const uncovered =
38
+ previous?.windowEnd && sameConfig
39
+ ? new Date(`${previous.windowEnd}T00:00:00Z`).getTime() + DAY_MS
40
+ : earliest;
36
41
  const start = Math.max(earliest, Math.min(lookback, uncovered));
37
42
  return {
38
43
  startDate: utcDate(new Date(start)),
@@ -1,3 +1,6 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { normalizeSearchConsoleProperty } from "../../../../shared/search-console-property.js";
1
4
  import { querySearchAnalytics } from "../../search-console.js";
2
5
  import type { SearchConsoleSite } from "../config.js";
3
6
  import { ContentStepStop, type ContentStep } from "../step.js";
@@ -33,7 +36,7 @@ function escapeRegex(value: string): string {
33
36
 
34
37
  /** Whether a page URL belongs to a Domain or URL-prefix property. */
35
38
  export function siteCoversUrl(siteUrl: string, url: string): boolean {
36
- if (siteUrl.startsWith("sc-domain:")) {
39
+ if (siteUrl.toLowerCase().startsWith("sc-domain:")) {
37
40
  const domain = siteUrl.slice("sc-domain:".length).toLowerCase();
38
41
  if (!URL.canParse(url)) return false;
39
42
  const host = new URL(url).hostname.toLowerCase();
@@ -59,6 +62,16 @@ export const searchConsoleStep: ContentStep = {
59
62
  id: "search_console",
60
63
  async run({ collection, now, signal }) {
61
64
  const sites = collection.config.searchConsole.sites;
65
+ const configFingerprint = createHash("sha256")
66
+ .update(
67
+ JSON.stringify(
68
+ sites.map((site) => [
69
+ normalizeSearchConsoleProperty(site.siteUrl),
70
+ site.pathPrefix?.trim() ?? "",
71
+ ]),
72
+ ),
73
+ )
74
+ .digest("hex");
62
75
  if (sites.length === 0) {
63
76
  throw new ContentStepStop({
64
77
  status: "not_configured",
@@ -82,6 +95,7 @@ export const searchConsoleStep: ContentStep = {
82
95
  "search_console",
83
96
  now,
84
97
  LOOKBACK_DAYS,
98
+ configFingerprint,
85
99
  );
86
100
  const newPages = new Map<string, ContentItemInput>();
87
101
  const snapshots = new Map<string, ContentSnapshotInput>();
@@ -167,9 +181,8 @@ export const searchConsoleStep: ContentStep = {
167
181
  outcome: {
168
182
  rowCount: snapshots.size,
169
183
  windowStart: window.startDate,
170
- // Query coverage advances even when no rows exist. Data dates remain
171
- // on the snapshots, and omitted values still have no snapshot.
172
184
  windowEnd: window.endDate,
185
+ configFingerprint,
173
186
  },
174
187
  };
175
188
  },
@@ -0,0 +1,240 @@
1
+ import {
2
+ BigQueryMaximumBytesBilledError,
3
+ dryRunQuerySchema,
4
+ runQuery,
5
+ } from "../../bigquery.js";
6
+ import type { SignupsConfig } from "../config.js";
7
+ import { ContentStepStop, type ContentStep } from "../step.js";
8
+ import { listContentItems } from "../store.js";
9
+ import { BUILDER_BLOG_ORIGIN } from "./builder-blog.js";
10
+ import { BACKFILL_DAYS, DailyMetricValues, utcDate } from "./daily-metrics.js";
11
+
12
+ export const SIGNUPS_FIRST_TOUCH_METRIC = "signups_first_touch";
13
+ /** The same first-touch signups, only those from the saved `signupSource`. */
14
+ export const SOURCE_SIGNUPS_FIRST_TOUCH_METRIC = "signups_first_touch_source";
15
+
16
+ const DAY_MS = 24 * 60 * 60 * 1000;
17
+ /** Far more than any day's article and signup pairs; reaching it is a failure. */
18
+ const MAX_ROWS = 200_000;
19
+
20
+ export type SignupsQuery = {
21
+ sql: string;
22
+ startDate: string;
23
+ endDate: string;
24
+ };
25
+
26
+ type SignupsTables = {
27
+ [K in Exclude<
28
+ keyof SignupsConfig,
29
+ "enabled" | "maxBytesBilled"
30
+ >]: NonNullable<SignupsConfig[K]>;
31
+ };
32
+
33
+ const TABLE_SETTINGS = [
34
+ "signupEventsTable",
35
+ "firstPageviewsTable",
36
+ "signupSource",
37
+ "signupsSince",
38
+ ] as const;
39
+
40
+ function notSetUp(missing: readonly string[]): ContentStepStop {
41
+ return new ContentStepStop({
42
+ status: "not_configured",
43
+ errorCode: "signups_not_configured",
44
+ errorMessage: `Per-article signups need ${missing.join(", ")} set with configure-content-collection. Measure maxBytesBilled with estimate-content-signups-cost first.`,
45
+ });
46
+ }
47
+
48
+ /** The warehouse settings the join is built from; a dry run needs only these. */
49
+ export function signupsTables(config: SignupsConfig): SignupsTables {
50
+ const missing = TABLE_SETTINGS.filter((key) => config[key] === null);
51
+ if (missing.length > 0) throw notSetUp(missing);
52
+ return config as SignupsTables;
53
+ }
54
+
55
+ /**
56
+ * The approved cap. Without one the step is not set up, so a join nobody has
57
+ * priced never runs.
58
+ */
59
+ function signupsCap(config: SignupsConfig): number {
60
+ if (config.maxBytesBilled === null) throw notSetUp(["maxBytesBilled"]);
61
+ return config.maxBytesBilled;
62
+ }
63
+
64
+ /**
65
+ * Signups credited to the blog article each person first landed on, by the
66
+ * day of that first visit: DevRel Leaderboard v3's first-touch join, so a
67
+ * window's daily values add up to its total. A later signup still credits
68
+ * the visit's day, so every run recomputes the whole backfill window.
69
+ *
70
+ * Signups are read only from the window's first day (or `signupsSince`, if
71
+ * later): a signup before the first visit isn't first-touch credit, and a
72
+ * fixed lower bound would grow the scan by a day's events every run.
73
+ *
74
+ * `data_through` is the newest first pageview in the window; days after it
75
+ * aren't in the warehouse yet and get no row.
76
+ */
77
+ export function buildSignupsQuery(
78
+ config: SignupsTables,
79
+ now: Date,
80
+ ): SignupsQuery {
81
+ const end = new Date(now.getTime() - DAY_MS);
82
+ const startDate = utcDate(
83
+ new Date(end.getTime() - (BACKFILL_DAYS - 1) * DAY_MS),
84
+ );
85
+ const endDate = utcDate(end);
86
+ const signupsFrom =
87
+ config.signupsSince > startDate ? config.signupsSince : startDate;
88
+ const blog = new URL(BUILDER_BLOG_ORIGIN);
89
+ const escapeRegex = (value: string) =>
90
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ const blogPattern = `^https?://(www\\.)?${escapeRegex(blog.hostname.replace(/^www\./, ""))}${escapeRegex(blog.pathname)}`;
92
+ const sql = `WITH signups AS (
93
+ SELECT JSON_VALUE(data, '$.visitorId') AS visitor_id, JSON_VALUE(data, '$.userId') AS user_id, createdDate AS signup_at,
94
+ LOGICAL_OR(JSON_VALUE(data, '$.signupSource') = '${config.signupSource}') AS from_source
95
+ FROM \`${config.signupEventsTable}\`
96
+ WHERE createdDate >= TIMESTAMP('${signupsFrom}') AND event = 'signup'
97
+ AND COALESCE(JSON_VALUE(data, '$.visitorId'), '') != ''
98
+ GROUP BY 1, 2, 3
99
+ ), visits AS (
100
+ SELECT REGEXP_EXTRACT(url, r'/blog/([^/?#]+)') AS handle, visitor_id, created_date, DATE(created_date) AS day
101
+ FROM \`${config.firstPageviewsTable}\`
102
+ WHERE REGEXP_CONTAINS(url, r'${blogPattern}') AND is_bot = FALSE
103
+ AND created_date >= TIMESTAMP('${startDate}') AND created_date < TIMESTAMP(DATE_ADD(DATE '${endDate}', INTERVAL 1 DAY))
104
+ ), fresh AS (
105
+ SELECT FORMAT_DATE('%F', MAX(day)) AS data_through FROM visits
106
+ ), counts AS (
107
+ SELECT v.handle, FORMAT_DATE('%F', v.day) AS day, COUNT(DISTINCT s.user_id) AS signups,
108
+ COUNT(DISTINCT IF(s.from_source, s.user_id, NULL)) AS source_signups
109
+ FROM visits v JOIN signups s ON s.visitor_id = v.visitor_id AND s.signup_at >= v.created_date
110
+ WHERE v.handle IS NOT NULL
111
+ GROUP BY 1, 2
112
+ )
113
+ SELECT f.data_through, c.handle, c.day, c.signups, c.source_signups FROM fresh f LEFT JOIN counts c ON TRUE`;
114
+ return { sql, startDate, endDate };
115
+ }
116
+
117
+ function costLimited(message: string): ContentStepStop {
118
+ return new ContentStepStop({
119
+ status: "cost_limited",
120
+ errorCode: "signups_bytes_cap",
121
+ errorMessage: message,
122
+ });
123
+ }
124
+
125
+ function articleHandle(url: string | null): string | null {
126
+ if (!url) return null;
127
+ const match = /\/blog\/([^/?#]+)/.exec(url);
128
+ return match ? normalizeHandle(match[1]!) : null;
129
+ }
130
+
131
+ function normalizeHandle(handle: string): string | null {
132
+ try {
133
+ return decodeURIComponent(handle).toLowerCase();
134
+ // coercion-ok: a malformed escape gets the typed null handle, which matches no row
135
+ } catch {
136
+ return null;
137
+ }
138
+ }
139
+
140
+ /** Daily first-touch signups per Builder blog article, behind a bytes cap. */
141
+ export const signupsStep: ContentStep = {
142
+ id: "signups",
143
+ async run({ collection, now, signal }) {
144
+ const tables = signupsTables(collection.config.signups);
145
+ const cap = signupsCap(collection.config.signups);
146
+ const articles = await listContentItems(collection, "builder_blog");
147
+ if (articles.length === 0) {
148
+ throw new ContentStepStop({
149
+ status: "not_configured",
150
+ errorCode: "no_content_items",
151
+ errorMessage:
152
+ "No blog articles are collected yet, so there are no articles to credit signups to.",
153
+ });
154
+ }
155
+
156
+ const query = buildSignupsQuery(tables, now);
157
+ const estimate = await dryRunQuerySchema(query.sql, {
158
+ ...(signal ? { signal } : {}),
159
+ });
160
+ if (estimate.error) {
161
+ throw new Error(`The signups dry run failed: ${estimate.error}`);
162
+ }
163
+ if (estimate.totalBytesProcessed === undefined) {
164
+ throw new Error(
165
+ "The signups dry run returned no byte estimate, so the cap can't be checked.",
166
+ );
167
+ }
168
+ if (estimate.totalBytesProcessed > cap) {
169
+ throw costLimited(
170
+ `The signups join would scan ${estimate.totalBytesProcessed} bytes, over the ${cap}-byte cap. Nothing ran.`,
171
+ );
172
+ }
173
+
174
+ let result: Awaited<ReturnType<typeof runQuery>>;
175
+ try {
176
+ result = await runQuery(query.sql, {
177
+ maxBytesBilled: cap,
178
+ forceRefresh: true,
179
+ ...(signal ? { signal } : {}),
180
+ });
181
+ } catch (error) {
182
+ if (error instanceof BigQueryMaximumBytesBilledError) {
183
+ throw costLimited(
184
+ `BigQuery stopped the signups join at the ${cap}-byte cap.`,
185
+ );
186
+ }
187
+ throw error;
188
+ }
189
+ if (result.truncated || result.rows.length >= MAX_ROWS) {
190
+ throw new Error(
191
+ `The signups join returned ${result.rows.length} rows, more than this step reads; nothing was written.`,
192
+ );
193
+ }
194
+
195
+ const byHandle = new Map(
196
+ articles.flatMap((article) => {
197
+ const handle = articleHandle(article.url);
198
+ return handle ? [[handle, article] as const] : [];
199
+ }),
200
+ );
201
+ const values = new DailyMetricValues();
202
+ let dataThrough: string | null = null;
203
+ for (const row of result.rows) {
204
+ if (typeof row.data_through === "string") dataThrough = row.data_through;
205
+ if (typeof row.handle !== "string" || typeof row.day !== "string") {
206
+ continue;
207
+ }
208
+ const handle = normalizeHandle(row.handle);
209
+ const article = handle ? byHandle.get(handle) : undefined;
210
+ if (!article) continue;
211
+ values.add(
212
+ article,
213
+ row.day,
214
+ SIGNUPS_FIRST_TOUCH_METRIC,
215
+ Number(row.signups),
216
+ );
217
+ values.add(
218
+ article,
219
+ row.day,
220
+ SOURCE_SIGNUPS_FIRST_TOUCH_METRIC,
221
+ Number(row.source_signups),
222
+ );
223
+ }
224
+
225
+ const snapshots = values.snapshots({
226
+ items: articles,
227
+ metrics: [SIGNUPS_FIRST_TOUCH_METRIC, SOURCE_SIGNUPS_FIRST_TOUCH_METRIC],
228
+ startDate: query.startDate,
229
+ lastReturnedDate: dataThrough,
230
+ });
231
+ return {
232
+ snapshots,
233
+ outcome: {
234
+ rowCount: snapshots.length,
235
+ windowStart: query.startDate,
236
+ windowEnd: dataThrough,
237
+ },
238
+ };
239
+ },
240
+ };
@@ -60,6 +60,7 @@ export type ContentRunOutcome = {
60
60
  rowCount: number;
61
61
  windowStart?: string | null;
62
62
  windowEnd?: string | null;
63
+ configFingerprint?: string | null;
63
64
  bytesProcessed?: number | null;
64
65
  bytesCap?: number | null;
65
66
  costUsd?: number | null;
@@ -482,6 +483,9 @@ export async function completeContentRun(
482
483
  runId: sql`excluded.run_id`,
483
484
  collectedAt: sql`excluded.collected_at`,
484
485
  },
486
+ // Steps re-send whole windows (signups recomputes 90 days daily),
487
+ // so an unchanged value writes nothing and keeps the run that set it.
488
+ setWhere: sql`${schema.analyticsContentMetricSnapshots.value} IS DISTINCT FROM excluded.value OR ${schema.analyticsContentMetricSnapshots.source} IS DISTINCT FROM excluded.source`,
485
489
  });
486
490
  }
487
491
 
@@ -493,6 +497,7 @@ export async function completeContentRun(
493
497
  rowCount: outcome.rowCount,
494
498
  windowStart: outcome.windowStart ?? null,
495
499
  windowEnd: outcome.windowEnd ?? null,
500
+ configFingerprint: outcome.configFingerprint ?? null,
496
501
  bytesProcessed: outcome.bytesProcessed ?? null,
497
502
  bytesCap: outcome.bytesCap ?? null,
498
503
  costUsd: outcome.costUsd ?? null,
@@ -969,6 +969,27 @@ export const dashboardCatalogEntries: DashboardCatalogEntry[] = [
969
969
  visibleInCatalog: false,
970
970
  buildConfig: () => seedConfig("skills-cli-funnel"),
971
971
  },
972
+ {
973
+ id: "content-view",
974
+ name: "Content",
975
+ description:
976
+ "Blog articles with authors, daily GA4 pageviews, and Search Console clicks and impressions from the daily content collection, with each source's last run.",
977
+ category: "Acquisition",
978
+ defaultDashboardId: "content-view",
979
+ dataSources: ["first-party"],
980
+ tags: [
981
+ "content",
982
+ "blog",
983
+ "articles",
984
+ "authors",
985
+ "search console",
986
+ "pageviews",
987
+ "seo",
988
+ ],
989
+ panelCount: 4,
990
+ version: CATALOG_VERSION,
991
+ buildConfig: () => seedConfig("content-view"),
992
+ },
972
993
  {
973
994
  id: "google-analytics-web",
974
995
  name: "Google Analytics Website",