hazo_umetrics 1.6.2 → 1.7.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.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # hazo_umetrics — Change Log
2
2
 
3
+ ## 1.7.0 — Search query analytics: `searchQueries`, `SearchQueriesPanel`, `getIdentity`
4
+
5
+ **New features (minor — no breaking changes):**
6
+
7
+ ### `getIdentity()` — exported client identity accessor
8
+
9
+ - New export from `hazo_umetrics/client`: `getIdentity(): { session_id: string; user_id: string | null }`.
10
+ - Lets consumers attribute custom events with the SDK's tracked identity without going through `useMetrics`.
11
+
12
+ ### `searchQueries()` — server-side search aggregation
13
+
14
+ - New function in `src/server/behavior.ts`: `searchQueries(adapter, opts)`.
15
+ - Reads `action.search` rows; returns `SearchQueriesResult` with: `total_searches`, `unique_terms`, `unique_searchers`, `zero_result_searches`, `top_terms` (ranked by count), `zero_result_terms` (content gaps — searches with `result_count === 0`), `over_time` (per-day buckets), `per_user` (rows with real `user_id`).
16
+ - Term normalization: `.trim().toLowerCase()` for tallying; first-seen casing preserved for display.
17
+ - `result_count` missing ≠ zero; only explicit `0` counts as zero-result.
18
+
19
+ ### `getSearchQueries` API handler
20
+
21
+ - Added to `createBehaviorHandlers`: `GET analytics/search-queries?app_id=…[&since=…][&top_n=…][&user_ids=…]`.
22
+ - Gates on `metrics.view`; returns `{ ok: true, data: SearchQueriesResult }`.
23
+
24
+ ### `SearchQueriesPanel` — admin UI panel
25
+
26
+ - New client component exported from `hazo_umetrics/ui`: `SearchQueriesPanel({ appId, apiBase, since?, resolveUserProfiles? })`.
27
+ - Renders summary cards (total searches, unique terms, unique searchers, zero-result count), top-terms mini-bar chart, zero-result terms section (amber highlight — content gaps), searches-over-time day bars, and per-user table with optional profile resolution.
28
+
29
+ ## 1.6.4 — Fix: empty `datapoint_schema` hid all datapoint dimensions
30
+
31
+ **Fix (patch — no breaking changes):**
32
+
33
+ ### `datapointStats` treated an empty schema array as "restrict to zero keys"
34
+
35
+ - `getDatapoints` passes the action's `datapoint_schema` as `opts.schema`. For actions with no declared schema this is `[]`, which is truthy — so `datapointStats` built an empty `schemaKeys` Set and then filtered out **every** discovered dimension via `if (schemaKeys && !schemaKeys.has(k)) continue`.
36
+ - Result: any action without an explicit schema showed "No datapoint dimensions found" even when its events carried non-reserved dimensions (e.g. `timer`, `duration_bucket`).
37
+ - Now an empty/absent schema means "no constraint — surface all discovered dimensions"; a non-empty schema still restricts to the declared keys.
38
+
3
39
  ## 1.5.1 — `ui.pageexit.*` rename, smart auto-labels, `defaultActionLabels`, `ActionsPanel.refreshTrigger`
4
40
 
5
41
  **Fixes / improvements (patch — no breaking changes for new installs; existing `ui.dwell.*` catalog rows need re-seeding):**
@@ -178,5 +178,10 @@ export declare function createBehaviorHandlers(options: ApiFactoryOptions): {
178
178
  * Returns user journey sessions and aggregated graph. Requires metrics.view.
179
179
  */
180
180
  getJourney(req: Request): Promise<Response>;
181
+ /**
182
+ * GET /...?app_id=<id>[&since=<iso>][&top_n=<n>][&user_ids=<csv>]
183
+ * Returns search query analytics. Requires metrics.view.
184
+ */
185
+ getSearchQueries(req: Request): Promise<Response>;
181
186
  };
