hazo_umetrics 1.6.1 → 1.6.3

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.
@@ -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;AAuC9D,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;EAuBpD"}
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
@@ -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,7 @@ 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 });
875
877
  return jsonOk(result);
876
878
  },
877
879
  };
@@ -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;
@@ -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 }))
@@ -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":"AAgBA,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;AA+VD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,EAAE,iBAAiB,+BA8IzG"}
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"}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useState, useEffect, useRef } from 'react';
4
+ import { MousePointer2, Maximize2, Minimize2 } from 'lucide-react';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Helpers
6
7
  // ---------------------------------------------------------------------------
@@ -17,6 +18,7 @@ function kindColor(kind) {
17
18
  switch (kind) {
18
19
  case 'action': return '#6366f1';
19
20
  case 'pageview': return '#10b981';
21
+ case 'click': return '#f59e0b';
20
22
  case 'error': return '#ef4444';
21
23
  default: return '#94a3b8';
22
24
  }
@@ -26,7 +28,14 @@ function clampZoom(z) { return Math.max(0.25, Math.min(3, z)); }
26
28
  // JourneyFlowGraph — SVG left-to-right DAG visualization
27
29
  // ---------------------------------------------------------------------------
28
30
  const NODE_W = 148;
29
- 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
+ }
30
39
  const COL_GAP = 200;
31
40
  const ROW_GAP = 64;
32
41
  const PAD = 24;
@@ -102,13 +111,100 @@ function computeFlowLayout(nodes, edges) {
102
111
  }
103
112
  return pos;
104
113
  }
105
- function JourneyFlowGraph({ graph }) {
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 }) {
106
174
  const [zoom, setZoom] = useState(1.0);
107
175
  const [pan, setPan] = useState({ x: 0, y: 0 });
108
176
  const [dragging, setDragging] = useState(false);
109
177
  const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
110
178
  const [selectedNode, setSelectedNode] = useState(null);
179
+ const [isFullscreen, setIsFullscreen] = useState(false);
180
+ const [visibleKinds, setVisibleKinds] = useState({
181
+ action: true, pageview: true, click: true, error: true, other: true,
182
+ });
183
+ const wrapperRef = useRef(null);
111
184
  const containerRef = useRef(null);
185
+ // Holds latest fitToScreen so the fullscreenchange effect can call it without
186
+ // re-subscribing every render (fitToScreen closes over per-render svg dims).
187
+ const fitToScreenRef = useRef(null);
188
+ // Track native fullscreen state (covers Esc-to-exit and the toggle button alike).
189
+ useEffect(() => {
190
+ const onFsChange = () => {
191
+ const active = document.fullscreenElement === wrapperRef.current;
192
+ setIsFullscreen(active);
193
+ // Refit once the card has resized to fill the screen.
194
+ if (active)
195
+ requestAnimationFrame(() => fitToScreenRef.current?.());
196
+ };
197
+ document.addEventListener('fullscreenchange', onFsChange);
198
+ return () => document.removeEventListener('fullscreenchange', onFsChange);
199
+ }, []);
200
+ function toggleFullscreen() {
201
+ if (document.fullscreenElement) {
202
+ document.exitFullscreen().catch(() => { });
203
+ }
204
+ else {
205
+ wrapperRef.current?.requestFullscreen().catch(() => { });
206
+ }
207
+ }
112
208
  // Non-passive wheel listener so preventDefault() actually suppresses page scroll.
113
209
  // React 17+ registers onWheel as passive, which silently ignores preventDefault.
114
210
  useEffect(() => {
@@ -124,7 +220,11 @@ function JourneyFlowGraph({ graph }) {
124
220
  }, []);
125
221
  if (graph.nodes.length === 0)
126
222
  return null;
127
- 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);
128
228
  // Compute SVG dimensions
129
229
  let maxX = 0;
130
230
  let maxY = 0;
@@ -161,11 +261,12 @@ function JourneyFlowGraph({ graph }) {
161
261
  setZoom(scale);
162
262
  setPan({ x: (cw - svgW * scale) / 2, y: (ch - svgH * scale) / 2 });
163
263
  }
264
+ fitToScreenRef.current = fitToScreen;
164
265
  const highlightedEdges = new Set();
165
266
  const highlightedNodes = new Set();
166
267
  if (selectedNode) {
167
268
  highlightedNodes.add(selectedNode);
168
- graph.edges.forEach((edge, i) => {
269
+ vEdges.forEach((edge, i) => {
169
270
  if (edge.from === selectedNode || edge.to === selectedNode) {
170
271
  highlightedEdges.add(i);
171
272
  highlightedNodes.add(edge.from);
@@ -174,37 +275,54 @@ function JourneyFlowGraph({ graph }) {
174
275
  });
175
276
  }
176
277
  const hasSelection = selectedNode !== null;
177
- const maxCount = Math.max(...graph.nodes.map(n => n.count), 1);
178
- return (_jsxs("div", { className: "rounded-lg border bg-white", children: [_jsxs("div", { className: "px-4 py-2 border-b flex items-center gap-2", 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)"] }), _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("div", { ref: containerRef, style: { position: 'relative', height: '500px', 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' }, onClick: (e) => { if (e.target === e.currentTarget)
179
- setSelectedNode(null); }, 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) => {
180
- const from = pos.get(edge.from);
181
- const to = pos.get(edge.to);
182
- if (!from || !to)
183
- return null;
184
- const x1 = from.x + NODE_W;
185
- const y1 = from.y + offsetY + NODE_H / 2;
186
- const x2 = to.x;
187
- const y2 = to.y + offsetY + NODE_H / 2;
188
- const cx = (x1 + x2) / 2;
189
- const isHighlighted = hasSelection ? highlightedEdges.has(i) : true;
190
- const edgeOpacity = isHighlighted ? Math.max(0.4, edge.count / maxCount) : 0.05;
191
- const edgeStroke = isHighlighted && hasSelection ? '#6366f1' : '#94a3b8';
192
- const edgeStrokeW = isHighlighted ? Math.max(1.5, Math.min(4, edge.count / maxCount * 4)) : 1;
193
- 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)" }), edge.count > 1 && (_jsxs("text", { x: cx, y: (y1 + y2) / 2 - 4, textAnchor: "middle", fontSize: "9", fill: "#94a3b8", opacity: edgeOpacity, children: [edge.count, "\u00D7"] }))] }, i));
194
- }), graph.nodes.map((node) => {
195
- const p = pos.get(node.id);
196
- if (!p)
197
- return null;
198
- const x = p.x;
199
- const y = p.y + offsetY;
200
- const fill = kindColor(node.kind);
201
- const isNodeHighlighted = hasSelection ? highlightedNodes.has(node.id) : true;
202
- const nodeAlpha = isNodeHighlighted ? Math.max(0.55, node.count / maxCount) : 0.1;
203
- const nodeStrokeW = node.id === selectedNode ? 2.5 : 1.5;
204
- const nodeStrokeOpacity = isNodeHighlighted ? Math.min(1, nodeAlpha + 0.3) : 0.1;
205
- const label = node.label.length > 18 ? node.label.slice(0, 17) + '…' : node.label;
206
- 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));
207
- })] }) })] }));
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." }))] }))] })] }));
208
326
  }
