@agent-native/core 0.73.0 → 0.75.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 (58) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +67 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/durable-background.ts +51 -5
  5. package/corpus/core/src/agent/production-agent.ts +92 -12
  6. package/corpus/core/src/client/ConnectBuilderCard.tsx +7 -7
  7. package/corpus/core/src/client/NewWorkspaceAppFlow.tsx +1 -1
  8. package/corpus/core/src/client/blocks/library/AnnotatedCodeBlock.tsx +33 -18
  9. package/corpus/core/src/client/blocks/library/annotation-rail.tsx +3 -4
  10. package/corpus/core/src/client/components/CodeRequiredDialog.tsx +5 -8
  11. package/corpus/core/src/deploy/build.ts +21 -9
  12. package/corpus/core/src/deploy/workspace-deploy.ts +12 -6
  13. package/corpus/core/src/server/agent-chat-plugin.ts +7 -7
  14. package/corpus/core/src/server/self-dispatch.ts +8 -1
  15. package/corpus/templates/analytics/actions/compose-dashboard.ts +34 -0
  16. package/corpus/templates/analytics/actions/install-dashboard-template.ts +51 -3
  17. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +31 -8
  18. package/corpus/templates/analytics/changelog/2026-06-24-dashboard-filters-now-apply-consistently-to-first-party-traf.md +6 -0
  19. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +97 -28
  20. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +57 -10
  21. package/corpus/templates/clips/app/components/library/library-layout.tsx +7 -0
  22. package/corpus/templates/clips/app/routes/_app.settings._index.tsx +786 -323
  23. package/corpus/templates/clips/changelog/2026-06-24-settings-now-puts-what-s-new-in-a-compact-sidebar-prioritize.md +6 -0
  24. package/corpus/templates/plan/changelog/2026-06-24-annotated-code-callouts-now-use-numbered-gutter-markers-that.md +6 -0
  25. package/dist/agent/durable-background.d.ts +21 -0
  26. package/dist/agent/durable-background.d.ts.map +1 -1
  27. package/dist/agent/durable-background.js +48 -5
  28. package/dist/agent/durable-background.js.map +1 -1
  29. package/dist/agent/production-agent.d.ts.map +1 -1
  30. package/dist/agent/production-agent.js +88 -13
  31. package/dist/agent/production-agent.js.map +1 -1
  32. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  33. package/dist/client/ConnectBuilderCard.js +6 -6
  34. package/dist/client/ConnectBuilderCard.js.map +1 -1
  35. package/dist/client/NewWorkspaceAppFlow.js +1 -1
  36. package/dist/client/NewWorkspaceAppFlow.js.map +1 -1
  37. package/dist/client/blocks/library/AnnotatedCodeBlock.d.ts.map +1 -1
  38. package/dist/client/blocks/library/AnnotatedCodeBlock.js +14 -10
  39. package/dist/client/blocks/library/AnnotatedCodeBlock.js.map +1 -1
  40. package/dist/client/blocks/library/annotation-rail.d.ts +3 -4
  41. package/dist/client/blocks/library/annotation-rail.d.ts.map +1 -1
  42. package/dist/client/blocks/library/annotation-rail.js +3 -4
  43. package/dist/client/blocks/library/annotation-rail.js.map +1 -1
  44. package/dist/client/components/CodeRequiredDialog.d.ts.map +1 -1
  45. package/dist/client/components/CodeRequiredDialog.js +2 -1
  46. package/dist/client/components/CodeRequiredDialog.js.map +1 -1
  47. package/dist/deploy/build.d.ts +13 -2
  48. package/dist/deploy/build.d.ts.map +1 -1
  49. package/dist/deploy/build.js +21 -9
  50. package/dist/deploy/build.js.map +1 -1
  51. package/dist/deploy/workspace-deploy.js +12 -6
  52. package/dist/deploy/workspace-deploy.js.map +1 -1
  53. package/dist/server/agent-chat-plugin.js +7 -7
  54. package/dist/server/agent-chat-plugin.js.map +1 -1
  55. package/dist/server/self-dispatch.d.ts.map +1 -1
  56. package/dist/server/self-dispatch.js +5 -1
  57. package/dist/server/self-dispatch.js.map +1 -1
  58. package/package.json +1 -1