182
187
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAwC9D,MAAM,MAAM,SAAS,GAAG,CACtB,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE;IAAE,oBAAoB,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,KAC3E,OAAO,CAAC;IACX,aAAa,EAAE,OAAO,CAAC;IACvB,IAAI,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC5B,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,aAAa,EAAE,OAAO,CAAC;IACvB,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB,CAAC,CAAC;AAEH,MAAM,WAAW,iBAAiB;IAChC,UAAU,EAAE,MAAM,OAAO,CAAC,kBAAkB,CAAC,GAAG,kBAAkB,CAAC;IACnE,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,CAAC,EAAE,OAAO,CAAC;KACnB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACpB;;;;;;;;;;;;;;OAcG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9C;AAiGD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB;IAIzD;;;OAGG;iBACgB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqB9C;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAwBpD;;;;OAIG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EA4CpD;AAqBD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,iBAAiB;IAW/D;;;OAGG;yBACwB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqBtD;;;;OAIG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAiDlD;;;;OAIG;yBACwB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IA8CtD;;;;OAIG;wBACuB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EA8CxD;AAMD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,iBAAiB;IAI/D,2DAA2D;iBACxC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAe9C,sDAAsD;mBACjC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAenD;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB;IAI1D,oDAAoD;eACnC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAe/C;AAMD,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,IAAI,CAAC,iBAAiB,EAAE,YAAY,GAAG,qBAAqB,CAAC;IAEtG;;;;;;;;;OASG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EA6CrD;AAMD,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,iBAAiB;IAI3D;;;OAGG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoBlD;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoBpD;;;;;OAKG;sBACqB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkDnD;;;;;OAKG;2BAC0B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAsE3D;AAMD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB;IAI7D;;;OAGG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAyBrD;AAMD,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,iBAAiB;IAIzD;;;OAGG;kBACiB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAkB/C;;;;OAIG;iBACgB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAoC9C;;;;OAIG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAiCpD;AAMD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,iBAAiB;IAI7D;;;;OAIG;qBACoB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IA0DlD;;;OAGG;2BAC0B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAyBxD;;;OAGG;uBACsB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAqCpD;;;OAGG;oBACmB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;IAyBjD;;;OAGG;0BACyB,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;EAsB1D"}
package/dist/api/index.js CHANGED
@@ -9,7 +9,7 @@ import { recordStat as _recordStat, getLatestStat as _getLatestStat, getStatSeri
9
9
  import { resolveVariant } from '../server/flags.js';
10
10
  import { getActivitySummary as _getActivitySummary, getActivityTrend as _getActivityTrend, } from '../server/activity.js';
11
11
  import { ensureActionDef as _ensureActionDef, listActions as _listActions, getActionTree as _getActionTree, updateActionDef as _updateActionDef, updateActionDefBulk as _updateActionDefBulk, applyLinksBulk as _applyLinksBulk, getLinks as _getLinks, addLink as _addLink, removeLink as _removeLink, } from '../server/actions.js';
12
- import { topActions as _topActions, firstSessionActions as _firstSessionActions, postReloginActions as _postReloginActions, actionsBeforeGoal as _actionsBeforeGoal, topErrors as _topErrors, tabDwell as _tabDwell, frequentActionsReturning as _frequentActionsReturning, eventsOverTime as _eventsOverTime, datapointStats as _datapointStats, userJourney as _userJourney, } from '../server/behavior.js';
12
+ import { topActions as _topActions, firstSessionActions as _firstSessionActions, postReloginActions as _postReloginActions, actionsBeforeGoal as _actionsBeforeGoal, topErrors as _topErrors, tabDwell as _tabDwell, frequentActionsReturning as _frequentActionsReturning, eventsOverTime as _eventsOverTime, datapointStats as _datapointStats, userJourney as _userJourney, searchQueries as _searchQueries, } from '../server/behavior.js';
13
13
  // ---------------------------------------------------------------------------
14
14
  // Default auth — dynamically imports hazo_auth/server-lib to avoid the
15
15
  // server-only guard firing at module-evaluation time (e.g. in test runners).
@@ -859,6 +859,8 @@ export function createBehaviorHandlers(options) {
859
859
  const user_id = url.searchParams.get('user_id') ?? undefined;
860
860
  const session_id = url.searchParams.get('session_id') ?? undefined;
861
861
  const since = url.searchParams.get('since') ?? undefined;
862
+ const splitByPage = url.searchParams.get('split_by_page') === '1';
863
+ const pageFlow = url.searchParams.get('page_flow') === '1';
862
864
  const gate = await gateAuth(req, {
863
865
  required_permissions: ['metrics.view'],
864
866
  app_id: app_id || undefined,
@@ -871,7 +873,32 @@ export function createBehaviorHandlers(options) {
871
873
  const adapter = await options.getAdapter();
872
874
  const catalog = await _listActions(adapter, { app_id });
873
875
  const catalogMap = new Map(catalog.map((a) => [a.action_key, { label: a.label, datapoint_schema: a.datapoint_schema }]));
874
- const result = await _userJourney(adapter, { app_id, user_id, session_id, since, catalogMap });
876
+ const result = await _userJourney(adapter, { app_id, user_id, session_id, since, catalogMap, splitByPage, pageFlow });
877
+ return jsonOk(result);
878
+ },
879
+ /**
880
+ * GET /...?app_id=<id>[&since=<iso>][&top_n=<n>][&user_ids=<csv>]
881
+ * Returns search query analytics. Requires metrics.view.
882
+ */
883
+ async getSearchQueries(req) {
884
+ const url = new URL(req.url);
885
+ const app_id = url.searchParams.get('app_id') ?? '';
886
+ const since = url.searchParams.get('since') ?? undefined;
887
+ const topNParam = url.searchParams.get('top_n');
888
+ const topN = topNParam != null ? parseInt(topNParam, 10) : undefined;
889
+ const userIdsParam = url.searchParams.get('user_ids');
890
+ const userIds = userIdsParam ? userIdsParam.split(',').filter(Boolean) : undefined;
891
+ const gate = await gateAuth(req, {
892
+ required_permissions: ['metrics.view'],
893
+ app_id: app_id || undefined,
894
+ getAuth,
895
+ });
896
+ if (!gate.ok)
897
+ return gate.response;
898
+ if (!app_id)
899
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
900
+ const adapter = await options.getAdapter();
901
+ const result = await _searchQueries(adapter, { app_id, since, topN, userIds });
875
902
  return jsonOk(result);
876
903
  },
877
904
  };
@@ -18,6 +18,10 @@ export declare function useMetrics(): MetricsContextValue;
18
18
  export declare function getVariant(serverVariant: string): string;
19
19
  export declare function isEnabled(serverVariant: string): boolean;
20
20
  export declare function identify(userId: string): void;
21
+ export declare function getIdentity(): {
22
+ session_id: string;
23
+ user_id: string | null;
24
+ };
21
25
  export declare function recordStat(_key: string, _value: number, _opts?: {
22
26
  unit?: string;
23
27
  }): void;
@@ -1 +1 @@
1
- {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAoD1B,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACjC,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC;IACjF,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1G,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;CAClE;AASD,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,wBAAwB,4EAsJxF;AAMD,wBAAgB,UAAU,IAAI,mBAAmB,CAEhD;AAMD,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAK7C;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAExF"}
1
+ {"version":3,"file":"index.client.d.ts","sourceRoot":"","sources":["../src/index.client.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,MAAM,OAAO,CAAC;AAoD1B,UAAU,mBAAmB;IAC3B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACjC,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,KAAK,IAAI,CAAC;IACjF,WAAW,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAA;KAAE,KAAK,IAAI,CAAC;IAC1G,UAAU,EAAE,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,KAAK,IAAI,CAAC;CAClE;AASD,MAAM,WAAW,wBAAwB;IACvC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;CAC3B;AAED,wBAAgB,mBAAmB,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,wBAAwB,4EAsJxF;AAMD,wBAAgB,UAAU,IAAI,mBAAmB,CAEhD;AAMD,wBAAgB,UAAU,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAExD;AAED,wBAAgB,SAAS,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAExD;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAK7C;AAED,wBAAgB,WAAW,IAAI;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,CAE5E;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE;IAAE,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAExF"}
@@ -199,6 +199,9 @@ export function identify(userId) {
199
199
  localStorage.setItem('hazo_umetrics_uid', userId);
200
200
  }
201
201
  }
202
+ export function getIdentity() {
203
+ return { session_id: getSessionId(), user_id: getUserId() };
204
+ }
202
205
  export function recordStat(_key, _value, _opts) {
203
206
  // M1: client-side stat record via API
204
207
  }
@@ -63,7 +63,7 @@ export declare function topActions(adapter: HazoConnectAdapter, opts: BehaviorOp
63
63
  export declare function eventsOverTime(adapter: HazoConnectAdapter, opts: BehaviorOptions): Promise<EventDayBucket[]>;
64
64
  export interface JourneyStep {
65
65
  metric_key: string;
66
- kind: 'action' | 'pageview' | 'error' | 'other';
66
+ kind: 'action' | 'pageview' | 'click' | 'error' | 'other';
67
67
  label: string;
68
68
  path?: string;
69
69
  captured_at: string;
@@ -81,8 +81,14 @@ export interface JourneySession {
81
81
  export interface JourneyGraphNode {
82
82
  id: string;
83
83
  label: string;
84
- kind: 'action' | 'pageview' | 'error' | 'other';
84
+ kind: 'action' | 'pageview' | 'click' | 'error' | 'other';
85
85
  count: number;
86
+ /** Pages this event fired on, most frequent first. Lets the UI answer
87
+ * "which page is this click from?" for nodes keyed only by metric_key. */
88
+ paths?: Array<{
89
+ path: string;
90
+ count: number;
91
+ }>;
86
92
  }
87
93
  export interface JourneyGraphEdge {
88
94
  from: string;
@@ -108,6 +114,13 @@ export declare function userJourney(adapter: HazoConnectAdapter, opts: {
108
114
  label?: string;
109
115
  datapoint_schema?: DatapointSchema;
110
116
  }>;
117
+ /** When true, nodes/edges are keyed by (metric_key + page) so the same event
118
+ * on different pages becomes distinct boxes. Removes cross-page ambiguity. */
119
+ splitByPage?: boolean;
120
+ /** When true, only pageview events form the graph and each pageview links to
121
+ * the NEXT pageview in the session (intervening clicks are skipped), giving a
122
+ * connected page-to-page navigation flow. */
123
+ pageFlow?: boolean;
111
124
  }): Promise<JourneyResult>;
112
125
  export interface DatapointKeyStats {
113
126
  key: string;
@@ -143,4 +156,29 @@ export declare function datapointStats(adapter: HazoConnectAdapter, opts: {
143
156
  topN?: number;
144
157
  schema?: DatapointSchema;
145
158
  }): Promise<DatapointStatsResult>;
159
+ export interface SearchTermEntry {
160
+ term: string;
161
+ count: number;
162
+ searchers: number;
163
+ zeroResults: number;
164
+ lastSearchedAt: string;
165
+ }
166
+ export interface SearchQueriesResult {
167
+ total_searches: number;
168
+ unique_terms: number;
169
+ unique_searchers: number;
170
+ zero_result_searches: number;
171
+ top_terms: SearchTermEntry[];
172
+ zero_result_terms: SearchTermEntry[];
173
+ over_time: EventDayBucket[];
174
+ per_user: Array<{
175
+ user_id: string;
176
+ count: number;
177
+ distinct_terms: number;
178
+ terms: string[];
179
+ }>;
180
+ }
181
+ export declare function searchQueries(adapter: HazoConnectAdapter, opts: BehaviorOptions & {
182
+ topN?: number;
183
+ }): Promise<SearchQueriesResult>;
146
184
  //# sourceMappingURL=behavior.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"behavior.d.ts","sourceRoot":"","sources":["../../src/server/behavior.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"behavior.d.ts","sourceRoot":"","sources":["../../src/server/behavior.ts"],"names":[],"mappings":"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"}
@@ -347,8 +347,11 @@ export async function eventsOverTime(adapter, opts) {
347
347
  function classifyKind(metric_key) {
348
348
  if (metric_key.startsWith('action.'))
349
349
  return 'action';
350
- if (metric_key.startsWith('pageview'))
350
+ // Pageview keys are emitted as `ui.pageview.*` (also accept bare `pageview*`).
351
+ if (metric_key.startsWith('ui.pageview') || metric_key.startsWith('pageview'))
351
352
  return 'pageview';
353
+ if (metric_key.startsWith('ui.click.'))
354
+ return 'click';
352
355
  if (metric_key.startsWith('ui.error.') || metric_key.startsWith('error.'))
353
356
  return 'error';
354
357
  return 'other';
@@ -386,9 +389,22 @@ export async function userJourney(adapter, opts) {
386
389
  const nodeCountMap = new Map();
387
390
  const edgeAccMap = new Map();
388
391
  const sessions = [];
392
+ // Node/edge identity. In split-by-page mode the same event on two pages gets
393
+ // two distinct ids (NUL separator — never appears in a metric_key or path).
394
+ const scopedId = (mk, d) => {
395
+ if (!opts.splitByPage)
396
+ return mk;
397
+ const p = d.path;
398
+ return p ? `${mk}@@${p}` : mk;
399
+ };
389
400
  for (const [sessionKey, sessionRows] of sessionGroups) {
390
401
  // fetchWindow returns rows in ascending captured_at order
391
402
  const steps = [];
403
+ // Last node accumulated into the graph for this session. Edges connect it to
404
+ // the next accumulated node — so in page-flow mode (where non-pageviews are
405
+ // skipped) a pageview links to the next pageview, not the intervening click.
406
+ let prevNodeId = null;
407
+ let prevCapturedMs = null;
392
408
  for (let i = 0; i < sessionRows.length; i++) {
393
409
  const row = sessionRows[i];
394
410
  const dims = parseDims(row.dimensions);
@@ -397,7 +413,11 @@ export async function userJourney(adapter, opts) {
397
413
  // Look up catalog entry by action_key (strips 'action.' prefix)
398
414
  const actionKey = metric_key.startsWith('action.') ? metric_key.slice('action.'.length) : metric_key;
399
415
  const catalogEntry = opts.catalogMap?.get(actionKey);
400
- const label = catalogEntry?.label ?? metric_key;
416
+ // Prefer a human label: curated catalog label click text captured in
417
+ // dims.label → page path (for pageviews) → raw metric_key as last resort.
418
+ const dimLabel = typeof dims.label === 'string' && dims.label.trim() ? dims.label.trim() : undefined;
419
+ const prettyFallback = kind === 'pageview' ? (dims.path ?? metric_key) : metric_key;
420
+ const label = catalogEntry?.label ?? dimLabel ?? prettyFallback;
401
421
  // Duration to next step in this session
402
422
  const nextRow = sessionRows[i + 1];
403
423
  let durationMsToNext = null;
@@ -428,24 +448,32 @@ export async function userJourney(adapter, opts) {
428
448
  session_id: dims.session_id ?? '__anon__',
429
449
  user_id: dims.user_id,
430
450
  });
431
- // Accumulate node count
432
- if (!nodeCountMap.has(metric_key)) {
433
- nodeCountMap.set(metric_key, { label, kind, count: 0 });
451
+ // In page-flow mode, only pageviews participate in the graph.
452
+ if (opts.pageFlow && kind !== 'pageview')
453
+ continue;
454
+ // Accumulate node count + per-page tally (keyed by scoped id)
455
+ const nodeId = scopedId(metric_key, dims);
456
+ if (!nodeCountMap.has(nodeId)) {
457
+ nodeCountMap.set(nodeId, { label, kind, count: 0, pathCounts: new Map() });
434
458
  }
435
- nodeCountMap.get(metric_key).count++;
436
- // Accumulate edge (from previous step to current)
437
- if (i > 0) {
438
- const fromKey = sessionRows[i - 1].metric_key;
439
- const edgeKey = `${fromKey}→${metric_key}`;
459
+ const nodeAcc = nodeCountMap.get(nodeId);
460
+ nodeAcc.count++;
461
+ const stepPath = dims.path;
462
+ if (stepPath)
463
+ nodeAcc.pathCounts.set(stepPath, (nodeAcc.pathCounts.get(stepPath) ?? 0) + 1);
464
+ // Edge from the previous accumulated node to this one.
465
+ const nowMs = new Date(row.captured_at).getTime();
466
+ if (prevNodeId !== null) {
467
+ const edgeKey = `${prevNodeId}→${nodeId}`;
440
468
  if (!edgeAccMap.has(edgeKey)) {
441
- edgeAccMap.set(edgeKey, { from: fromKey, to: metric_key, count: 0, totalMs: 0 });
469
+ edgeAccMap.set(edgeKey, { from: prevNodeId, to: nodeId, count: 0, totalMs: 0 });
442
470
  }
443
471
  const edgeData = edgeAccMap.get(edgeKey);
444
472
  edgeData.count++;
445
- const t1 = new Date(sessionRows[i - 1].captured_at).getTime();
446
- const t2 = new Date(row.captured_at).getTime();
447
- edgeData.totalMs += (t2 - t1);
473
+ edgeData.totalMs += prevCapturedMs !== null ? (nowMs - prevCapturedMs) : 0;
448
474
  }
475
+ prevNodeId = nodeId;
476
+ prevCapturedMs = nowMs;
449
477
  }
450
478
  sessions.push({
451
479
  session_id: sessionKey,
@@ -455,7 +483,15 @@ export async function userJourney(adapter, opts) {
455
483
  }
456
484
  // Build sorted graph
457
485
  const nodes = [...nodeCountMap.entries()]
458
- .map(([id, data]) => ({ id, label: data.label, kind: data.kind, count: data.count }))
486
+ .map(([id, data]) => ({
487
+ id,
488
+ label: data.label,
489
+ kind: data.kind,
490
+ count: data.count,
491
+ paths: [...data.pathCounts.entries()]
492
+ .map(([path, count]) => ({ path, count }))
493
+ .sort((a, b) => b.count - a.count),
494
+ }))
459
495
  .sort((a, b) => a.id.localeCompare(b.id));
460
496
  const edges = [...edgeAccMap.values()]
461
497
  .map(({ from, to, count, totalMs }) => ({ from, to, count, avgMs: count > 0 ? totalMs / count : 0 }))
@@ -475,8 +511,11 @@ export async function datapointStats(adapter, opts) {
475
511
  filtered = filtered.filter((r) => r.captured_at <= opts.until);
476
512
  }
477
513
  const total_events = filtered.length;
478
- const schemaKeys = opts.schema ? new Set(opts.schema.map((f) => f.key)) : null;
479
- const schemaMap = opts.schema ? new Map(opts.schema.map((f) => [f.key, f])) : null;
514
+ // An empty schema array means "no schema declared" restrict to nothing would
515
+ // wrongly filter out every discovered dimension. Only constrain when non-empty.
516
+ const hasSchema = Array.isArray(opts.schema) && opts.schema.length > 0;
517
+ const schemaKeys = hasSchema ? new Set(opts.schema.map((f) => f.key)) : null;
518
+ const schemaMap = hasSchema ? new Map(opts.schema.map((f) => [f.key, f])) : null;
480
519
  const allValues = new Map();
481
520
  for (const row of filtered) {
482
521
  const dims = parseDims(row.dimensions);
@@ -539,3 +578,92 @@ export async function datapointStats(adapter, opts) {
539
578
  keys.sort((a, b) => a.key.localeCompare(b.key));
540
579
  return { action_key: opts.action_key, total_events, keys };
541
580
  }
581
+ export async function searchQueries(adapter, opts) {
582
+ const { app_id, scope_id, since, limit = 10000 } = opts;
583
+ const topN_ = opts.topN ?? 20;
584
+ const rows = await fetchWindow(adapter, { app_id, scope_id, since: since ?? defaultSince(), cap: limit });
585
+ const searchRows = rows.filter((r) => r.metric_key === 'action.search');
586
+ const termMap = new Map();
587
+ const dayMap = new Map();
588
+ const userMap = new Map();
589
+ let zeroResultSearches = 0;
590
+ const allSearchers = new Set();
591
+ for (const row of searchRows) {
592
+ const dims = parseDims(row.dimensions);
593
+ const rawTerm = typeof dims.term === 'string' ? dims.term : String(dims.term ?? '');
594
+ if (!rawTerm)
595
+ continue;
596
+ const normTerm = rawTerm.trim().toLowerCase();
597
+ const uid = dims.user_id;
598
+ const sid = dims.session_id;
599
+ const identity = uid ?? sid;
600
+ const resultCount = dims.result_count != null ? Number(dims.result_count) : null;
601
+ const isZero = resultCount === 0;
602
+ if (identity)
603
+ allSearchers.add(identity);
604
+ // Term accumulator
605
+ if (!termMap.has(normTerm)) {
606
+ termMap.set(normTerm, {
607
+ count: 0,
608
+ searchers: new Set(),
609
+ zeroResults: 0,
610
+ firstCasing: rawTerm.trim(),
611
+ lastAt: row.captured_at,
612
+ });
613
+ }
614
+ const acc = termMap.get(normTerm);
615
+ acc.count++;
616
+ if (identity)
617
+ acc.searchers.add(identity);
618
+ if (isZero) {
619
+ acc.zeroResults++;
620
+ zeroResultSearches++;
621
+ }
622
+ if (row.captured_at > acc.lastAt)
623
+ acc.lastAt = row.captured_at;
624
+ // Day bucket
625
+ const day = dayBucket(row.captured_at);
626
+ dayMap.set(day, (dayMap.get(day) ?? 0) + 1);
627
+ // Per-user
628
+ if (uid) {
629
+ if (!userMap.has(uid))
630
+ userMap.set(uid, { count: 0, terms: new Set() });
631
+ const ua = userMap.get(uid);
632
+ ua.count++;
633
+ ua.terms.add(normTerm);
634
+ }
635
+ }
636
+ const allTermEntries = [...termMap.entries()].map(([, acc]) => ({
637
+ term: acc.firstCasing,
638
+ count: acc.count,
639
+ searchers: acc.searchers.size,
640
+ zeroResults: acc.zeroResults,
641
+ lastSearchedAt: acc.lastAt,
642
+ }));
643
+ const top_terms = [...allTermEntries].sort((a, b) => b.count - a.count).slice(0, topN_);
644
+ const zero_result_terms = allTermEntries
645
+ .filter((e) => e.zeroResults > 0)
646
+ .sort((a, b) => b.zeroResults - a.zeroResults)
647
+ .slice(0, topN_);
648
+ const over_time = [...dayMap.entries()]
649
+ .sort((a, b) => a[0].localeCompare(b[0]))
650
+ .map(([day, count]) => ({ day, count }));
651
+ const per_user = [...userMap.entries()]
652
+ .map(([user_id, { count, terms }]) => ({
653
+ user_id,
654
+ count,
655
+ distinct_terms: terms.size,
656
+ terms: [...terms],
657
+ }))
658
+ .sort((a, b) => b.count - a.count);
659
+ return {
660
+ total_searches: searchRows.length,
661
+ unique_terms: termMap.size,
662
+ unique_searchers: allSearchers.size,
663
+ zero_result_searches: zeroResultSearches,
664
+ top_terms,
665
+ zero_result_terms,
666
+ over_time,
667
+ per_user,
668
+ };
669
+ }
@@ -3,4 +3,5 @@ export * from './actions_panel.js';
3
3
  export * from './analytics_panel.js';
4
4
  export * from './datapoint_panel.js';
5
5
  export * from './journey_panel.js';
6
+ export * from './search_queries_panel.js';
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC"}
package/dist/ui/index.js CHANGED
@@ -5,3 +5,4 @@ export * from './actions_panel.js';
5
5
  export * from './analytics_panel.js';
6
6
  export * from './datapoint_panel.js';
7
7
  export * from './journey_panel.js';
8
+ export * from './search_queries_panel.js';
@@ -1,6 +1,6 @@
1
1
  export interface JourneyStep {
2
2
  metric_key: string;
3
- kind: 'action' | 'pageview' | 'error' | 'other';
3
+ kind: 'action' | 'pageview' | 'click' | 'error' | 'other';
4
4
  label: string;
5
5
  path?: string;
6
6
  captured_at: string;
@@ -18,8 +18,13 @@ export interface JourneySession {
18
18
  export interface JourneyGraphNode {
19
19
  id: string;
20
20
  label: string;
21
- kind: 'action' | 'pageview' | 'error' | 'other';
21
+ kind: 'action' | 'pageview' | 'click' | 'error' | 'other';
22
22
  count: number;
23
+ /** Pages this event fired on, most frequent first. */
24
+ paths?: Array<{
25
+ path: string;
26
+ count: number;
27
+ }>;
23
28
  }
24
29
  export interface JourneyGraphEdge {
25
30
  from: string;
@@ -46,6 +51,10 @@ export interface JourneyPanelProps {
46
51
  name?: string;
47
52
  profile_picture_url?: string;
48
53
  }>>;
54
+ /** Let edges enter a node's top/bottom (not only its left) to reduce crossings. Default true. */
55
+ multiSidePorts?: boolean;
56
+ /** Route back edges forward out of the source before curving back. Default true. */
57
+ backEdgeLoop?: boolean;
49
58
  }
50
- export declare function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles }: JourneyPanelProps): import("react").JSX.Element;
59
+ export declare function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles, multiSidePorts, backEdgeLoop }: JourneyPanelProps): import("react").JSX.Element;
51
60
  //# sourceMappingURL=journey_panel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"journey_panel.d.ts","sourceRoot":"","sources":["../../src/ui/journey_panel.tsx"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;IAChD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,KAAK,EAAE,YAAY,CAAC;CACrB;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC3I;AAiaD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,EAAE,iBAAiB,+BAoJzG"}
1
+ {"version":3,"file":"journey_panel.d.ts","sourceRoot":"","sources":["../../src/ui/journey_panel.tsx"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,KAAK,EAAE,YAAY,CAAC;CACrB;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC1I,iGAAiG;IACjG,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oFAAoF;IACpF,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AA4kBD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,cAAqB,EAAE,YAAmB,EAAE,EAAE,iBAAiB,+BAiLrJ"}
@@ -18,6 +18,7 @@ function kindColor(kind) {
18
18
  switch (kind) {
19
19
  case 'action': return '#6366f1';
20
20
  case 'pageview': return '#10b981';
21
+ case 'click': return '#f59e0b';
21
22
  case 'error': return '#ef4444';
22
23
  default: return '#94a3b8';
23
24
  }
@@ -27,7 +28,14 @@ function clampZoom(z) { return Math.max(0.25, Math.min(3, z)); }
27
28
  // JourneyFlowGraph — SVG left-to-right DAG visualization
28
29
  // ---------------------------------------------------------------------------
29
30
  const NODE_W = 148;
30
- const NODE_H = 44;
31
+ const NODE_H = 56;
32
+ /** Shorten a page path for display inside a node (keeps the tail, which is the
33
+ * distinguishing part: /board-games/backgammon-timer → …/backgammon-timer). */
34
+ function shortPath(path, max = 20) {
35
+ if (path.length <= max)
36
+ return path;
37
+ return '…' + path.slice(path.length - (max - 1));
38
+ }
31
39
  const COL_GAP = 200;
32
40
  const ROW_GAP = 64;
33
41
  const PAD = 24;
@@ -103,13 +111,75 @@ function computeFlowLayout(nodes, edges) {
103
111
  }
104
112
  return pos;
105
113
  }
106
- function JourneyFlowGraph({ graph, userFilter }) {
114
+ /** A node port: anchor point on a node side plus the outward unit normal. The
115
+ * normal drives the bezier control point so the line leaves/enters square to
116
+ * the side (arrowheads sit flush and curves don't clip the node corner). */
117
+ function portPoint(x, y, side) {
118
+ switch (side) {
119
+ case 'left': return { px: x, py: y + NODE_H / 2, ux: -1, uy: 0 };
120
+ case 'right': return { px: x + NODE_W, py: y + NODE_H / 2, ux: 1, uy: 0 };
121
+ case 'top': return { px: x + NODE_W / 2, py: y, ux: 0, uy: -1 };
122
+ case 'bottom': return { px: x + NODE_W / 2, py: y + NODE_H, ux: 0, uy: 1 };
123
+ }
124
+ }
125
+ /** Pick exit/entry sides for an edge and emit its cubic-bezier path plus a
126
+ * label anchor (bezier midpoint). `sx,sy`/`tx,ty` are node top-left corners in
127
+ * screen space.
128
+ *
129
+ * - multiSidePorts: let steep forward edges enter the target's top/bottom, and
130
+ * same-column neighbours connect bottom→top, instead of always entering the
131
+ * left side. Cuts the fan of crossings that piles up on a single side.
132
+ * - backEdgeLoop: for back edges (target left of source) leave the source's
133
+ * right side — going forward a little — then curve back into the target's
134
+ * top/bottom, instead of cutting straight back across the source. */
135
+ function buildEdgePath(sx, sy, tx, ty, multiSidePorts, backEdgeLoop) {
136
+ const dx = tx - sx;
137
+ const dyc = ty - sy; // top-left deltas; centers share the same delta
138
+ const COL_TOL = (NODE_W + COL_GAP) * 0.5;
139
+ let exitSide = 'right';
140
+ let entrySide = 'left';
141
+ if (dx > COL_TOL) {
142
+ // Forward edge. Steep ones enter top/bottom so they don't crowd the left.
143
+ if (multiSidePorts && Math.abs(dyc) > Math.max(NODE_H * 1.5, dx * 0.8)) {
144
+ entrySide = dyc > 0 ? 'top' : 'bottom';
145
+ }
146
+ }
147
+ else if (dx < -COL_TOL) {
148
+ // Back edge: target is to the left.
149
+ if (backEdgeLoop) {
150
+ exitSide = 'right'; // hook forward first
151
+ entrySide = dyc >= 0 ? 'top' : 'bottom';
152
+ }
153
+ }
154
+ else {
155
+ // Same column: vertical neighbours connect bottom↔top.
156
+ if (multiSidePorts || backEdgeLoop) {
157
+ exitSide = dyc >= 0 ? 'bottom' : 'top';
158
+ entrySide = dyc >= 0 ? 'top' : 'bottom';
159
+ }
160
+ }
161
+ const e = portPoint(sx, sy, exitSide);
162
+ const n = portPoint(tx, ty, entrySide);
163
+ const dist = Math.hypot(n.px - e.px, n.py - e.py);
164
+ const k = Math.max(40, dist * 0.5);
165
+ const c1x = e.px + e.ux * k, c1y = e.py + e.uy * k;
166
+ const c2x = n.px + n.ux * k, c2y = n.py + n.uy * k;
167
+ const d = `M${e.px},${e.py} C${c1x},${c1y} ${c2x},${c2y} ${n.px},${n.py}`;
168
+ // Cubic bezier midpoint (t=0.5): 0.125·P0 + 0.375·P1 + 0.375·P2 + 0.125·P3.
169
+ const lx = 0.125 * e.px + 0.375 * c1x + 0.375 * c2x + 0.125 * n.px;
170
+ const ly = 0.125 * e.py + 0.375 * c1y + 0.375 * c2y + 0.125 * n.py;
171
+ return { d, lx, ly };
172
+ }
173
+ function JourneyFlowGraph({ graph, userFilter, loading, multiSidePorts = true, backEdgeLoop = true }) {
107
174
  const [zoom, setZoom] = useState(1.0);
108
175
  const [pan, setPan] = useState({ x: 0, y: 0 });
109
176
  const [dragging, setDragging] = useState(false);
110
177
  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
111
178
  const [selectedNode, setSelectedNode] = useState(null);
112
179
  const [isFullscreen, setIsFullscreen] = useState(false);
180
+ const [visibleKinds, setVisibleKinds] = useState({
181
+ action: true, pageview: true, click: true, error: true, other: true,
182
+ });
113
183
  const wrapperRef = useRef(null);
114
184
  const containerRef = useRef(null);
115
185
  // Holds latest fitToScreen so the fullscreenchange effect can call it without
@@ -150,7 +220,11 @@ function JourneyFlowGraph({ graph, userFilter }) {
150
220
  }, []);
151
221
  if (graph.nodes.length === 0)
152
222
  return null;
153
- const pos = computeFlowLayout(graph.nodes, graph.edges);
223
+ // Apply kind filters: drop hidden-kind nodes and any edge touching them.
224
+ const vNodes = graph.nodes.filter(n => visibleKinds[n.kind]);
225
+ const vIds = new Set(vNodes.map(n => n.id));
226
+ const vEdges = graph.edges.filter(e => vIds.has(e.from) && vIds.has(e.to));
227
+ const pos = computeFlowLayout(vNodes, vEdges);
154
228
  // Compute SVG dimensions
155
229
  let maxX = 0;
156
230
  let maxY = 0;
@@ -192,7 +266,7 @@ function JourneyFlowGraph({ graph, userFilter }) {
192
266
  const highlightedNodes = new Set();
193
267
  if (selectedNode) {
194
268
  highlightedNodes.add(selectedNode);
195
- graph.edges.forEach((edge, i) => {
269
+ vEdges.forEach((edge, i) => {
196
270
  if (edge.from === selectedNode || edge.to === selectedNode) {
197
271
  highlightedEdges.add(i);
198
272
  highlightedNodes.add(edge.from);
@@ -201,44 +275,54 @@ function JourneyFlowGraph({ graph, userFilter }) {
201
275
  });
202
276
  }
203
277
  const hasSelection = selectedNode !== null;
204
- const maxCount = Math.max(...graph.nodes.map(n => n.count), 1);
205
- return (_jsxs("div", { ref: wrapperRef, className: "rounded-lg border bg-white", style: isFullscreen ? { display: 'flex', flexDirection: 'column', height: '100vh', borderRadius: 0 } : undefined, children: [_jsxs("div", { className: "px-4 py-2 border-b flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "text-xs font-semibold text-gray-600 uppercase tracking-wide", children: "Flow Graph" }), _jsxs("span", { className: "text-xs text-gray-400", children: ["(", graph.nodes.length, " nodes \u00B7 ", graph.edges.length, " transitions)"] }), isFullscreen && userFilter && (_jsx("div", { className: "flex items-center gap-1.5 text-xs text-gray-500", children: userFilter })), _jsxs("div", { className: "ml-auto flex items-center gap-3 text-[10px] text-gray-400", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#6366f1' } }), " action"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#10b981' } }), " pageview"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#ef4444' } }), " error"] }), _jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => setZoom(z => clampZoom(z * 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom in", children: "+" }), _jsx("button", { onClick: () => setZoom(z => clampZoom(z / 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom out", children: "\u2212" }), _jsx("button", { onClick: fitToScreen, className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Fit to screen", children: "\u22A1" }), _jsx("button", { onClick: () => setSelectedNode(null), disabled: !hasSelection, className: `px-1.5 py-0.5 text-xs border rounded flex items-center ${hasSelection ? 'bg-indigo-50 border-indigo-300 text-indigo-600 hover:bg-indigo-100' : 'text-gray-300 cursor-default'}`, title: "Clear selection", children: _jsx(MousePointer2, { className: "w-3 h-3" }) }), _jsx("button", { onClick: toggleFullscreen, className: "px-1.5 py-0.5 text-xs border rounded flex items-center hover:bg-gray-50", title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', children: isFullscreen ? _jsx(Minimize2, { className: "w-3 h-3" }) : _jsx(Maximize2, { className: "w-3 h-3" }) })] })] })] }), _jsx("div", { ref: containerRef, style: { position: 'relative', height: isFullscreen ? undefined : '500px', flex: isFullscreen ? 1 : undefined, overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab' }, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: _jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block', transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom})`, transformOrigin: '0 0', userSelect: 'none' }, children: [_jsx("defs", { children: _jsx("marker", { id: "arrow", markerWidth: "8", markerHeight: "8", refX: "7", refY: "3", orient: "auto", children: _jsx("path", { d: "M0,0 L0,6 L8,3 z", fill: "#94a3b8" }) }) }), graph.edges.map((edge, i) => {
206
- const from = pos.get(edge.from);
207
- const to = pos.get(edge.to);
208
- if (!from || !to)
209
- return null;
210
- const x1 = from.x + NODE_W;
211
- const y1 = from.y + offsetY + NODE_H / 2;
212
- const x2 = to.x;
213
- const y2 = to.y + offsetY + NODE_H / 2;
214
- const cx = (x1 + x2) / 2;
215
- const isHighlighted = hasSelection ? highlightedEdges.has(i) : true;
216
- const edgeOpacity = isHighlighted ? Math.max(0.4, edge.count / maxCount) : 0.05;
217
- const edgeStroke = isHighlighted && hasSelection ? '#6366f1' : '#94a3b8';
218
- const edgeStrokeW = isHighlighted ? Math.max(1.5, Math.min(4, edge.count / maxCount * 4)) : 1;
219
- return (_jsxs("g", { children: [_jsx("path", { d: `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`, fill: "none", stroke: edgeStroke, strokeWidth: edgeStrokeW, strokeOpacity: edgeOpacity, markerEnd: "url(#arrow)" }), (() => {
220
- // On selection, label every connected edge (even 1×) boldly so
221
- // occurrence counts are readable; otherwise keep the faint >1 hint.
222
- const selectedEdge = hasSelection && isHighlighted;
223
- const showLabel = hasSelection ? isHighlighted : edge.count > 1;
224
- if (!showLabel)
225
- return null;
226
- return (_jsxs("text", { x: cx, y: (y1 + y2) / 2 - 4, textAnchor: "middle", fontSize: selectedEdge ? 12 : 9, fontWeight: selectedEdge ? 700 : 400, fill: selectedEdge ? '#4338ca' : '#94a3b8', opacity: selectedEdge ? 1 : edgeOpacity, stroke: selectedEdge ? '#ffffff' : undefined, strokeWidth: selectedEdge ? 3 : undefined, paintOrder: "stroke", children: [edge.count, "\u00D7"] }));
227
- })()] }, i));
228
- }), graph.nodes.map((node) => {
229
- const p = pos.get(node.id);
230
- if (!p)
231
- return null;
232
- const x = p.x;
233
- const y = p.y + offsetY;
234
- const fill = kindColor(node.kind);
235
- const isNodeHighlighted = hasSelection ? highlightedNodes.has(node.id) : true;
236
- const nodeAlpha = isNodeHighlighted ? Math.max(0.55, node.count / maxCount) : 0.1;
237
- const nodeStrokeW = node.id === selectedNode ? 2.5 : 1.5;
238
- const nodeStrokeOpacity = isNodeHighlighted ? Math.min(1, nodeAlpha + 0.3) : 0.1;
239
- const label = node.label.length > 18 ? node.label.slice(0, 17) + '…' : node.label;
240
- return (_jsxs("g", { "data-node": "true", onClick: (e) => { e.stopPropagation(); setSelectedNode(prev => prev === node.id ? null : node.id); }, style: { cursor: 'pointer' }, children: [_jsx("rect", { x: x, y: y, width: NODE_W, height: NODE_H, rx: 6, fill: fill, fillOpacity: nodeAlpha, stroke: fill, strokeOpacity: nodeStrokeOpacity, strokeWidth: nodeStrokeW }), _jsx("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 - 5, textAnchor: "middle", fontSize: "11", fontWeight: "600", fill: "#1e293b", children: label }), _jsxs("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 + 9, textAnchor: "middle", fontSize: "9", fill: "#475569", children: [node.count, "\u00D7"] })] }, node.id));
241
- })] }) })] }));
278
+ const selectedNodeObj = selectedNode ? vNodes.find(n => n.id === selectedNode) ?? null : null;
279
+ const maxCount = Math.max(...vNodes.map(n => n.count), 1);
280
+ return (_jsxs("div", { ref: wrapperRef, className: "rounded-lg border bg-white", style: isFullscreen ? { display: 'flex', flexDirection: 'column', height: '100vh', borderRadius: 0 } : undefined, children: [_jsxs("div", { className: "px-4 py-2 border-b flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "text-xs font-semibold text-gray-600 uppercase tracking-wide", children: "Flow Graph" }), _jsxs("span", { className: "text-xs text-gray-400", children: ["(", vNodes.length, " nodes \u00B7 ", vEdges.length, " transitions)"] }), loading && _jsx("span", { className: "text-[10px] text-indigo-400 animate-pulse", children: "updating\u2026" }), isFullscreen && userFilter && (_jsx("div", { className: "flex items-center gap-1.5 text-xs text-gray-500", children: userFilter })), _jsxs("div", { className: "ml-auto flex items-center gap-3 text-[10px] text-gray-400", children: [[
281
+ ['pageview', '#10b981'],
282
+ ['click', '#f59e0b'],
283
+ ['action', '#6366f1'],
284
+ ['error', '#ef4444'],
285
+ ['other', '#94a3b8'],
286
+ ].map(([kind, color]) => (_jsxs("label", { className: "flex items-center gap-1 cursor-pointer select-none", title: `Toggle ${kind} nodes`, children: [_jsx("input", { type: "checkbox", checked: visibleKinds[kind], onChange: (e) => setVisibleKinds(v => ({ ...v, [kind]: e.target.checked })), className: "w-3 h-3 cursor-pointer", style: { accentColor: color } }), kind] }, kind))), _jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => setZoom(z => clampZoom(z * 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom in", children: "+" }), _jsx("button", { onClick: () => setZoom(z => clampZoom(z / 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom out", children: "\u2212" }), _jsx("button", { onClick: fitToScreen, className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Fit to screen", children: "\u22A1" }), _jsx("button", { onClick: () => setSelectedNode(null), disabled: !hasSelection, className: `px-1.5 py-0.5 text-xs border rounded flex items-center ${hasSelection ? 'bg-indigo-50 border-indigo-300 text-indigo-600 hover:bg-indigo-100' : 'text-gray-300 cursor-default'}`, title: "Clear selection", children: _jsx(MousePointer2, { className: "w-3 h-3" }) }), _jsx("button", { onClick: toggleFullscreen, className: "px-1.5 py-0.5 text-xs border rounded flex items-center hover:bg-gray-50", title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', children: isFullscreen ? _jsx(Minimize2, { className: "w-3 h-3" }) : _jsx(Maximize2, { className: "w-3 h-3" }) })] })] })] }), _jsxs("div", { ref: containerRef, style: { position: 'relative', height: isFullscreen ? undefined : '500px', flex: isFullscreen ? 1 : undefined, overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab' }, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block', transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom})`, transformOrigin: '0 0', userSelect: 'none' }, children: [_jsx("defs", { children: _jsx("marker", { id: "arrow", markerWidth: "8", markerHeight: "8", refX: "7", refY: "3", orient: "auto", children: _jsx("path", { d: "M0,0 L0,6 L8,3 z", fill: "#94a3b8" }) }) }), vEdges.map((edge, i) => {
287
+ const from = pos.get(edge.from);
288
+ const to = pos.get(edge.to);
289
+ if (!from || !to)
290
+ return null;
291
+ const route = buildEdgePath(from.x, from.y + offsetY, to.x, to.y + offsetY, multiSidePorts, backEdgeLoop);
292
+ const isHighlighted = hasSelection ? highlightedEdges.has(i) : true;
293
+ const edgeOpacity = isHighlighted ? Math.max(0.4, edge.count / maxCount) : 0.05;
294
+ const edgeStroke = isHighlighted && hasSelection ? '#6366f1' : '#94a3b8';
295
+ const edgeStrokeW = isHighlighted ? Math.max(1.5, Math.min(4, edge.count / maxCount * 4)) : 1;
296
+ return (_jsxs("g", { children: [_jsx("path", { d: route.d, fill: "none", stroke: edgeStroke, strokeWidth: edgeStrokeW, strokeOpacity: edgeOpacity, markerEnd: "url(#arrow)" }), (() => {
297
+ // On selection, label every connected edge (even 1×) boldly so
298
+ // occurrence counts are readable; otherwise keep the faint >1 hint.
299
+ const selectedEdge = hasSelection && isHighlighted;
300
+ const showLabel = hasSelection ? isHighlighted : edge.count > 1;
301
+ if (!showLabel)
302
+ return null;
303
+ return (_jsxs("text", { x: route.lx, y: route.ly - 4, textAnchor: "middle", fontSize: selectedEdge ? 12 : 9, fontWeight: selectedEdge ? 700 : 400, fill: selectedEdge ? '#4338ca' : '#94a3b8', opacity: selectedEdge ? 1 : edgeOpacity, stroke: selectedEdge ? '#ffffff' : undefined, strokeWidth: selectedEdge ? 3 : undefined, paintOrder: "stroke", children: [edge.count, "\u00D7"] }));
304
+ })()] }, i));
305
+ }), vNodes.map((node) => {
306
+ const p = pos.get(node.id);
307
+ if (!p)
308
+ return null;
309
+ const x = p.x;
310
+ const y = p.y + offsetY;
311
+ const fill = kindColor(node.kind);
312
+ const isNodeHighlighted = hasSelection ? highlightedNodes.has(node.id) : true;
313
+ const nodeAlpha = isNodeHighlighted ? Math.max(0.55, node.count / maxCount) : 0.1;
314
+ const nodeStrokeW = node.id === selectedNode ? 2.5 : 1.5;
315
+ const nodeStrokeOpacity = isNodeHighlighted ? Math.min(1, nodeAlpha + 0.3) : 0.1;
316
+ const label = node.label.length > 18 ? node.label.slice(0, 17) + '…' : node.label;
317
+ // Dominant page shown inline on click/action/other nodes (a pageview
318
+ // node IS its page, so the subtitle would be redundant there).
319
+ const showPage = node.kind !== 'pageview' && node.paths && node.paths.length > 0;
320
+ const topPath = showPage ? node.paths[0].path : null;
321
+ const morePages = showPage ? node.paths.length - 1 : 0;
322
+ return (_jsxs("g", { "data-node": "true", onClick: (e) => { e.stopPropagation(); setSelectedNode(prev => prev === node.id ? null : node.id); }, style: { cursor: 'pointer' }, children: [_jsxs("title", { children: [`${node.label} · ${node.count}×`, node.paths && node.paths.length > 0
323
+ ? `\nPages:\n` + node.paths.slice(0, 8).map(p => ` ${p.path} (${p.count}×)`).join('\n')
324
+ : ''] }), _jsx("rect", { x: x, y: y, width: NODE_W, height: NODE_H, rx: 6, fill: fill, fillOpacity: nodeAlpha, stroke: fill, strokeOpacity: nodeStrokeOpacity, strokeWidth: nodeStrokeW }), topPath ? (_jsxs(_Fragment, { children: [_jsx("text", { x: x + NODE_W / 2, y: y + 17, textAnchor: "middle", fontSize: "11", fontWeight: "600", fill: "#1e293b", children: label }), _jsxs("text", { x: x + NODE_W / 2, y: y + 31, textAnchor: "middle", fontSize: "8.5", fill: kindColor(node.kind), children: [shortPath(topPath), morePages > 0 ? ` +${morePages}` : ''] }), _jsxs("text", { x: x + NODE_W / 2, y: y + 46, textAnchor: "middle", fontSize: "9", fill: "#475569", children: [node.count, "\u00D7"] })] })) : (_jsxs(_Fragment, { children: [_jsx("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 - 3, textAnchor: "middle", fontSize: "11", fontWeight: "600", fill: "#1e293b", children: label }), _jsxs("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 + 12, textAnchor: "middle", fontSize: "9", fill: "#475569", children: [node.count, "\u00D7"] })] }))] }, node.id));
325
+ })] }), selectedNodeObj && (_jsxs("div", { className: "absolute top-3 left-3 max-w-xs rounded-lg border bg-white/95 shadow-sm backdrop-blur px-3 py-2", style: { pointerEvents: 'auto' }, onMouseDown: (e) => e.stopPropagation(), children: [_jsxs("div", { className: "flex items-center gap-2 mb-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm flex-shrink-0", style: { backgroundColor: kindColor(selectedNodeObj.kind) } }), _jsx("span", { className: "text-xs font-semibold text-gray-700 truncate", children: selectedNodeObj.label }), _jsxs("span", { className: "text-[10px] text-gray-400 ml-auto flex-shrink-0", children: [selectedNodeObj.count, "\u00D7"] })] }), selectedNodeObj.paths && selectedNodeObj.paths.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "text-[10px] uppercase tracking-wide text-gray-400 mb-1", children: "Pages" }), _jsx("ul", { className: "flex flex-col gap-0.5 max-h-48 overflow-auto", children: selectedNodeObj.paths.map((p) => (_jsxs("li", { className: "flex items-center gap-2 text-[11px]", children: [_jsx("span", { className: "font-mono text-gray-600 truncate", children: p.path }), _jsxs("span", { className: "text-gray-400 tabular-nums ml-auto flex-shrink-0", children: [p.count, "\u00D7"] })] }, p.path))) })] })) : (_jsx("p", { className: "text-[11px] text-gray-400", children: "No page attribution recorded." }))] }))] })] }));
242
326
  }
243
327
  function SessionDetail({ session, index, profile }) {
244
328
  const [open, setOpen] = useState(index === 0);
@@ -250,7 +334,7 @@ function SessionDetail({ session, index, profile }) {
250
334
  // ---------------------------------------------------------------------------
251
335
  // JourneyPanel — main export
252
336
  // ---------------------------------------------------------------------------
253
- export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles }) {
337
+ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles, multiSidePorts = true, backEdgeLoop = true }) {
254
338
  const [result, setResult] = useState(null);
255
339
  const [loading, setLoading] = useState(false);
256
340
  const [error, setError] = useState(null);
@@ -262,6 +346,10 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
262
346
  // Behavior tab's single-user selection).
263
347
  const [selectedUserId, setSelectedUserId] = useState('');
264
348
  const effectiveUserId = selectedUserId || userId;
349
+ // Split-by-page: ask the server to key nodes by event+page (refetch on change).
350
+ const [splitByPage, setSplitByPage] = useState(false);
351
+ // Page-flow: server returns only pageviews, linked page→page (skips clicks).
352
+ const [pageFlow, setPageFlow] = useState(false);
265
353
  function userLabel(id) {
266
354
  const p = userProfiles.get(id);
267
355
  return p?.name ?? p?.email ?? `${id.slice(0, 12)}…`;
@@ -277,6 +365,10 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
277
365
  params.set('user_id', effectiveUserId);
278
366
  if (sessionId)
279
367
  params.set('session_id', sessionId);
368
+ if (splitByPage)
369
+ params.set('split_by_page', '1');
370
+ if (pageFlow)
371
+ params.set('page_flow', '1');
280
372
  fetch(`${apiBase}/analytics/journey?${params.toString()}`, { credentials: 'include' })
281
373
  .then((r) => r.json().catch(() => null))
282
374
  .then((body) => {
@@ -313,11 +405,11 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
313
405
  setLoading(false);
314
406
  });
315
407
  return () => { cancelled = true; };
316
- }, [appId, apiBase, effectiveUserId, sessionId]);
408
+ }, [appId, apiBase, effectiveUserId, sessionId, splitByPage, pageFlow]);
317
409
  const sessionCount = result?.sessions.length ?? 0;
318
410
  const totalSteps = result?.sessions.reduce((s, sess) => s + sess.steps.length, 0) ?? 0;
319
- // Single source of truth for the user filter — rendered in the toolbar, and
320
- // passed into the flow graph so it stays reachable in graph fullscreen.
321
- const userFilterControl = knownUserIds.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("label", { className: "text-gray-500", children: "User" }), _jsxs("select", { value: selectedUserId, onChange: (e) => setSelectedUserId(e.target.value), className: "rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400", children: [_jsx("option", { value: "", children: "All users" }), knownUserIds.map((id) => (_jsx("option", { value: id, children: userLabel(id) }, id)))] })] })) : null;
322
- return (_jsxs("div", { className: "flex flex-col gap-4 p-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-4 text-xs text-gray-500", children: [_jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: sessionCount }), " session", sessionCount !== 1 ? 's' : ''] }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: totalSteps }), " total steps"] }), userFilterControl && (_jsx("div", { className: "ml-auto flex items-center gap-1.5", children: userFilterControl }))] }), error && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-700", children: error })), loading && (_jsx("div", { className: "p-4 text-xs text-gray-400", children: "Loading journey data\u2026" })), !loading && result && (_jsxs(_Fragment, { children: [result.graph.nodes.length > 0 && (_jsx(JourneyFlowGraph, { graph: result.graph, userFilter: userFilterControl })), result.sessions.length === 0 ? (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No journey sessions found." })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wide", children: "Sessions" }), result.sessions.map((session, i) => (_jsx(SessionDetail, { session: session, index: i, profile: session.user_id ? userProfiles.get(session.user_id) : undefined }, session.session_id)))] }))] })), !loading && !result && !error && (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No data yet." }))] }));
411
+ // Single source of truth for the data-fetch controls — rendered in the toolbar,
412
+ // and passed into the flow graph so they stay reachable in graph fullscreen.
413
+ const toolbarControls = (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex items-center gap-1 cursor-pointer select-none", title: "Show only page-to-page navigation (each page links to the next page visited)", children: [_jsx("input", { type: "checkbox", checked: pageFlow, onChange: (e) => setPageFlow(e.target.checked), className: "w-3 h-3 cursor-pointer", style: { accentColor: '#10b981' } }), "Pages only"] }), _jsxs("label", { className: "flex items-center gap-1 cursor-pointer select-none", title: "Split each event into one box per page", children: [_jsx("input", { type: "checkbox", checked: splitByPage, onChange: (e) => setSplitByPage(e.target.checked), className: "w-3 h-3 cursor-pointer" }), "Group by page"] }), knownUserIds.length > 0 && (_jsxs(_Fragment, { children: [_jsx("label", { className: "text-gray-500 ml-1", children: "User" }), _jsxs("select", { value: selectedUserId, onChange: (e) => setSelectedUserId(e.target.value), className: "rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400", children: [_jsx("option", { value: "", children: "All users" }), knownUserIds.map((id) => (_jsx("option", { value: id, children: userLabel(id) }, id)))] })] }))] }));
414
+ return (_jsxs("div", { className: "flex flex-col gap-4 p-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-4 text-xs text-gray-500", children: [_jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: sessionCount }), " session", sessionCount !== 1 ? 's' : ''] }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: totalSteps }), " total steps"] }), _jsx("div", { className: "ml-auto flex items-center gap-3", children: toolbarControls })] }), error && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-700", children: error })), loading && !result && (_jsx("div", { className: "p-4 text-xs text-gray-400", children: "Loading journey data\u2026" })), result && (_jsxs(_Fragment, { children: [result.graph.nodes.length > 0 && (_jsx(JourneyFlowGraph, { graph: result.graph, userFilter: toolbarControls, loading: loading, multiSidePorts: multiSidePorts, backEdgeLoop: backEdgeLoop })), result.sessions.length === 0 ? (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No journey sessions found." })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wide", children: "Sessions" }), result.sessions.map((session, i) => (_jsx(SessionDetail, { session: session, index: i, profile: session.user_id ? userProfiles.get(session.user_id) : undefined }, session.session_id)))] }))] })), !loading && !result && !error && (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No data yet." }))] }));
323
415
  }