209
327
  function SessionDetail({ session, index, profile }) {
210
328
  const [open, setOpen] = useState(index === 0);
@@ -216,7 +334,7 @@ function SessionDetail({ session, index, profile }) {
216
334
  // ---------------------------------------------------------------------------
217
335
  // JourneyPanel — main export
218
336
  // ---------------------------------------------------------------------------
219
- export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles }) {
337
+ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserProfiles, multiSidePorts = true, backEdgeLoop = true }) {
220
338
  const [result, setResult] = useState(null);
221
339
  const [loading, setLoading] = useState(false);
222
340
  const [error, setError] = useState(null);
@@ -228,6 +346,10 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
228
346
  // Behavior tab's single-user selection).
229
347
  const [selectedUserId, setSelectedUserId] = useState('');
230
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);
231
353
  function userLabel(id) {
232
354
  const p = userProfiles.get(id);
233
355
  return p?.name ?? p?.email ?? `${id.slice(0, 12)}…`;
@@ -243,6 +365,10 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
243
365
  params.set('user_id', effectiveUserId);
244
366
  if (sessionId)
245
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');
246
372
  fetch(`${apiBase}/analytics/journey?${params.toString()}`, { credentials: 'include' })
247
373
  .then((r) => r.json().catch(() => null))
248
374
  .then((body) => {
@@ -279,8 +405,11 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
279
405
  setLoading(false);
280
406
  });
281
407
  return () => { cancelled = true; };
282
- }, [appId, apiBase, effectiveUserId, sessionId]);
408
+ }, [appId, apiBase, effectiveUserId, sessionId, splitByPage, pageFlow]);
283
409
  const sessionCount = result?.sessions.length ?? 0;
284
410
  const totalSteps = result?.sessions.reduce((s, sess) => s + sess.steps.length, 0) ?? 0;
285
- 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"] }), knownUserIds.length > 0 && (_jsxs("div", { className: "ml-auto flex items-center gap-1.5", 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)))] })] }))] }), 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 })), 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." }))] }));
286
415
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_umetrics",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
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",