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 +36 -0
- package/dist/api/index.d.ts +5 -0
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +29 -2
- package/dist/index.client.d.ts +4 -0
- package/dist/index.client.d.ts.map +1 -1
- package/dist/index.client.js +3 -0
- package/dist/server/behavior.d.ts +40 -2
- package/dist/server/behavior.d.ts.map +1 -1
- package/dist/server/behavior.js +145 -17
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/journey_panel.d.ts +12 -3
- package/dist/ui/journey_panel.d.ts.map +1 -1
- package/dist/ui/journey_panel.js +140 -48
- package/dist/ui/search_queries_panel.d.ts +15 -0
- package/dist/ui/search_queries_panel.d.ts.map +1 -0
- package/dist/ui/search_queries_panel.js +71 -0
- package/package.json +1 -1
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):**
|
package/dist/api/index.d.ts
CHANGED
|
@@ -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
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
|
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
|
};
|
package/dist/index.client.d.ts
CHANGED
|
@@ -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"}
|
package/dist/index.client.js
CHANGED
|
@@ -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":"AAWA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAsB7D,MAAM,MAAM,WAAW,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AACzE,MAAM,MAAM,UAAU,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAC3D,MAAM,MAAM,cAAc,GAAG;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAM7F,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IACpC,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,aAAa,CAAC,EAAE,OAAO,CAAC;CACzB;AAqED;;GAEG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CAgCxB;AAMD;;GAEG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CAkDxB;AAMD;;;GAGG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GAAG;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GACvD,OAAO,CAAC,WAAW,EAAE,CAAC,CAmCxB;AAMD;;GAEG;AACH,wBAAsB,SAAS,CAC7B,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CAexB;AAMD;;GAEG;AACH,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,UAAU,EAAE,CAAC,CAmBvB;AAMD;;GAEG;AACH,wBAAsB,wBAAwB,CAC5C,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CA8BxB;AAMD;;GAEG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,WAAW,EAAE,CAAC,CAgBxB;AAMD;;;GAGG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,kBAAkB,EAC3B,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,cAAc,EAAE,CAAC,CAoC3B;AAMD,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"behavior.d.ts","sourceRoot":"","sources":["../../src/server/behavior.ts"],"names":[],"mappings":"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"}
|
package/dist/server/behavior.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
432
|
-
if (
|
|
433
|
-
|
|
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(
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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:
|
|
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
|
-
|
|
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]) => ({
|
|
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
|
-
|
|
479
|
-
|
|
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
|
+
}
|
package/dist/ui/index.d.ts
CHANGED
package/dist/ui/index.d.ts.map
CHANGED
|
@@ -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
|
@@ -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;
|
|
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"}
|
package/dist/ui/journey_panel.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
320
|
-
// passed into the flow graph so
|
|
321
|
-
const
|
|
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"] }),
|
|
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
|
+
}
|