@@ -0,0 +1,15 @@
1
+ interface UserProfile {
2
+ id: string;
3
+ name?: string;
4
+ email?: string;
5
+ avatar_url?: string;
6
+ }
7
+ export interface SearchQueriesPanelProps {
8
+ appId: string;
9
+ apiBase: string;
10
+ since?: string;
11
+ resolveUserProfiles?: (ids: string[]) => Promise<UserProfile[]>;
12
+ }
13
+ export declare function SearchQueriesPanel({ appId, apiBase, since, resolveUserProfiles }: SearchQueriesPanelProps): import("react").JSX.Element;
14
+ export {};
15
+ //# sourceMappingURL=search_queries_panel.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"search_queries_panel.d.ts","sourceRoot":"","sources":["../../src/ui/search_queries_panel.tsx"],"names":[],"mappings":"AA4BA,UAAU,WAAW;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA8CD,MAAM,WAAW,uBAAuB;IACtC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC;CACjE;AAED,wBAAgB,kBAAkB,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,uBAAuB,+BAiJzG"}
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useState, useEffect } from 'react';
4
+ // ---------------------------------------------------------------------------
5
+ // MiniBar
6
+ // ---------------------------------------------------------------------------
7
+ function MiniBar({ value, max, color = '#6366f1' }) {
8
+ const pct = max > 0 ? (value / max) * 100 : 0;
9
+ return (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("div", { className: "flex-1 h-2 rounded bg-gray-100 overflow-hidden", children: _jsx("div", { className: "h-full rounded", style: { width: `${pct}%`, backgroundColor: color } }) }), _jsx("span", { className: "text-xs tabular-nums text-gray-600 w-8 text-right", children: value })] }));
10
+ }
11
+ // ---------------------------------------------------------------------------
12
+ // SummaryCard
13
+ // ---------------------------------------------------------------------------
14
+ function SummaryCard({ label, value }) {
15
+ return (_jsxs("div", { className: "rounded-lg border bg-white p-4 flex flex-col gap-1", children: [_jsx("p", { className: "text-[10px] text-gray-400 uppercase tracking-wide", children: label }), _jsx("p", { className: "text-2xl font-semibold text-gray-800 tabular-nums", children: value })] }));
16
+ }
17
+ export function SearchQueriesPanel({ appId, apiBase, since, resolveUserProfiles }) {
18
+ const [data, setData] = useState(null);
19
+ const [profiles, setProfiles] = useState(new Map());
20
+ const [loading, setLoading] = useState(false);
21
+ const [error, setError] = useState(null);
22
+ useEffect(() => {
23
+ if (!appId)
24
+ return;
25
+ let cancelled = false;
26
+ setLoading(true);
27
+ setError(null);
28
+ const params = new URLSearchParams({ app_id: appId });
29
+ if (since)
30
+ params.set('since', since);
31
+ fetch(`${apiBase}/analytics/search-queries?${params}`, { credentials: 'include' })
32
+ .then((r) => r.json().catch(() => null))
33
+ .then((body) => {
34
+ if (cancelled)
35
+ return;
36
+ setData(body?.data ?? null);
37
+ setLoading(false);
38
+ })
39
+ .catch((err) => {
40
+ if (cancelled)
41
+ return;
42
+ setError(err instanceof Error ? err.message : 'Load error');
43
+ setLoading(false);
44
+ });
45
+ return () => { cancelled = true; };
46
+ }, [appId, apiBase, since]);
47
+ // Resolve user profiles after data loads
48
+ useEffect(() => {
49
+ if (!data || !resolveUserProfiles || data.per_user.length === 0)
50
+ return;
51
+ const ids = data.per_user.map((u) => u.user_id);
52
+ resolveUserProfiles(ids).then((resolved) => {
53
+ const map = new Map(resolved.map((p) => [p.id, p]));
54
+ setProfiles(map);
55
+ }).catch(() => { });
56
+ }, [data, resolveUserProfiles]);
57
+ if (loading)
58
+ return _jsx("div", { className: "p-4 text-xs text-gray-400", children: "Loading\u2026" });
59
+ if (error)
60
+ return _jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-700", children: error });
61
+ if (!data)
62
+ return _jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No data yet." });
63
+ const maxTermCount = data.top_terms.length > 0 ? Math.max(...data.top_terms.map((t) => t.count), 1) : 1;
64
+ const maxZeroCount = data.zero_result_terms.length > 0 ? Math.max(...data.zero_result_terms.map((t) => t.zeroResults), 1) : 1;
65
+ const maxDayCount = data.over_time.length > 0 ? Math.max(...data.over_time.map((d) => d.count), 1) : 1;
66
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-4", children: [_jsxs("div", { className: "grid grid-cols-2 md:grid-cols-4 gap-4", children: [_jsx(SummaryCard, { label: "Total Searches", value: data.total_searches }), _jsx(SummaryCard, { label: "Unique Terms", value: data.unique_terms }), _jsx(SummaryCard, { label: "Unique Searchers", value: data.unique_searchers }), _jsx(SummaryCard, { label: "Zero-Result Searches", value: data.zero_result_searches })] }), _jsxs("div", { className: "rounded-lg border bg-white p-4 flex flex-col gap-3", children: [_jsx("h3", { className: "text-sm font-semibold text-gray-700", children: "Top Search Terms" }), data.top_terms.length === 0 ? (_jsx("p", { className: "text-xs text-gray-400", children: "No searches recorded yet." })) : (_jsx("div", { className: "flex flex-col gap-1", children: data.top_terms.map((t) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-xs text-gray-600 w-32 truncate", title: t.term, children: t.term }), _jsx(MiniBar, { value: t.count, max: maxTermCount })] }, t.term))) }))] }), data.zero_result_terms.length > 0 && (_jsxs("div", { className: "rounded-lg border border-amber-200 bg-amber-50 p-4 flex flex-col gap-3", children: [_jsxs("h3", { className: "text-sm font-semibold text-amber-800", children: ["Zero-Result Terms ", _jsx("span", { className: "text-xs font-normal text-amber-600", children: "(content gaps)" })] }), _jsx("div", { className: "flex flex-col gap-1", children: data.zero_result_terms.map((t) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-xs text-amber-700 w-32 truncate", title: t.term, children: t.term }), _jsx(MiniBar, { value: t.zeroResults, max: maxZeroCount, color: "#f59e0b" })] }, t.term))) })] })), data.over_time.length > 0 && (_jsxs("div", { className: "rounded-lg border bg-white p-4 flex flex-col gap-3", children: [_jsx("h3", { className: "text-sm font-semibold text-gray-700", children: "Searches Over Time" }), _jsx("div", { className: "flex flex-col gap-1", children: data.over_time.map((d) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[10px] text-gray-400 w-20", children: d.day }), _jsx(MiniBar, { value: d.count, max: maxDayCount, color: "#10b981" })] }, d.day))) })] })), data.per_user.length > 0 && (_jsxs("div", { className: "rounded-lg border bg-white p-4 flex flex-col gap-3", children: [_jsx("h3", { className: "text-sm font-semibold text-gray-700", children: "Per-User Searches" }), _jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-xs border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { className: "text-left text-gray-400 border-b", children: [_jsx("th", { className: "py-1 pr-4 font-medium", children: "User" }), _jsx("th", { className: "py-1 pr-4 font-medium tabular-nums", children: "Searches" }), _jsx("th", { className: "py-1 pr-4 font-medium tabular-nums", children: "Distinct Terms" }), _jsx("th", { className: "py-1 font-medium", children: "Recent Terms" })] }) }), _jsx("tbody", { children: data.per_user.map((u) => {
67
+ const profile = profiles.get(u.user_id);
68
+ const displayName = profile?.name ?? profile?.email ?? u.user_id.slice(0, 8) + '…';
69
+ return (_jsxs("tr", { className: "border-b border-gray-50", children: [_jsxs("td", { className: "py-1 pr-4 text-gray-700", children: [profile?.avatar_url && (_jsx("img", { src: profile.avatar_url, alt: "", className: "inline-block w-4 h-4 rounded-full mr-1 align-middle" })), displayName] }), _jsx("td", { className: "py-1 pr-4 tabular-nums text-gray-600", children: u.count }), _jsx("td", { className: "py-1 pr-4 tabular-nums text-gray-600", children: u.distinct_terms }), _jsxs("td", { className: "py-1 text-gray-500", children: [u.terms.slice(0, 5).join(', '), u.terms.length > 5 ? '…' : ''] })] }, u.user_id));
70
+ }) })] }) })] }))] }));
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_umetrics",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",