@@ -2039,7 +2039,7 @@ function createBuilderBrowserTool(deps: {
2039
2039
  const entries: Record<string, ActionEntry> = {
2040
2040
  "connect-builder": {
2041
2041
  tool: {
2042
- description: `Render a Builder.io card inline in the chat. Call this as the first step (no code exploration or planning needed) when the user asks to modify the APP'S OWN SOURCE CODE: add a feature, change the UI chrome, edit a React component, add a route, add an integration, fix a bug in the app itself, or anything else that requires source-file edits while in hosted/production mode. ${extensionRequestGuidance}Do NOT call this for content the app is meant to produce — creating a video, generating a design, drafting an email, building a slide deck, making a dashboard, etc. — those run through the app's own domain actions, not Builder. Do NOT mention 'click Send to Builder' in your response unless this card is already in the conversation. If Builder is connected and Builder Cloud Agents are available, the card shows a 'Send to Builder' button that hands the work off to Builder's cloud agent and returns a branch URL. If \`builderEnabled\` is false, the card shows a waitlist/local-dev fallback instead; never tell the user to enable Builder Cloud Agents in Builder org settings or beta settings, and do not claim the Builder card has everything, is pre-loaded for handoff, or can run the cloud agent. When you call this for a code-change request, pass the user's request verbatim as the \`prompt\` arg so the card can forward it to Builder unchanged when cloud agents are available.`,
2042
+ description: `Render a Builder.io card inline in the chat. Call this as the first step (no code exploration or planning needed) when the user asks to modify the APP'S OWN SOURCE CODE: add a feature, change the UI chrome, edit a React component, add a route, add an integration, fix a bug in the app itself, or anything else that requires source-file edits while in hosted/production mode. ${extensionRequestGuidance}Do NOT call this for content the app is meant to produce — creating a video, generating a design, drafting an email, building a slide deck, making a dashboard, etc. — those run through the app's own domain actions, not Builder. Do NOT mention 'click Send to Builder' in your response unless this card is already in the conversation. The tool result includes \`builderEnabled\`; treat \`true\` as "Builder Cloud Agents can take the code-change handoff" and \`false\` as "this still needs a code change, but no Builder Cloud Agent can run here." If Builder is connected and Builder Cloud Agents are available, the card shows a 'Send to Builder' button that hands the work off to Builder's cloud agent and returns a branch URL. If \`builderEnabled\` is false, the card still renders but shows the code-change fallback: "This requires a code change. Edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way you like." Never tell the user to enable Builder Cloud Agents in Builder org settings or beta settings, and do not claim the Builder card has everything, is pre-loaded for handoff, or can run the cloud agent when \`builderEnabled\` is false. When you call this for a code-change request, pass the user's request verbatim as the \`prompt\` arg so the card can forward it to Builder unchanged when cloud agents are available.`,
2043
2043
  parameters: {
2044
2044
  type: "object",
2045
2045
  properties: {
@@ -2920,7 +2920,7 @@ Do NOT add this offer for one-shot work: lookups (find Alice, what's the schema,
2920
2920
 
2921
2921
  builder: `### Connecting Builder.io
2922
2922
 
2923
- When the user asks to connect Builder.io or you hit a "Builder not configured" error, call the \`connect-builder\` tool. It renders a one-click Connect card inline — do NOT write out multi-step setup instructions yourself. If Builder Cloud Agents are not available for this workspace, never send the user to Builder org settings or beta settings; use the card's waitlist/local-dev fallback.`,
2923
+ When the user asks to connect Builder.io or you hit a "Builder not configured" error, call the \`connect-builder\` tool. It renders a Connect/code-change card inline — do NOT write out multi-step setup instructions yourself. Inspect the returned \`builderEnabled\` flag: \`true\` means Builder Cloud Agents can take the code-change handoff, while \`false\` means this requires a code change and the user should edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way they like. If Builder Cloud Agents are not available for this workspace, never send the user to Builder org settings or beta settings.`,
2924
2924
 
2925
2925
  browser: `### Browser Automation
2926
2926
 
@@ -3108,14 +3108,14 @@ ${extensionConnectBuilderGuard}
3108
3108
  In Act mode, when the user asks you to change the UI, modify code, add a feature, fix a bug in the app itself, change styles, add a hook, create a component, add a route, add an integration, or anything else that requires editing source files — you MUST take exactly these steps, in order:
3109
3109
 
3110
3110
  1. Briefly acknowledge the user's specific request in their own terms — one short clause naming what they asked for (e.g. "Got it — wider subject lines in the email list."). Do NOT restate the request verbatim, do NOT add a generic preamble, and do NOT promise outcomes. Skip this step entirely if the user already knows you're handing off (e.g. they said "send this to Builder").
3111
- 2. Call the \`connect-builder\` tool, passing the user's full request verbatim as the \`prompt\` argument. This renders an inline card. If Builder is connected and \`builderEnabled\` is true, the card hands the prompt off to Builder's cloud agent on one click and returns a branch URL. If Builder is not connected, it shows the Connect Builder flow. If \`builderEnabled\` is false, it shows a waitlist/local-dev fallback instead of a cloud handoff.
3112
- 3. After the card renders, inspect the tool result and write one sentence that frames the next click around what the user just asked — not as a Builder pitch. Examples: "Click Send to Builder and it'll wire that up for you." / "Connect Builder once and it'll handle this and future changes." / "Builder Cloud Agents aren't available here yet join the waitlist in the card, or use the Agent Native Desktop app for local code changes: https://www.agent-native.com/download." Do NOT say the card is pre-loaded, has everything, or lead with "Builder Cloud Agents are…" as if it were the headline.
3111
+ 2. Call the \`connect-builder\` tool, passing the user's full request verbatim as the \`prompt\` argument. This renders an inline card. The returned \`builderEnabled\` flag is authoritative: \`true\` means Builder Cloud Agents can take the code-change handoff; \`false\` means this still requires a code change, but no Builder Cloud Agent can run from this workspace. If Builder is connected and \`builderEnabled\` is true, the card hands the prompt off to Builder's cloud agent on one click and returns a branch URL. If Builder is not connected and \`builderEnabled\` is true, it shows the Connect Builder flow. If \`builderEnabled\` is false, it still shows the card, but as a code-change fallback instead of a runnable cloud handoff.
3112
+ 3. After the card renders, inspect the tool result and write one sentence that frames the next click around what the user just asked — not as a Builder pitch. If \`builderEnabled\` is true and the card can send, say something like "Click Send to Builder and it'll wire that up for you." If the user needs to connect first, say something like "Connect Builder once and it'll handle this and future changes." If \`builderEnabled\` is false, say: "This requires a code change. Edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way you like." Do NOT say "click Send to Builder", mention a branch URL, say the card is pre-loaded, or imply a Builder Cloud Agent can run when \`builderEnabled\` is false.
3113
3113
 
3114
- When \`builderEnabled\` is false, Builder Cloud Agents are not self-serve from Builder org settings. Do NOT tell the user to go to Builder org settings, beta features, "Cloud Agents", or "AI Agents" to enable them. The only allowed guidance is the card's waitlist/local-dev fallback: join the waitlist when the card offers it, use Agent Native Desktop, or edit from a local clone.
3114
+ When \`builderEnabled\` is false, Builder Cloud Agents are not self-serve from Builder org settings. Do NOT tell the user to go to Builder org settings, beta features, "Cloud Agents", or "AI Agents" to enable them. The only allowed guidance is the card's code-change fallback: edit locally, use Agent Native Desktop, or use Builder.io to edit this code in the cloud and continue customizing the app any way you like.
3115
3115
 
3116
3116
  **Act-mode handoff — do exactly this:** The moment a code change is requested, call \`connect-builder\` with the user's verbatim request, then write one sentence framing the next click. That is the whole job: Builder plans and edits in its own sandbox, so the planning, file-mapping, and code all happen there, not here. Keep your turn to the call plus that one sentence — skip code exploration, implementation plans, \`resources\` writes of specs, and sub-agents (they have no code-editing tools either). You don't need filesystem access to recommend Builder, so don't reach for it or list tools you lack.
3117
3117
 
3118
- Builder Cloud Agents are the recommended path when available because they are one-click and run in the cloud. Local dev and the desktop app are the fallback when cloud agent access is waitlisted or unavailable.
3118
+ Builder Cloud Agents are the recommended path when \`builderEnabled\` is true because they are one-click and run in the cloud. Local dev, Agent Native Desktop, and Builder.io cloud editing are the fallback when \`builderEnabled\` is false.
3119
3119
  ${FRAMEWORK_CORE}`;
3120
3120
 
3121
3121
  const DEV_FRAMEWORK_PROMPT = `## Agent-Native Framework — Development Mode
@@ -3157,7 +3157,7 @@ ${extensionInstructionsCompact}
3157
3157
 
3158
3158
  ### Code Changes — Call \`connect-builder\`
3159
3159
 
3160
- In Act mode, when a request genuinely needs a source-code edit (per the rules above), do this in order: (1) briefly acknowledge the user's specific ask in their own terms — one short clause, no preamble — then (2) call \`connect-builder\` with the user's request as the \`prompt\`, then (3) write one sentence framing the next click around what they asked for, not a Builder pitch. Do NOT write code, list files, make plans, or spawn sub-agents. Mention \`npx agent-native create <app-name>\`, Agent Native Desktop, or the waitlist card as the fallback when Builder Cloud Agents aren't available. Never tell users to enable Builder Cloud Agents from Builder org settings, beta features, "Cloud Agents", or "AI Agents"; they are not self-serve there.
3160
+ In Act mode, when a request genuinely needs a source-code edit (per the rules above), do this in order: (1) briefly acknowledge the user's specific ask in their own terms — one short clause, no preamble — then (2) call \`connect-builder\` with the user's request as the \`prompt\`, then (3) inspect \`builderEnabled\` in the result and write one sentence framing the next click around what they asked for, not a Builder pitch. When \`builderEnabled\` is true, Builder Cloud Agents can take the handoff; when \`builderEnabled\` is false, say this requires a code change and they can edit locally or use Builder.io to edit this code in the cloud and continue customizing the app any way they like. Do NOT write code, list files, make plans, or spawn sub-agents. Mention \`npx agent-native create <app-name>\`, Agent Native Desktop, or the code-change fallback when Builder Cloud Agents aren't available. Never tell users to enable Builder Cloud Agents from Builder org settings, beta features, "Cloud Agents", or "AI Agents"; they are not self-serve there.
3161
3161
  ${FRAMEWORK_CORE_COMPACT}`;
3162
3162
 
3163
3163
  const DEV_FRAMEWORK_PROMPT_COMPACT = `## Agent-Native Framework — Development Mode
@@ -150,7 +150,14 @@ export async function fireInternalDispatch(
150
150
  }
151
151
  });
152
152
  dispatchPromise.catch((err) => {
153
- console.error(`[self-dispatch] dispatch to ${options.path} failed:`, err);
153
+ // Include the resolved base URL: a self-dispatch failure is almost always
154
+ // about *which* host we POST to (custom domain behind an edge/auth wall vs
155
+ // the deploy URL), and that is invisible from the error alone. Keeps prod
156
+ // logs diagnostic without changing the URL resolution order.
157
+ console.error(
158
+ `[self-dispatch] dispatch to ${options.path} (base ${baseUrl}) failed:`,
159
+ err,
160
+ );
154
161
  });
155
162
 
156
163
  const settleMs = options.settleMs ?? DEFAULT_DISPATCH_SETTLE_MS;
@@ -7,10 +7,12 @@ import {
7
7
  import { z } from "zod";
8
8
  import { getDashboard, upsertDashboard } from "../server/lib/dashboards-store";
9
9
  import {
10
+ buildFirstPartyDashboardFilters,
10
11
  buildPanel,
11
12
  listMetricKeys,
12
13
  type ComposedPanel,
13
14
  type MetricWindow,
15
+ usesFirstPartyDashboardFilters,
14
16
  } from "../server/lib/first-party-metric-catalog";
15
17
  import { validateFirstPartyAnalyticsSql } from "../server/lib/first-party-analytics.js";
16
18
  import {
@@ -97,6 +99,32 @@ const metricSchema = z.union([
97
99
 
98
100
  const METRIC_KEYS = listMetricKeys();
99
101
 
102
+ function filterId(filter: unknown): string | null {
103
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) {
104
+ return null;
105
+ }
106
+ const id = (filter as { id?: unknown }).id;
107
+ return typeof id === "string" && id.trim() ? id : null;
108
+ }
109
+
110
+ function withFirstPartyDashboardFilters(
111
+ config: Record<string, unknown>,
112
+ ): Record<string, unknown> {
113
+ const filters = Array.isArray(config.filters) ? [...config.filters] : [];
114
+ const existingIds = new Set(
115
+ filters
116
+ .map((filter) => filterId(filter))
117
+ .filter((id): id is string => id !== null),
118
+ );
119
+ for (const filter of buildFirstPartyDashboardFilters()) {
120
+ if (!existingIds.has(filter.id)) {
121
+ filters.push(filter);
122
+ existingIds.add(filter.id);
123
+ }
124
+ }
125
+ return { ...config, filters };
126
+ }
127
+
100
128
  export default defineAction({
101
129
  description:
102
130
  "Build a large first-party analytics dashboard in ONE fast call: name the metrics you want and the SERVER generates the validated SQL + chart config for every panel. " +
@@ -249,6 +277,12 @@ export default defineAction({
249
277
  };
250
278
  }
251
279
 
280
+ if (
281
+ composedPanels.some((panel) => usesFirstPartyDashboardFilters(panel.sql))
282
+ ) {
283
+ finalConfig = withFirstPartyDashboardFilters(finalConfig);
284
+ }
285
+
252
286
  const panelCount = Array.isArray(finalConfig.panels)
253
287
  ? (finalConfig.panels as unknown[]).length
254
288
  : 0;
@@ -41,6 +41,49 @@ function uniqueConstraintMessage(err: unknown): boolean {
41
41
  return /unique|constraint|primary key/i.test(message);
42
42
  }
43
43
 
44
+ function filterId(filter: unknown): string | null {
45
+ if (!filter || typeof filter !== "object" || Array.isArray(filter)) {
46
+ return null;
47
+ }
48
+ const id = (filter as { id?: unknown }).id;
49
+ return typeof id === "string" && id.trim() ? id : null;
50
+ }
51
+
52
+ function mergeMissingFilters(
53
+ targetConfig: Record<string, unknown>,
54
+ seedConfig: Record<string, unknown>,
55
+ ): { config: Record<string, unknown>; addedFilterIds: string[] } {
56
+ const seedFilters = Array.isArray(seedConfig.filters)
57
+ ? (seedConfig.filters as unknown[])
58
+ : [];
59
+ if (seedFilters.length === 0) {
60
+ return { config: targetConfig, addedFilterIds: [] };
61
+ }
62
+
63
+ const targetFilters = Array.isArray(targetConfig.filters)
64
+ ? [...targetConfig.filters]
65
+ : [];
66
+ const existingIds = new Set(
67
+ targetFilters
68
+ .map((filter) => filterId(filter))
69
+ .filter((id): id is string => id !== null),
70
+ );
71
+ const addedFilterIds: string[] = [];
72
+ for (const filter of seedFilters) {
73
+ const id = filterId(filter);
74
+ if (id && existingIds.has(id)) continue;
75
+ targetFilters.push(filter);
76
+ if (id) {
77
+ existingIds.add(id);
78
+ addedFilterIds.push(id);
79
+ }
80
+ }
81
+
82
+ return addedFilterIds.length > 0
83
+ ? { config: { ...targetConfig, filters: targetFilters }, addedFilterIds }
84
+ : { config: targetConfig, addedFilterIds };
85
+ }
86
+
44
87
  export default defineAction({
45
88
  description:
46
89
  "Install a dashboard template from the Analytics catalog into the user's SQL-backed dashboards. Use list-dashboard-templates first when choosing a template. " +
@@ -125,7 +168,10 @@ export default defineAction({
125
168
  .filter((id): id is string => !!id),
126
169
  );
127
170
 
128
- const seedConfig = cloneDashboardConfig(entry);
171
+ const seedConfig = cloneDashboardConfig(entry) as unknown as Record<
172
+ string,
173
+ unknown
174
+ >;
129
175
  const seedPanels = Array.isArray(seedConfig.panels)
130
176
  ? (seedConfig.panels as unknown as Array<Record<string, unknown>>)
131
177
  : [];
@@ -146,13 +192,15 @@ export default defineAction({
146
192
  }
147
193
  }
148
194
 
149
- const mergedConfig: Record<string, unknown> = {
195
+ let mergedConfig: Record<string, unknown> = {
150
196
  ...targetConfig,
151
197
  panels: [...existingPanels, ...appended],
152
198
  };
199
+ const filterMerge = mergeMissingFilters(mergedConfig, seedConfig);
200
+ mergedConfig = filterMerge.config;
153
201
  const panelCount = (mergedConfig.panels as unknown[]).length;
154
202
 
155
- if (appended.length > 0) {
203
+ if (appended.length > 0 || filterMerge.addedFilterIds.length > 0) {
156
204
  const saved = await upsertDashboard(
157
205
  targetId,
158
206
  target.kind,
@@ -812,6 +812,24 @@ function formatCell(value: unknown, format: ColumnFormat | undefined): string {
812
812
  return String(value);
813
813
  }
814
814
 
815
+ export function safeDashboardLinkHref(value: unknown): string | null {
816
+ if (typeof value !== "string") return null;
817
+ const href = value.trim();
818
+ if (!href) return null;
819
+ if (href.startsWith("//")) return null;
820
+
821
+ try {
822
+ const parsed = href.startsWith("/")
823
+ ? new URL(href, "https://agent-native.local")
824
+ : new URL(href);
825
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
826
+ ? href
827
+ : null;
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+
815
833
  function renderDeltaCell(value: unknown): ReactNode {
816
834
  if (value == null || typeof value !== "number" || Number.isNaN(value)) {
817
835
  return <span className="text-muted-foreground">-</span>;
@@ -983,19 +1001,24 @@ function TableRenderer({
983
1001
  const href = col.linkKey
984
1002
  ? String(row[col.linkKey] ?? "")
985
1003
  : String(raw ?? "");
1004
+ const safeHref = safeDashboardLinkHref(href);
986
1005
  return (
987
1006
  <td
988
1007
  key={col.key}
989
1008
  className="py-1.5 px-2 whitespace-nowrap"
990
1009
  >
991
- <a
992
- href={href}
993
- target="_blank"
994
- rel="noopener noreferrer"
995
- className="text-primary hover:underline"
996
- >
997
- {formatted}
998
- </a>
1010
+ {safeHref ? (
1011
+ <a
1012
+ href={safeHref}
1013
+ target="_blank"
1014
+ rel="noopener noreferrer"
1015
+ className="text-primary hover:underline"
1016
+ >
1017
+ {formatted}
1018
+ </a>
1019
+ ) : (
1020
+ formatted
1021
+ )}
999
1022
  </td>
1000
1023
  );
1001
1024
  }
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: fixed
3
+ date: 2026-06-24
4
+ ---
5
+
6
+ Dashboard filters now apply consistently to first-party traffic panels and unsafe table links render as plain text.
@@ -8,12 +8,30 @@
8
8
  "label": "Time range",
9
9
  "default": "90d",
10
10
  "options": [
11
- { "value": "7d", "label": "Last 7 days" },
12
- { "value": "30d", "label": "Last 30 days" },
13
- { "value": "90d", "label": "Last 90 days" },
14
- { "value": "180d", "label": "Last 180 days" },
15
- { "value": "365d", "label": "Last 365 days" },
16
- { "value": "all", "label": "All time" }
11
+ {
12
+ "value": "7d",
13
+ "label": "Last 7 days"
14
+ },
15
+ {
16
+ "value": "30d",
17
+ "label": "Last 30 days"
18
+ },
19
+ {
20
+ "value": "90d",
21
+ "label": "Last 90 days"
22
+ },
23
+ {
24
+ "value": "180d",
25
+ "label": "Last 180 days"
26
+ },
27
+ {
28
+ "value": "365d",
29
+ "label": "Last 365 days"
30
+ },
31
+ {
32
+ "value": "all",
33
+ "label": "All time"
34
+ }
17
35
  ]
18
36
  },
19
37
  {
@@ -22,9 +40,18 @@
22
40
  "label": "Email filter",
23
41
  "default": "all",
24
42
  "options": [
25
- { "value": "all", "label": "All users" },
26
- { "value": "exclude_builder", "label": "Exclude @builder.io" },
27
- { "value": "only_builder", "label": "Only @builder.io" }
43
+ {
44
+ "value": "all",
45
+ "label": "All users"
46
+ },
47
+ {
48
+ "value": "exclude_builder",
49
+ "label": "Exclude @builder.io"
50
+ },
51
+ {
52
+ "value": "only_builder",
53
+ "label": "Only @builder.io"
54
+ }
28
55
  ]
29
56
  }
30
57
  ],
@@ -83,7 +110,7 @@
83
110
  "chartType": "metric",
84
111
  "source": "first-party",
85
112
  "width": 1,
86
- "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template'",
113
+ "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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'))",
87
114
  "config": {
88
115
  "yKey": "count",
89
116
  "yFormatter": "number",
@@ -96,7 +123,7 @@
96
123
  "chartType": "metric",
97
124
  "source": "first-party",
98
125
  "width": 1,
99
- "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo'",
126
+ "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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'))",
100
127
  "config": {
101
128
  "yKey": "count",
102
129
  "yFormatter": "number",
@@ -109,7 +136,7 @@
109
136
  "chartType": "metric",
110
137
  "source": "first-party",
111
138
  "width": 1,
112
- "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command'",
139
+ "sql": "SELECT COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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'))",
113
140
  "config": {
114
141
  "yKey": "count",
115
142
  "yFormatter": "number",
@@ -122,7 +149,7 @@
122
149
  "chartType": "area",
123
150
  "source": "first-party",
124
151
  "width": 2,
125
- "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
152
+ "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click template' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
126
153
  "config": {
127
154
  "xKey": "date",
128
155
  "yKey": "count",
@@ -141,7 +168,7 @@
141
168
  "chartType": "area",
142
169
  "source": "first-party",
143
170
  "width": 2,
144
- "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
171
+ "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'click try demo' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
145
172
  "config": {
146
173
  "xKey": "date",
147
174
  "yKey": "count",
@@ -160,7 +187,7 @@
160
187
  "chartType": "area",
161
188
  "source": "first-party",
162
189
  "width": 2,
163
- "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
190
+ "sql": "SELECT substr(timestamp, 1, 10) AS date, COALESCE(NULLIF(template, ''), 'unknown') AS template, COUNT(*) AS count FROM analytics_events WHERE event_name = 'copy cli command' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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')) GROUP BY substr(timestamp, 1, 10), COALESCE(NULLIF(template, ''), 'unknown') ORDER BY date, template",
164
191
  "config": {
165
192
  "xKey": "date",
166
193
  "yKey": "count",
@@ -240,17 +267,37 @@
240
267
  "chartType": "table",
241
268
  "source": "first-party",
242
269
  "width": 2,
243
- "sql": "WITH pageviews AS (SELECT COALESCE(CASE WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') END, NULLIF(url, ''), NULLIF(path, ''), 'unknown') AS url, CASE WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') WHEN NULLIF(url, '') LIKE 'http%' THEN url ELSE COALESCE(NULLIF(url, ''), NULLIF(path, ''), '') END AS href, COUNT(*) AS views, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users, MAX(substr(timestamp, 1, 10)) AS last_seen FROM analytics_events WHERE event_name = 'pageview' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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')) GROUP BY 1, 2) SELECT url, views, users, last_seen, href FROM pageviews ORDER BY views DESC LIMIT 25",
270
+ "sql": "WITH pageviews AS (SELECT COALESCE(CASE WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') END, NULLIF(url, ''), NULLIF(path, ''), 'unknown') AS url, CASE WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') WHEN lower(COALESCE(NULLIF(url, ''), '')) LIKE 'http://%' OR lower(COALESCE(NULLIF(url, ''), '')) LIKE 'https://%' THEN url WHEN NULLIF(path, '') IS NOT NULL AND substr(path, 1, 1) = '/' AND substr(path, 1, 2) != '//' THEN path ELSE '' END AS href, COUNT(*) AS views, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users, MAX(substr(timestamp, 1, 10)) AS last_seen FROM analytics_events WHERE event_name = 'pageview' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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')) GROUP BY 1, 2) SELECT url, views, users, last_seen, href FROM pageviews ORDER BY views DESC LIMIT 25",
244
271
  "config": {
245
272
  "description": "Most-viewed URLs in the selected time range. Links open in a new tab.",
246
273
  "sortable": true,
247
274
  "limit": 25,
248
275
  "columns": [
249
- { "key": "url", "label": "URL", "format": "link", "linkKey": "href" },
250
- { "key": "views", "label": "Views", "format": "number" },
251
- { "key": "users", "label": "Users", "format": "number" },
252
- { "key": "last_seen", "label": "Last seen", "format": "date" },
253
- { "key": "href", "hidden": true }
276
+ {
277
+ "key": "url",
278
+ "label": "URL",
279
+ "format": "link",
280
+ "linkKey": "href"
281
+ },
282
+ {
283
+ "key": "views",
284
+ "label": "Views",
285
+ "format": "number"
286
+ },
287
+ {
288
+ "key": "users",
289
+ "label": "Users",
290
+ "format": "number"
291
+ },
292
+ {
293
+ "key": "last_seen",
294
+ "label": "Last seen",
295
+ "format": "date"
296
+ },
297
+ {
298
+ "key": "href",
299
+ "hidden": true
300
+ }
254
301
  ]
255
302
  }
256
303
  },
@@ -260,7 +307,7 @@
260
307
  "chartType": "table",
261
308
  "source": "first-party",
262
309
  "width": 2,
263
- "sql": "WITH clip_events AS (SELECT COALESCE(NULLIF(properties::jsonb ->> 'recording_id', ''), NULLIF(path, ''), NULLIF(url, ''), 'unknown') AS clip_key, CASE WHEN NULLIF(url, '') LIKE 'http%' THEN url WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') WHEN NULLIF(path, '') LIKE '/%' THEN 'https://clips.agent-native.com' || path ELSE COALESCE(NULLIF(url, ''), '') END AS href, user_id, anonymous_id, timestamp FROM analytics_events WHERE event_name = 'share_view' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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 COALESCE(NULLIF(properties::jsonb ->> 'surface', ''), 'clip') = 'clip'), clip_views AS (SELECT clip_key, COALESCE(MAX(NULLIF(href, '')), clip_key) AS href, COUNT(*) AS views, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users, MAX(substr(timestamp, 1, 10)) AS last_seen FROM clip_events GROUP BY clip_key) SELECT CASE WHEN href LIKE 'http%' THEN href ELSE clip_key END AS clip, views, users, last_seen, href FROM clip_views ORDER BY views DESC LIMIT 25",
310
+ "sql": "WITH clip_events AS (SELECT COALESCE(NULLIF(properties::jsonb ->> 'recording_id', ''), NULLIF(path, ''), NULLIF(url, ''), 'unknown') AS clip_key, CASE WHEN lower(COALESCE(NULLIF(url, ''), '')) LIKE 'http://%' OR lower(COALESCE(NULLIF(url, ''), '')) LIKE 'https://%' THEN url WHEN NULLIF(hostname, '') IS NOT NULL THEN 'https://' || hostname || COALESCE(NULLIF(path, ''), '/') WHEN NULLIF(path, '') LIKE '/%' THEN 'https://clips.agent-native.com' || path ELSE '' END AS href, user_id, anonymous_id, timestamp FROM analytics_events WHERE event_name = 'share_view' AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '7 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '30d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '30 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '90d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '90 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '180d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '180 days', 'YYYY-MM-DD')) OR ('{{timeRange}}' = '365d' AND substr(timestamp, 1, 10) >= to_char(CURRENT_DATE - INTERVAL '365 days', 'YYYY-MM-DD'))) 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 COALESCE(NULLIF(properties::jsonb ->> 'surface', ''), 'clip') = 'clip'), clip_views AS (SELECT clip_key, COALESCE(MAX(NULLIF(href, '')), clip_key) AS href, COUNT(*) AS views, COUNT(DISTINCT COALESCE(NULLIF(user_id, ''), NULLIF(anonymous_id, ''))) AS users, MAX(substr(timestamp, 1, 10)) AS last_seen FROM clip_events GROUP BY clip_key) SELECT CASE WHEN lower(COALESCE(href, '')) LIKE 'http://%' OR lower(COALESCE(href, '')) LIKE 'https://%' THEN href ELSE clip_key END AS clip, views, users, last_seen, href FROM clip_views ORDER BY views DESC LIMIT 25",
264
311
  "config": {
265
312
  "description": "Most-viewed shared Clips pages in the selected time range.",
266
313
  "sortable": true,
@@ -272,10 +319,25 @@
272
319
  "format": "link",
273
320
  "linkKey": "href"
274
321
  },
275
- { "key": "views", "label": "Views", "format": "number" },
276
- { "key": "users", "label": "Users", "format": "number" },
277
- { "key": "last_seen", "label": "Last seen", "format": "date" },
278
- { "key": "href", "hidden": true }
322
+ {
323
+ "key": "views",
324
+ "label": "Views",
325
+ "format": "number"
326
+ },
327
+ {
328
+ "key": "users",
329
+ "label": "Users",
330
+ "format": "number"
331
+ },
332
+ {
333
+ "key": "last_seen",
334
+ "label": "Last seen",
335
+ "format": "date"
336
+ },
337
+ {
338
+ "key": "href",
339
+ "hidden": true
340
+ }
279
341
  ]
280
342
  }
281
343
  },
@@ -487,8 +549,15 @@
487
549
  "config": {
488
550
  "description": "Users (by id) driving the most referred signups over the last 90 days.",
489
551
  "columns": [
490
- { "key": "referrer_user", "label": "Referrer (user id)" },
491
- { "key": "signups", "label": "Signups", "format": "number" }
552
+ {
553
+ "key": "referrer_user",
554
+ "label": "Referrer (user id)"
555
+ },
556
+ {
557
+ "key": "signups",
558
+ "label": "Signups",
559
+ "format": "number"
560
+ }
492
561
  ]
493
562
  }
494
563
  },