hazo_umetrics 1.5.1 → 1.6.2

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.
@@ -5,6 +5,7 @@ export type TopEntry = {
5
5
  };
6
6
  export type ActivitySummary = {
7
7
  activeUsers: number;
8
+ activeUserIds: string[];
8
9
  pageViews: number;
9
10
  clicks: number;
10
11
  topPages: TopEntry[];
@@ -1 +1 @@
1
- {"version":3,"file":"activity.d.ts","sourceRoot":"","sources":["../../src/server/activity.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAsB9D,MAAM,MAAM,QAAQ,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,MAAM,MAAM,eAAe,GAAG;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,UAAU,EAAE,QAAQ,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AA8DvD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,eAAe,CAAC,CAoC1B;AAMD;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBvB"}
1
+ {"version":3,"file":"activity.d.ts","sourceRoot":"","sources":["../../src/server/activity.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAsB9D,MAAM,MAAM,QAAQ,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AAExD,MAAM,MAAM,eAAe,GAAG;IAC5B,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,QAAQ,EAAE,CAAC;IACrB,UAAU,EAAE,QAAQ,EAAE,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC;AA8DvD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,eAAe,CAAC,CAwC1B;AAMD;;GAEG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,UAAU,EAAE,CAAC,CAqBvB"}
@@ -63,6 +63,7 @@ export async function getActivitySummary(adapter, params) {
63
63
  const rows = await fetchWindow(adapter, { app_id: params.app_id, scope_id: params.scope_id, since, cap });
64
64
  const uiRows = rows.filter((r) => r.metric_key.startsWith('ui.'));
65
65
  const sessions = new Set();
66
+ const userIds = new Set();
66
67
  const pageTally = new Map();
67
68
  const btnTally = new Map();
68
69
  let pageViews = 0;
@@ -72,6 +73,9 @@ export async function getActivitySummary(adapter, params) {
72
73
  const sid = dims.session_id;
73
74
  if (sid)
74
75
  sessions.add(sid);
76
+ const uid = dims.user_id;
77
+ if (uid)
78
+ userIds.add(uid);
75
79
  if (row.metric_key.startsWith('ui.pageview.')) {
76
80
  pageViews++;
77
81
  const path = dims.path ?? row.metric_key.replace('ui.pageview.', '/');
@@ -85,6 +89,7 @@ export async function getActivitySummary(adapter, params) {
85
89
  }
86
90
  return {
87
91
  activeUsers: sessions.size,
92
+ activeUserIds: Array.from(userIds),
88
93
  pageViews,
89
94
  clicks,
90
95
  topPages: topN(pageTally, 5),
@@ -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;AA0QD,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,CAAC;IAChD,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,CAAC;IAChD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,KAAK,EAAE,YAAY,CAAC;CACrB;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;CAC3I;AAiaD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,EAAE,iBAAiB,+BAoJzG"}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useState, useEffect } from 'react';
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { MousePointer2, Maximize2, Minimize2 } from 'lucide-react';
4
5
  // ---------------------------------------------------------------------------
5
6
  // Helpers
6
7
  // ---------------------------------------------------------------------------
@@ -21,6 +22,7 @@ function kindColor(kind) {
21
22
  default: return '#94a3b8';
22
23
  }
23
24
  }
25
+ function clampZoom(z) { return Math.max(0.25, Math.min(3, z)); }
24
26
  // ---------------------------------------------------------------------------
25
27
  // JourneyFlowGraph — SVG left-to-right DAG visualization
26
28
  // ---------------------------------------------------------------------------
@@ -101,7 +103,51 @@ function computeFlowLayout(nodes, edges) {
101
103
  }
102
104
  return pos;
103
105
  }
104
- function JourneyFlowGraph({ graph }) {
106
+ function JourneyFlowGraph({ graph, userFilter }) {
107
+ const [zoom, setZoom] = useState(1.0);
108
+ const [pan, setPan] = useState({ x: 0, y: 0 });
109
+ const [dragging, setDragging] = useState(false);
110
+ const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
111
+ const [selectedNode, setSelectedNode] = useState(null);
112
+ const [isFullscreen, setIsFullscreen] = useState(false);
113
+ const wrapperRef = useRef(null);
114
+ const containerRef = useRef(null);
115
+ // Holds latest fitToScreen so the fullscreenchange effect can call it without
116
+ // re-subscribing every render (fitToScreen closes over per-render svg dims).
117
+ const fitToScreenRef = useRef(null);
118
+ // Track native fullscreen state (covers Esc-to-exit and the toggle button alike).
119
+ useEffect(() => {
120
+ const onFsChange = () => {
121
+ const active = document.fullscreenElement === wrapperRef.current;
122
+ setIsFullscreen(active);
123
+ // Refit once the card has resized to fill the screen.
124
+ if (active)
125
+ requestAnimationFrame(() => fitToScreenRef.current?.());
126
+ };
127
+ document.addEventListener('fullscreenchange', onFsChange);
128
+ return () => document.removeEventListener('fullscreenchange', onFsChange);
129
+ }, []);
130
+ function toggleFullscreen() {
131
+ if (document.fullscreenElement) {
132
+ document.exitFullscreen().catch(() => { });
133
+ }
134
+ else {
135
+ wrapperRef.current?.requestFullscreen().catch(() => { });
136
+ }
137
+ }
138
+ // Non-passive wheel listener so preventDefault() actually suppresses page scroll.
139
+ // React 17+ registers onWheel as passive, which silently ignores preventDefault.
140
+ useEffect(() => {
141
+ const el = containerRef.current;
142
+ if (!el)
143
+ return;
144
+ const onWheel = (e) => {
145
+ e.preventDefault();
146
+ setZoom(z => clampZoom(z * (e.deltaY < 0 ? 1.1 : 0.9)));
147
+ };
148
+ el.addEventListener('wheel', onWheel, { passive: false });
149
+ return () => el.removeEventListener('wheel', onWheel);
150
+ }, []);
105
151
  if (graph.nodes.length === 0)
106
152
  return null;
107
153
  const pos = computeFlowLayout(graph.nodes, graph.edges);
@@ -120,8 +166,43 @@ function JourneyFlowGraph({ graph }) {
120
166
  const offsetY = minY < 0 ? -minY + PAD : PAD;
121
167
  const svgH = maxY - minY + PAD * 2;
122
168
  const svgW = maxX;
169
+ function handleMouseDown(e) {
170
+ if (e.target.closest('[data-node]'))
171
+ return;
172
+ setDragging(true);
173
+ setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
174
+ }
175
+ function handleMouseMove(e) {
176
+ if (!dragging)
177
+ return;
178
+ setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
179
+ }
180
+ function handleMouseUp() { setDragging(false); }
181
+ function fitToScreen() {
182
+ if (!containerRef.current)
183
+ return;
184
+ const cw = containerRef.current.clientWidth;
185
+ const ch = containerRef.current.clientHeight;
186
+ const scale = clampZoom(Math.min(cw / svgW, ch / svgH) * 0.9);
187
+ setZoom(scale);
188
+ setPan({ x: (cw - svgW * scale) / 2, y: (ch - svgH * scale) / 2 });
189
+ }
190
+ fitToScreenRef.current = fitToScreen;
191
+ const highlightedEdges = new Set();
192
+ const highlightedNodes = new Set();
193
+ if (selectedNode) {
194
+ highlightedNodes.add(selectedNode);
195
+ graph.edges.forEach((edge, i) => {
196
+ if (edge.from === selectedNode || edge.to === selectedNode) {
197
+ highlightedEdges.add(i);
198
+ highlightedNodes.add(edge.from);
199
+ highlightedNodes.add(edge.to);
200
+ }
201
+ });
202
+ }
203
+ const hasSelection = selectedNode !== null;
123
204
  const maxCount = Math.max(...graph.nodes.map(n => n.count), 1);
124
- return (_jsxs("div", { className: "rounded-lg border bg-white overflow-x-auto", 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"] })] })] }), _jsx("div", { style: { overflowX: 'auto' }, children: _jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block' }, 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) => {
205
+ return (_jsxs("div", { ref: wrapperRef, className: "rounded-lg border bg-white", style: isFullscreen ? { display: 'flex', flexDirection: 'column', height: '100vh', borderRadius: 0 } : undefined, children: [_jsxs("div", { className: "px-4 py-2 border-b flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "text-xs font-semibold text-gray-600 uppercase tracking-wide", children: "Flow Graph" }), _jsxs("span", { className: "text-xs text-gray-400", children: ["(", graph.nodes.length, " nodes \u00B7 ", graph.edges.length, " transitions)"] }), isFullscreen && userFilter && (_jsx("div", { className: "flex items-center gap-1.5 text-xs text-gray-500", children: userFilter })), _jsxs("div", { className: "ml-auto flex items-center gap-3 text-[10px] text-gray-400", children: [_jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#6366f1' } }), " action"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#10b981' } }), " pageview"] }), _jsxs("span", { className: "flex items-center gap-1", children: [_jsx("span", { className: "inline-block w-2 h-2 rounded-sm", style: { backgroundColor: '#ef4444' } }), " error"] }), _jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => setZoom(z => clampZoom(z * 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom in", children: "+" }), _jsx("button", { onClick: () => setZoom(z => clampZoom(z / 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom out", children: "\u2212" }), _jsx("button", { onClick: fitToScreen, className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Fit to screen", children: "\u22A1" }), _jsx("button", { onClick: () => setSelectedNode(null), disabled: !hasSelection, className: `px-1.5 py-0.5 text-xs border rounded flex items-center ${hasSelection ? 'bg-indigo-50 border-indigo-300 text-indigo-600 hover:bg-indigo-100' : 'text-gray-300 cursor-default'}`, title: "Clear selection", children: _jsx(MousePointer2, { className: "w-3 h-3" }) }), _jsx("button", { onClick: toggleFullscreen, className: "px-1.5 py-0.5 text-xs border rounded flex items-center hover:bg-gray-50", title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', children: isFullscreen ? _jsx(Minimize2, { className: "w-3 h-3" }) : _jsx(Maximize2, { className: "w-3 h-3" }) })] })] })] }), _jsx("div", { ref: containerRef, style: { position: 'relative', height: isFullscreen ? undefined : '500px', flex: isFullscreen ? 1 : undefined, overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab' }, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: _jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block', transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom})`, transformOrigin: '0 0', userSelect: 'none' }, children: [_jsx("defs", { children: _jsx("marker", { id: "arrow", markerWidth: "8", markerHeight: "8", refX: "7", refY: "3", orient: "auto", children: _jsx("path", { d: "M0,0 L0,6 L8,3 z", fill: "#94a3b8" }) }) }), graph.edges.map((edge, i) => {
125
206
  const from = pos.get(edge.from);
126
207
  const to = pos.get(edge.to);
127
208
  if (!from || !to)
@@ -131,9 +212,19 @@ function JourneyFlowGraph({ graph }) {
131
212
  const x2 = to.x;
132
213
  const y2 = to.y + offsetY + NODE_H / 2;
133
214
  const cx = (x1 + x2) / 2;
134
- const opacity = Math.max(0.2, edge.count / maxCount);
135
- const strokeW = Math.max(1, Math.min(4, edge.count / maxCount * 4));
136
- return (_jsxs("g", { children: [_jsx("path", { d: `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`, fill: "none", stroke: "#94a3b8", strokeWidth: strokeW, strokeOpacity: opacity, markerEnd: "url(#arrow)" }), edge.count > 1 && (_jsxs("text", { x: cx, y: (y1 + y2) / 2 - 4, textAnchor: "middle", fontSize: "9", fill: "#94a3b8", opacity: opacity, children: [edge.count, "\u00D7"] }))] }, i));
215
+ const isHighlighted = hasSelection ? highlightedEdges.has(i) : true;
216
+ const edgeOpacity = isHighlighted ? Math.max(0.4, edge.count / maxCount) : 0.05;
217
+ const edgeStroke = isHighlighted && hasSelection ? '#6366f1' : '#94a3b8';
218
+ const edgeStrokeW = isHighlighted ? Math.max(1.5, Math.min(4, edge.count / maxCount * 4)) : 1;
219
+ return (_jsxs("g", { children: [_jsx("path", { d: `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`, fill: "none", stroke: edgeStroke, strokeWidth: edgeStrokeW, strokeOpacity: edgeOpacity, markerEnd: "url(#arrow)" }), (() => {
220
+ // On selection, label every connected edge (even 1×) boldly so
221
+ // occurrence counts are readable; otherwise keep the faint >1 hint.
222
+ const selectedEdge = hasSelection && isHighlighted;
223
+ const showLabel = hasSelection ? isHighlighted : edge.count > 1;
224
+ if (!showLabel)
225
+ return null;
226
+ return (_jsxs("text", { x: cx, y: (y1 + y2) / 2 - 4, textAnchor: "middle", fontSize: selectedEdge ? 12 : 9, fontWeight: selectedEdge ? 700 : 400, fill: selectedEdge ? '#4338ca' : '#94a3b8', opacity: selectedEdge ? 1 : edgeOpacity, stroke: selectedEdge ? '#ffffff' : undefined, strokeWidth: selectedEdge ? 3 : undefined, paintOrder: "stroke", children: [edge.count, "\u00D7"] }));
227
+ })()] }, i));
137
228
  }), graph.nodes.map((node) => {
138
229
  const p = pos.get(node.id);
139
230
  if (!p)
@@ -141,9 +232,12 @@ function JourneyFlowGraph({ graph }) {
141
232
  const x = p.x;
142
233
  const y = p.y + offsetY;
143
234
  const fill = kindColor(node.kind);
144
- const alpha = Math.max(0.25, node.count / maxCount);
235
+ const isNodeHighlighted = hasSelection ? highlightedNodes.has(node.id) : true;
236
+ const nodeAlpha = isNodeHighlighted ? Math.max(0.55, node.count / maxCount) : 0.1;
237
+ const nodeStrokeW = node.id === selectedNode ? 2.5 : 1.5;
238
+ const nodeStrokeOpacity = isNodeHighlighted ? Math.min(1, nodeAlpha + 0.3) : 0.1;
145
239
  const label = node.label.length > 18 ? node.label.slice(0, 17) + '…' : node.label;
146
- return (_jsxs("g", { children: [_jsx("rect", { x: x, y: y, width: NODE_W, height: NODE_H, rx: 6, fill: fill, fillOpacity: alpha, stroke: fill, strokeOpacity: Math.min(1, alpha + 0.3), strokeWidth: 1.5 }), _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));
240
+ return (_jsxs("g", { "data-node": "true", onClick: (e) => { e.stopPropagation(); setSelectedNode(prev => prev === node.id ? null : node.id); }, style: { cursor: 'pointer' }, children: [_jsx("rect", { x: x, y: y, width: NODE_W, height: NODE_H, rx: 6, fill: fill, fillOpacity: nodeAlpha, stroke: fill, strokeOpacity: nodeStrokeOpacity, strokeWidth: nodeStrokeW }), _jsx("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 - 5, textAnchor: "middle", fontSize: "11", fontWeight: "600", fill: "#1e293b", children: label }), _jsxs("text", { x: x + NODE_W / 2, y: y + NODE_H / 2 + 9, textAnchor: "middle", fontSize: "9", fill: "#475569", children: [node.count, "\u00D7"] })] }, node.id));
147
241
  })] }) })] }));
148
242
  }
149
243
  function SessionDetail({ session, index, profile }) {
@@ -222,5 +316,8 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
222
316
  }, [appId, apiBase, effectiveUserId, sessionId]);
223
317
  const sessionCount = result?.sessions.length ?? 0;
224
318
  const totalSteps = result?.sessions.reduce((s, sess) => s + sess.steps.length, 0) ?? 0;
225
- 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." }))] }));
319
+ // Single source of truth for the user filter rendered in the toolbar, and
320
+ // passed into the flow graph so it stays reachable in graph fullscreen.
321
+ const userFilterControl = knownUserIds.length > 0 ? (_jsxs(_Fragment, { children: [_jsx("label", { className: "text-gray-500", children: "User" }), _jsxs("select", { value: selectedUserId, onChange: (e) => setSelectedUserId(e.target.value), className: "rounded border px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-indigo-400", children: [_jsx("option", { value: "", children: "All users" }), knownUserIds.map((id) => (_jsx("option", { value: id, children: userLabel(id) }, id)))] })] })) : null;
322
+ return (_jsxs("div", { className: "flex flex-col gap-4 p-4", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-4 text-xs text-gray-500", children: [_jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: sessionCount }), " session", sessionCount !== 1 ? 's' : ''] }), _jsxs("span", { children: [_jsx("span", { className: "font-semibold text-gray-700", children: totalSteps }), " total steps"] }), userFilterControl && (_jsx("div", { className: "ml-auto flex items-center gap-1.5", children: userFilterControl }))] }), error && (_jsx("div", { className: "rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-700", children: error })), loading && (_jsx("div", { className: "p-4 text-xs text-gray-400", children: "Loading journey data\u2026" })), !loading && result && (_jsxs(_Fragment, { children: [result.graph.nodes.length > 0 && (_jsx(JourneyFlowGraph, { graph: result.graph, userFilter: userFilterControl })), result.sessions.length === 0 ? (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No journey sessions found." })) : (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsx("h3", { className: "text-xs font-semibold text-gray-500 uppercase tracking-wide", children: "Sessions" }), result.sessions.map((session, i) => (_jsx(SessionDetail, { session: session, index: i, profile: session.user_id ? userProfiles.get(session.user_id) : undefined }, session.session_id)))] }))] })), !loading && !result && !error && (_jsx("div", { className: "rounded-lg border p-6 text-center text-sm text-gray-400", children: "No data yet." }))] }));
226
323
  }
@@ -1,3 +1,4 @@
1
+ import type { UserProfileInfo } from './analytics_panel.js';
1
2
  export interface StatCardsProps {
2
3
  stats: Array<{
3
4
  key: string;
@@ -28,6 +29,7 @@ export interface ExperimentListProps {
28
29
  export declare function ExperimentList({ experiments }: ExperimentListProps): import("react").JSX.Element;
29
30
  export interface MetricsPanelProps {
30
31
  appId: string;
32
+ resolveUserProfiles?: (ids: string[]) => Promise<UserProfileInfo[]>;
31
33
  }
32
- export declare function MetricsPanel({ appId }: MetricsPanelProps): import("react").JSX.Element;
34
+ export declare function MetricsPanel({ appId, resolveUserProfiles }: MetricsPanelProps): import("react").JSX.Element;
33
35
  //# sourceMappingURL=metrics_panel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"metrics_panel.d.ts","sourceRoot":"","sources":["../../src/ui/metrics_panel.tsx"],"names":[],"mappings":"AA8DA,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5F;AAWD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,cAAc,+BAkBlD;AAMD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAQD,wBAAgB,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,cAAc,+BAmBxD;AA4CD,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnG;AAcD,wBAAgB,cAAc,CAAC,EAAE,WAAW,EAAE,EAAE,mBAAmB,+BAwBlE;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;CACf;AAwBD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,EAAE,iBAAiB,+BA0JxD"}
1
+ {"version":3,"file":"metrics_panel.d.ts","sourceRoot":"","sources":["../../src/ui/metrics_panel.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AA2D5D,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAC5F;AAWD,wBAAgB,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,cAAc,+BAkBlD;AAMD,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,KAAK,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAQD,wBAAgB,SAAS,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,cAAc,+BAmBxD;AA4CD,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACnG;AAcD,wBAAgB,cAAc,CAAC,EAAE,WAAW,EAAE,EAAE,mBAAmB,+BAwBlE;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;CACrE;AAwBD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,mBAAmB,EAAE,EAAE,iBAAiB,+BAyM7E"}
@@ -64,10 +64,13 @@ const INITIAL_STATE = {
64
64
  topButtons: [],
65
65
  experiments: [],
66
66
  };
67
- export function MetricsPanel({ appId }) {
67
+ export function MetricsPanel({ appId, resolveUserProfiles }) {
68
68
  const [state, setState] = useState(INITIAL_STATE);
69
69
  const [refreshKey, setRefreshKey] = useState(0);
70
70
  const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
71
+ const [activeUserIds, setActiveUserIds] = useState([]);
72
+ const [userProfiles, setUserProfiles] = useState([]);
73
+ const [showUsers, setShowUsers] = useState(false);
71
74
  useEffect(() => {
72
75
  if (!appId)
73
76
  return;
@@ -106,6 +109,19 @@ export function MetricsPanel({ appId }) {
106
109
  ];
107
110
  topPages = summary.topPages ?? [];
108
111
  topButtons = summary.topButtons ?? [];
112
+ const ids = summary.activeUserIds ?? [];
113
+ if (!cancelled) {
114
+ setActiveUserIds(ids);
115
+ if (resolveUserProfiles && ids.length > 0) {
116
+ resolveUserProfiles(ids).then(profiles => {
117
+ if (!cancelled)
118
+ setUserProfiles(profiles);
119
+ }).catch(() => { });
120
+ }
121
+ else {
122
+ setUserProfiles([]);
123
+ }
124
+ }
109
125
  }
110
126
  trend = actBody?.data?.trend ?? [];
111
127
  }
@@ -120,7 +136,7 @@ export function MetricsPanel({ appId }) {
120
136
  }
121
137
  });
122
138
  return () => { cancelled = true; };
123
- }, [appId, refreshKey]);
139
+ }, [appId, refreshKey, resolveUserProfiles]);
124
140
  // -------------------------------------------------------------------------
125
141
  // Render states
126
142
  // -------------------------------------------------------------------------
@@ -136,5 +152,13 @@ export function MetricsPanel({ appId }) {
136
152
  // -------------------------------------------------------------------------
137
153
  // Main render
138
154
  // -------------------------------------------------------------------------
139
- return (_jsxs("div", { className: "flex flex-col gap-6 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", {}), _jsxs("button", { onClick: refresh, className: "flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-muted transition-colors", title: "Refresh metrics", children: [_jsx(RotateCw, { className: "h-3.5 w-3.5" }), "Refresh"] })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Activity (last 14 days)" }), _jsx(StatCards, { stats: state.stats })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Active Users Trend" }), _jsx(StatTrend, { data: state.trend, label: "Active Users" })] }), _jsxs("div", { className: "grid grid-cols-1 gap-6 sm:grid-cols-2", children: [_jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Top Pages" }), _jsx(TopList, { items: state.topPages, empty: "No page views yet" })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Top Buttons" }), _jsx(TopList, { items: state.topButtons, empty: "No clicks yet" })] })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Experiments & Flags" }), _jsx(ExperimentList, { experiments: state.experiments })] })] }));
155
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", {}), _jsxs("button", { onClick: refresh, className: "flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-muted transition-colors", title: "Refresh metrics", children: [_jsx(RotateCw, { className: "h-3.5 w-3.5" }), "Refresh"] })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Activity (last 14 days)" }), _jsx(StatCards, { stats: state.stats }), activeUserIds.length > 0 && (_jsxs("div", { className: "mt-2", children: [_jsx("button", { onClick: () => setShowUsers((v) => !v), className: "text-xs text-indigo-500 hover:underline", children: showUsers ? 'Hide users' : 'View users' }), showUsers && (_jsxs("div", { className: "mt-3 space-y-2", children: [userProfiles.map((p) => (_jsxs("div", { className: "flex items-center gap-2 text-sm", children: [p.profile_picture_url
156
+ ? _jsx("img", { src: p.profile_picture_url, className: "w-7 h-7 rounded-full", alt: "" })
157
+ : _jsx("div", { className: "w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-xs text-gray-500", children: (p.name || p.email || '?')[0].toUpperCase() }), _jsxs("div", { children: [_jsx("div", { className: "font-medium text-gray-800", children: p.name ?? p.email ?? p.user_id }), p.name && p.email && _jsx("div", { className: "text-xs text-gray-400", children: p.email })] })] }, p.user_id))), (() => {
158
+ const totalSessions = state.stats.find((s) => s.key === 'active_users')?.value ?? 0;
159
+ const anonSessions = Math.max(0, totalSessions - userProfiles.length);
160
+ return anonSessions > 0
161
+ ? _jsxs("div", { className: "text-xs text-gray-400 pl-9", children: [anonSessions, " anonymous session", anonSessions > 1 ? 's' : ''] })
162
+ : null;
163
+ })()] }))] }))] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Active Users Trend" }), _jsx(StatTrend, { data: state.trend, label: "Active Users" })] }), _jsxs("div", { className: "grid grid-cols-1 gap-6 sm:grid-cols-2", children: [_jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Top Pages" }), _jsx(TopList, { items: state.topPages, empty: "No page views yet" })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Top Buttons" }), _jsx(TopList, { items: state.topButtons, empty: "No clicks yet" })] })] }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Experiments & Flags" }), _jsx(ExperimentList, { experiments: state.experiments })] })] }));
140
164
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_umetrics",
3
- "version": "1.5.1",
3
+ "version": "1.6.2",
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",