hazo_umetrics 1.4.0 → 1.6.1
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 +48 -0
- package/README.md +40 -4
- package/dist/api/index.d.ts +17 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +8 -2
- package/dist/index.client.js +2 -2
- package/dist/server/actions.d.ts +3 -0
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/actions.js +19 -1
- package/dist/server/activity.d.ts +1 -0
- package/dist/server/activity.d.ts.map +1 -1
- package/dist/server/activity.js +5 -0
- package/dist/server/behavior.js +2 -2
- package/dist/server/types.d.ts +1 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/ui/actions_panel.d.ts +3 -1
- package/dist/ui/actions_panel.d.ts.map +1 -1
- package/dist/ui/actions_panel.js +9 -4
- package/dist/ui/datapoint_panel.d.ts.map +1 -1
- package/dist/ui/datapoint_panel.js +15 -5
- package/dist/ui/journey_panel.d.ts.map +1 -1
- package/dist/ui/journey_panel.js +211 -20
- package/dist/ui/metrics_panel.d.ts +3 -1
- package/dist/ui/metrics_panel.d.ts.map +1 -1
- package/dist/ui/metrics_panel.js +27 -3
- package/migrations/db_setup_postgres.sql +1 -0
- package/migrations/db_setup_sqlite.sql +1 -0
- package/package.json +1 -5
package/CHANGE_LOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# hazo_umetrics — Change Log
|
|
2
2
|
|
|
3
|
+
## 1.5.1 — `ui.pageexit.*` rename, smart auto-labels, `defaultActionLabels`, `ActionsPanel.refreshTrigger`
|
|
4
|
+
|
|
5
|
+
**Fixes / improvements (patch — no breaking changes for new installs; existing `ui.dwell.*` catalog rows need re-seeding):**
|
|
6
|
+
|
|
7
|
+
### Rename: `ui.dwell.*` → `ui.pageexit.*`
|
|
8
|
+
- `HazoMetricsProvider` now fires `ui.pageexit.<slug>` (instead of `ui.dwell.<slug>`) on path change and page hide. The name better describes the event: it fires when a user *leaves* a page, carrying the foreground dwell time in `dims.value`.
|
|
9
|
+
- `tabDwell` behavior aggregation updated to match — reads `ui.pageexit.*` from the stat store.
|
|
10
|
+
- Seed and test-app updated accordingly. Existing `ui.dwell.*` rows in the catalog are inert; re-seed or rename manually.
|
|
11
|
+
|
|
12
|
+
### Smarter auto-labels for `ui.*` events
|
|
13
|
+
- `ensureActionDef` now uses `deriveLabel()` instead of the generic `humanize()` for auto-created catalog rows. `ui.pageview.*` → `PageEntry:<Page>`, `ui.pageexit.*` → `PageExit:<Page>`, `ui.click.*` → `Click:<Label>`, `action.*` → `Action:<Label>`. Keeps auto-created rows recognizable without manual editing.
|
|
14
|
+
|
|
15
|
+
### New: `defaultActionLabels` option on `createEventHandlers`
|
|
16
|
+
- Pass `defaultActionLabels: Record<string, string>` to `createEventHandlers` to override the derived label for specific event keys on first ingest. Useful for wiring friendly display names before an operator touches the catalog:
|
|
17
|
+
```ts
|
|
18
|
+
createEventHandlers({
|
|
19
|
+
getAdapter: () => myAdapter,
|
|
20
|
+
defaultActionLabels: {
|
|
21
|
+
'ui.pageview.root': 'PageEntry:Homepage',
|
|
22
|
+
'ui.click.sign_in': 'Click:Sign In',
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### New: `ActionsPanel.refreshTrigger` prop
|
|
28
|
+
- `ActionsPanel` now accepts an optional `refreshTrigger?: number` prop. Increment it from the parent to trigger an external catalog refresh (e.g. after seeding events via a button click), without requiring the user to scroll back to a manual refresh control.
|
|
29
|
+
|
|
30
|
+
### Edit form: shows technical name
|
|
31
|
+
- The `ActionsPanel` inline edit form now shows a read-only "Technical name" field above the label input, so operators know which `action_key` they're renaming. Also adds a placeholder to the label input showing the raw key.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 1.4.1 — Self-contained journey flow graph, journey user filter, smarter datapoint default
|
|
36
|
+
|
|
37
|
+
**Fixes / improvements (patch — no breaking API changes):**
|
|
38
|
+
|
|
39
|
+
### JourneyPanel: inline-SVG flow graph (replaces hazo_canvas)
|
|
40
|
+
- `JourneyPanel` now renders a self-contained inline-SVG left-to-right DAG. Removed the optional `hazo_canvas` peer dependency entirely — no external graph engine needed.
|
|
41
|
+
- Layout uses **shortest-path BFS** ranking (source nodes on the left, depth growing rightward). Journey graphs are cyclic (users revisit steps); the previous longest-path approach inflated every node to the same column and could grow its queue without bound (`RangeError: Invalid array length`). Shortest-path sets each node's rank once, so columns stay stable and bounded.
|
|
42
|
+
- Node fill encodes step `kind` (action / pageview / error); node opacity scales with visit count. Edge stroke width + opacity scale with transition count; counts >1 are labelled.
|
|
43
|
+
|
|
44
|
+
### JourneyPanel: built-in user filter
|
|
45
|
+
- Added a user-filter dropdown (top-right of the panel). "All users" by default; selecting a user refetches the journey scoped to `&user_id=<id>`.
|
|
46
|
+
- The known-user option list only grows across loads, so it stays stable while a single-user filter is active. Falls back to the `userId` prop when the internal filter is unset.
|
|
47
|
+
|
|
48
|
+
### DatapointPanel: smarter default action selection
|
|
49
|
+
- The action dropdown now defaults to the most useful action: first one with a declared `datapoint_schema`, else the first app-domain action (skipping auto-tracked `btn.*` / `ui.*` events), else the first action. Avoids landing on an empty auto-tracked event with no dimensions.
|
|
50
|
+
|
|
3
51
|
## 1.4.0 — Bulk catalog edits, per-action datapoints, user-journey viz, real user identity
|
|
4
52
|
|
|
5
53
|
**New features (minor bump — no breaking changes):**
|
package/README.md
CHANGED
|
@@ -103,7 +103,7 @@ Seven behavioral panels answer:
|
|
|
103
103
|
- **Post re-login actions** — what users do on their second session
|
|
104
104
|
- **Before "share"** — the N actions preceding a goal event (configurable goal key)
|
|
105
105
|
- **Common errors** — ranked `ui.error.*` frequency
|
|
106
|
-
- **Time per tab** — real foreground dwell time per path (from `ui.
|
|
106
|
+
- **Time per tab** — real foreground dwell time per path (from `ui.pageexit.*` events)
|
|
107
107
|
- **Returning-user actions** — most common actions for users with >1 session
|
|
108
108
|
|
|
109
109
|
For `user_id` to be captured, call `identify(userId)` after login or mount `UserIdentifier` (see test-app `src/components/user_identifier.tsx`).
|
|
@@ -123,7 +123,7 @@ const { trackAction, trackError } = useMetrics();
|
|
|
123
123
|
trackAction('timer.complete', { url: '/timer' });
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
-
Auto-tracked on mount: page views (`ui.pageview.*`), clicks (`ui.click.*`), errors (`ui.error.*`), dwell time (`ui.
|
|
126
|
+
Auto-tracked on mount: page views (`ui.pageview.*`), clicks (`ui.click.*`), errors (`ui.error.*`), page-exit dwell time (`ui.pageexit.*`).
|
|
127
127
|
|
|
128
128
|
## Bulk Action Catalog edits
|
|
129
129
|
|
|
@@ -201,7 +201,7 @@ import { JourneyPanel } from 'hazo_umetrics/ui';
|
|
|
201
201
|
/>
|
|
202
202
|
```
|
|
203
203
|
|
|
204
|
-
`JourneyPanel` renders a
|
|
204
|
+
`JourneyPanel` renders a self-contained inline-SVG flow graph — a left-to-right DAG laid out by shortest-path BFS (source nodes on the left, depth growing rightward). Node fill encodes step `kind` (action / pageview / error) and opacity scales with visit count; edge stroke width and opacity scale with transition count. No external graph dependency is required. A built-in **user filter** dropdown (top-right of the panel) scopes the graph and session list to a single user; it refetches with `&user_id=<id>`. The known-user option list only grows, so it stays stable while a filter is active. Below the graph, per-session collapsible step lists show timing between steps.
|
|
205
205
|
|
|
206
206
|
## Datapoint analytics
|
|
207
207
|
|
|
@@ -211,7 +211,7 @@ import { JourneyPanel } from 'hazo_umetrics/ui';
|
|
|
211
211
|
// Response: { ok: true, data: { action_key, total_events, keys: DatapointKeyStats[] } }
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
-
Each entry in `keys` describes one dimension: `{ key, type, total, histogram | topValues }`. Use the `DatapointPanel` component to render these automatically.
|
|
214
|
+
Each entry in `keys` describes one dimension: `{ key, type, total, histogram | topValues }`. Use the `DatapointPanel` component to render these automatically. The panel's action dropdown defaults to the most useful action: first an action with a declared `datapoint_schema`, else the first app-domain action (skipping auto-tracked `btn.*` / `ui.*` events), else the first action — so a user lands on data-bearing dimensions instead of an empty auto-tracked event.
|
|
215
215
|
|
|
216
216
|
## resolveUserProfiles — wiring user identity
|
|
217
217
|
|
|
@@ -247,6 +247,42 @@ export async function POST(req: Request) {
|
|
|
247
247
|
}
|
|
248
248
|
```
|
|
249
249
|
|
|
250
|
+
## Event ingestion — `defaultActionLabels`
|
|
251
|
+
|
|
252
|
+
Pass `defaultActionLabels` to `createEventHandlers` to declare human-readable labels for auto-tracked `ui.*` events before an operator edits the catalog. On first ingest of a key, the label is stored instead of the derived `PageEntry:*` / `Click:*` fallback:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
import { createEventHandlers } from 'hazo_umetrics/api';
|
|
256
|
+
|
|
257
|
+
const handlers = createEventHandlers({
|
|
258
|
+
getAdapter: () => myAdapter,
|
|
259
|
+
defaultActionLabels: {
|
|
260
|
+
'ui.pageview.root': 'Homepage',
|
|
261
|
+
'ui.pageview.timer': 'Timer Page',
|
|
262
|
+
'ui.click.sign_in': 'Sign In Button',
|
|
263
|
+
'ui.pageexit.root': 'Homepage Exit',
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// app/api/hazo_umetrics/events/route.ts
|
|
268
|
+
export const POST = (req: Request) => handlers.recordEvent(req);
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
## Action Catalog panel — `refreshTrigger`
|
|
272
|
+
|
|
273
|
+
`ActionsPanel` accepts an optional `refreshTrigger` prop. Increment it to force a catalog reload from the parent (e.g. after seeding events):
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
import { ActionsPanel } from 'hazo_umetrics/ui';
|
|
277
|
+
|
|
278
|
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
279
|
+
|
|
280
|
+
// After seeding:
|
|
281
|
+
setRefreshTrigger(n => n + 1);
|
|
282
|
+
|
|
283
|
+
<ActionsPanel appId="my-app" refreshTrigger={refreshTrigger} />
|
|
284
|
+
```
|
|
285
|
+
|
|
250
286
|
## API route factories
|
|
251
287
|
|
|
252
288
|
```ts
|
package/dist/api/index.d.ts
CHANGED
|
@@ -21,6 +21,22 @@ export interface ApiFactoryOptions {
|
|
|
21
21
|
app_id: string;
|
|
22
22
|
details?: unknown;
|
|
23
23
|
}) => Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* Default human-readable labels for auto-tracked events.
|
|
26
|
+
* When an event key is first seen and a catalog entry is auto-created,
|
|
27
|
+
* the matching label is used instead of the raw technical name.
|
|
28
|
+
*
|
|
29
|
+
* Example:
|
|
30
|
+
* ```ts
|
|
31
|
+
* defaultActionLabels: {
|
|
32
|
+
* 'ui.pageview.root': 'Homepage',
|
|
33
|
+
* 'ui.pageview.timer': 'Timer Page',
|
|
34
|
+
* 'ui.click.sign_in': 'Sign In Button',
|
|
35
|
+
* 'ui.pageexit.root': 'Homepage Exit',
|
|
36
|
+
* }
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
defaultActionLabels?: Record<string, string>;
|
|
24
40
|
}
|
|
25
41
|
export declare function createStatHandlers(options: ApiFactoryOptions): {
|
|
26
42
|
/**
|
|
@@ -75,7 +91,7 @@ export declare function createQueryHandlers(options: ApiFactoryOptions): {
|
|
|
75
91
|
/** STUB (M1): Execute a GA4 or stat-store query. */
|
|
76
92
|
query(req: Request): Promise<Response>;
|
|
77
93
|
};
|
|
78
|
-
export declare function createEventHandlers(options: Pick<ApiFactoryOptions, 'getAdapter'>): {
|
|
94
|
+
export declare function createEventHandlers(options: Pick<ApiFactoryOptions, 'getAdapter' | 'defaultActionLabels'>): {
|
|
79
95
|
/**
|
|
80
96
|
* POST /...
|
|
81
97
|
* Body: { app_id, metric_key, value?, dimensions? }
|
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;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;
|
|
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
|
@@ -413,11 +413,17 @@ export function createEventHandlers(options) {
|
|
|
413
413
|
value: typeof value === 'number' ? value : 1,
|
|
414
414
|
dimensions,
|
|
415
415
|
});
|
|
416
|
-
// Auto-classify: upsert a draft catalog row for action.* events
|
|
416
|
+
// Auto-classify: upsert a draft catalog row for action.* and ui.* events
|
|
417
417
|
if (metric_key.startsWith('action.')) {
|
|
418
418
|
const action_key = metric_key.slice('action.'.length);
|
|
419
419
|
const url = typeof dimensions?.url === 'string' ? dimensions.url : undefined;
|
|
420
|
-
|
|
420
|
+
const defaultLabel = options.defaultActionLabels?.[action_key];
|
|
421
|
+
await _ensureActionDef(adapter, { app_id, action_key, url, source: 'manual', defaultLabel });
|
|
422
|
+
}
|
|
423
|
+
else if (metric_key.startsWith('ui.')) {
|
|
424
|
+
const url = typeof dimensions?.url === 'string' ? dimensions.url : undefined;
|
|
425
|
+
const defaultLabel = options.defaultActionLabels?.[metric_key];
|
|
426
|
+
await _ensureActionDef(adapter, { app_id, action_key: metric_key, url, source: 'auto', defaultLabel });
|
|
421
427
|
}
|
|
422
428
|
return jsonOk({ recorded: true }, 201);
|
|
423
429
|
},
|
package/dist/index.client.js
CHANGED
|
@@ -71,7 +71,7 @@ export function HazoMetricsProvider({ appId, userId, children }) {
|
|
|
71
71
|
const elapsed = Date.now() - lastChangeAtRef.current;
|
|
72
72
|
if (prev && elapsed > 0 && prev !== pathname) {
|
|
73
73
|
const slug = prev.replace(/\//g, '_').replace(/^_/, '') || 'root';
|
|
74
|
-
postEvent(appId, `ui.
|
|
74
|
+
postEvent(appId, `ui.pageexit.${slug}`, { path: prev, value: elapsed });
|
|
75
75
|
}
|
|
76
76
|
// Update refs
|
|
77
77
|
lastPathRef.current = pathname;
|
|
@@ -88,7 +88,7 @@ export function HazoMetricsProvider({ appId, userId, children }) {
|
|
|
88
88
|
const elapsed = Date.now() - lastChangeAtRef.current;
|
|
89
89
|
if (elapsed > 0 && lastPathRef.current) {
|
|
90
90
|
const slug = lastPathRef.current.replace(/\//g, '_').replace(/^_/, '') || 'root';
|
|
91
|
-
postEvent(appId, `ui.
|
|
91
|
+
postEvent(appId, `ui.pageexit.${slug}`, { path: lastPathRef.current, value: elapsed });
|
|
92
92
|
lastChangeAtRef.current = Date.now();
|
|
93
93
|
}
|
|
94
94
|
}
|
package/dist/server/actions.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ type ActionDefRow = {
|
|
|
10
10
|
url: string | null;
|
|
11
11
|
is_key_event: number | boolean;
|
|
12
12
|
status: string;
|
|
13
|
+
source: string;
|
|
13
14
|
datapoint_schema: string;
|
|
14
15
|
created_at: string;
|
|
15
16
|
updated_at: string;
|
|
@@ -25,6 +26,8 @@ export declare function ensureActionDef(adapter: HazoConnectAdapter, params: {
|
|
|
25
26
|
scope_id?: string;
|
|
26
27
|
action_key: string;
|
|
27
28
|
url?: string | null;
|
|
29
|
+
source?: 'auto' | 'manual';
|
|
30
|
+
defaultLabel?: string;
|
|
28
31
|
}): Promise<void>;
|
|
29
32
|
/**
|
|
30
33
|
* Returns all action defs for an app as a flat list.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/server/actions.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAE,cAAc,EAAE,UAAU,EAAE,UAAU,EAAmB,MAAM,YAAY,CAAC;AAMrG,KAAK,YAAY,GAAG;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,MAAM,CAAC;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB,CAAC;
|
|
1
|
+
{"version":3,"file":"actions.d.ts","sourceRoot":"","sources":["../../src/server/actions.ts"],"names":[],"mappings":"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"}
|
package/dist/server/actions.js
CHANGED
|
@@ -31,6 +31,7 @@ function rowToDef(row) {
|
|
|
31
31
|
url: row.url ?? null,
|
|
32
32
|
is_key_event: Boolean(row.is_key_event),
|
|
33
33
|
status: row.status,
|
|
34
|
+
source: (row.source === 'auto' ? 'auto' : 'manual'),
|
|
34
35
|
datapoint_schema: parseSchema(row.datapoint_schema),
|
|
35
36
|
created_at: row.created_at,
|
|
36
37
|
updated_at: row.updated_at,
|
|
@@ -65,6 +66,22 @@ function humanize(key) {
|
|
|
65
66
|
.replace(/[._-]/g, ' ')
|
|
66
67
|
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
67
68
|
}
|
|
69
|
+
function deriveLabel(action_key, source) {
|
|
70
|
+
if (action_key.startsWith('ui.pageview.')) {
|
|
71
|
+
return `PageEntry:${humanize(action_key.slice('ui.pageview.'.length))}`;
|
|
72
|
+
}
|
|
73
|
+
if (action_key.startsWith('ui.pageexit.')) {
|
|
74
|
+
return `PageExit:${humanize(action_key.slice('ui.pageexit.'.length))}`;
|
|
75
|
+
}
|
|
76
|
+
if (action_key.startsWith('ui.click.')) {
|
|
77
|
+
return `Click:${humanize(action_key.slice('ui.click.'.length))}`;
|
|
78
|
+
}
|
|
79
|
+
if (source === 'manual') {
|
|
80
|
+
return `Action:${humanize(action_key)}`;
|
|
81
|
+
}
|
|
82
|
+
// other ui.* — raw key
|
|
83
|
+
return action_key;
|
|
84
|
+
}
|
|
68
85
|
// ---------------------------------------------------------------------------
|
|
69
86
|
// In-process seen-set cache — avoids a def write per event after first sight
|
|
70
87
|
// ---------------------------------------------------------------------------
|
|
@@ -94,12 +111,13 @@ export async function ensureActionDef(adapter, params) {
|
|
|
94
111
|
action_key: params.action_key,
|
|
95
112
|
app_id: params.app_id,
|
|
96
113
|
scope_id: params.scope_id ?? '',
|
|
97
|
-
label:
|
|
114
|
+
label: params.defaultLabel ?? deriveLabel(params.action_key, params.source ?? 'manual'),
|
|
98
115
|
description: '',
|
|
99
116
|
parent_key: null,
|
|
100
117
|
url: params.url ?? null,
|
|
101
118
|
is_key_event: 0,
|
|
102
119
|
status: 'unclassified',
|
|
120
|
+
source: params.source ?? 'manual',
|
|
103
121
|
datapoint_schema: '[]',
|
|
104
122
|
created_at: new Date().toISOString(),
|
|
105
123
|
updated_at: new Date().toISOString(),
|
|
@@ -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,
|
|
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"}
|
package/dist/server/activity.js
CHANGED
|
@@ -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),
|
package/dist/server/behavior.js
CHANGED
|
@@ -229,13 +229,13 @@ export async function tabDwell(adapter, opts) {
|
|
|
229
229
|
const rows = await fetchWindow(adapter, { app_id, scope_id, since: since ?? defaultSince(), cap: limit });
|
|
230
230
|
const tally = new Map();
|
|
231
231
|
for (const row of rows) {
|
|
232
|
-
if (!row.metric_key.startsWith('ui.
|
|
232
|
+
if (!row.metric_key.startsWith('ui.pageexit.'))
|
|
233
233
|
continue;
|
|
234
234
|
const dims = parseDims(row.dimensions);
|
|
235
235
|
const uid = dims.user_id;
|
|
236
236
|
if (userIds && uid && !userIds.includes(uid))
|
|
237
237
|
continue;
|
|
238
|
-
const path = dims.path ?? row.metric_key.slice('ui.
|
|
238
|
+
const path = dims.path ?? row.metric_key.slice('ui.pageexit.'.length);
|
|
239
239
|
const elapsed = typeof dims.value === 'number' ? dims.value : row.value;
|
|
240
240
|
tally.set(path, (tally.get(path) ?? 0) + elapsed);
|
|
241
241
|
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,IAAI;IACnB,EAAE,EAAE,MAAM,CAAC;IACX,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,GAAG,IAAI,CAAC;IACnD,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,OAAO;IACtB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;IACtD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,GAAG,YAAY,CAAC;IAC5B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAClD,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACnC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,UAAU;IACzB,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,OAAO,EAAE,CAAC,GAAG,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,OAAO,cAAc,EAAE,kBAAkB,CAAA;KAAE,KAAK,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5G;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IAC/C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,MAAM,MAAM,eAAe,GAAG,cAAc,EAAE,CAAC;AAE/C,MAAM,WAAW,SAAS;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,YAAY,EAAE,OAAO,CAAC;IACtB,MAAM,EAAE,cAAc,GAAG,QAAQ,GAAG,UAAU,CAAC;IAC/C,gBAAgB,EAAE,eAAe,CAAC;IAClC,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,MAAM,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,aAAa,GAAG,YAAY,GAAG,KAAK,GAAG,cAAc,GAAG,QAAQ,GAAG,kBAAkB,CAAC,CAAC,CAAC;AAEvJ,MAAM,WAAW,UAAW,SAAQ,SAAS;IAC3C,QAAQ,EAAE,UAAU,EAAE,CAAC;CACxB;AAED,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,gBAAgB;IAC/B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC1B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;CAC9C"}
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export interface ActionsPanelProps {
|
|
2
2
|
appId: string;
|
|
3
3
|
apiBase?: string;
|
|
4
|
+
/** Increment to trigger an external refresh (e.g. after seeding events). */
|
|
5
|
+
refreshTrigger?: number;
|
|
4
6
|
}
|
|
5
|
-
export declare function ActionsPanel({ appId, apiBase }: ActionsPanelProps): import("react").JSX.Element;
|
|
7
|
+
export declare function ActionsPanel({ appId, apiBase, refreshTrigger }: ActionsPanelProps): import("react").JSX.Element;
|
|
6
8
|
//# sourceMappingURL=actions_panel.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"actions_panel.d.ts","sourceRoot":"","sources":["../../src/ui/actions_panel.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"actions_panel.d.ts","sourceRoot":"","sources":["../../src/ui/actions_panel.tsx"],"names":[],"mappings":"AA42BA,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAWD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAA8B,EAAE,cAAkB,EAAE,EAAE,iBAAiB,+BAkI5G"}
|
package/dist/ui/actions_panel.js
CHANGED
|
@@ -12,6 +12,11 @@ function statusBadgeClass(status) {
|
|
|
12
12
|
return 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-600';
|
|
13
13
|
/* unclassified */ return 'inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800';
|
|
14
14
|
}
|
|
15
|
+
function sourceBadge(source) {
|
|
16
|
+
if (source !== 'auto')
|
|
17
|
+
return null;
|
|
18
|
+
return (_jsx("span", { className: "inline-block rounded px-1.5 py-0.5 text-xs font-medium bg-gray-100 text-gray-500 ml-1", children: "auto" }));
|
|
19
|
+
}
|
|
15
20
|
function ActionKeyPicker({ value, onChange, options, excludeKey, placeholder = 'Select action…' }) {
|
|
16
21
|
const [open, setOpen] = useState(false);
|
|
17
22
|
const [query, setQuery] = useState('');
|
|
@@ -169,7 +174,7 @@ function EditForm({ node, appId, apiBase, allActions, onSaved, onCancel }) {
|
|
|
169
174
|
setSaving(false);
|
|
170
175
|
}
|
|
171
176
|
}, [draft, node, appId, apiBase, relatedKeys, initRelated, onSaved, datapointSchemaText, schemaError]);
|
|
172
|
-
return (_jsxs("div", { className: "mt-1 ml-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 flex flex-col gap-3", children: [error != null && (_jsx("p", { className: "text-xs text-red-600 rounded bg-red-50 border border-red-200 px-2 py-1", children: error })), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Label" }), _jsx("input", { type: "text", value: draft.label, onChange: (e) => setDraft((d) => ({ ...d, label: e.target.value })), className: "rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Description" }), _jsx("textarea", { value: draft.description, rows: 2, onChange: (e) => setDraft((d) => ({ ...d, description: e.target.value })), className: "rounded border px-2 py-1 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Parent action (optional)" }), _jsx(ActionKeyPicker, { value: draft.parent_key, onChange: (key) => setDraft((d) => ({ ...d, parent_key: key })), options: allActions, excludeKey: node.action_key, placeholder: "None (root node)" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Related actions" }), relatedLoading ? (_jsx("p", { className: "text-xs text-gray-400", children: "Loading\u2026" })) : (_jsx(RelatedActionsPicker, { selectedKeys: relatedKeys, onChange: setRelatedKeys, options: allActions, excludeKey: node.action_key }))] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "URL (optional)" }), _jsx("input", { type: "text", value: draft.url, placeholder: "https://\u2026", onChange: (e) => setDraft((d) => ({ ...d, url: e.target.value })), className: "rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Datapoint Schema (JSON)" }), _jsx("textarea", { value: datapointSchemaText, rows: 5, onChange: (e) => {
|
|
177
|
+
return (_jsxs("div", { className: "mt-1 ml-6 rounded-lg border border-indigo-200 bg-indigo-50 p-4 flex flex-col gap-3", children: [error != null && (_jsx("p", { className: "text-xs text-red-600 rounded bg-red-50 border border-red-200 px-2 py-1", children: error })), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Technical name" }), _jsx("input", { type: "text", value: node.action_key, readOnly: true, className: "rounded border px-2 py-1 text-sm font-mono bg-gray-50 text-gray-500 cursor-default focus:outline-none" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Label" }), _jsx("input", { type: "text", value: draft.label, placeholder: node.action_key, onChange: (e) => setDraft((d) => ({ ...d, label: e.target.value })), className: "rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Description" }), _jsx("textarea", { value: draft.description, rows: 2, onChange: (e) => setDraft((d) => ({ ...d, description: e.target.value })), className: "rounded border px-2 py-1 text-sm resize-none focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Parent action (optional)" }), _jsx(ActionKeyPicker, { value: draft.parent_key, onChange: (key) => setDraft((d) => ({ ...d, parent_key: key })), options: allActions, excludeKey: node.action_key, placeholder: "None (root node)" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Related actions" }), relatedLoading ? (_jsx("p", { className: "text-xs text-gray-400", children: "Loading\u2026" })) : (_jsx(RelatedActionsPicker, { selectedKeys: relatedKeys, onChange: setRelatedKeys, options: allActions, excludeKey: node.action_key }))] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "URL (optional)" }), _jsx("input", { type: "text", value: draft.url, placeholder: "https://\u2026", onChange: (e) => setDraft((d) => ({ ...d, url: e.target.value })), className: "rounded border px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-400" })] }), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { className: "text-xs font-medium text-gray-600", children: "Datapoint Schema (JSON)" }), _jsx("textarea", { value: datapointSchemaText, rows: 5, onChange: (e) => {
|
|
173
178
|
setDatapointSchemaText(e.target.value);
|
|
174
179
|
try {
|
|
175
180
|
const parsed = JSON.parse(e.target.value);
|
|
@@ -242,10 +247,10 @@ function ActionRow({ node, depth, appId, apiBase, allActions, onRefresh, selecte
|
|
|
242
247
|
const indent = depth * 20;
|
|
243
248
|
return (_jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2 rounded-md px-2 py-1.5 hover:bg-gray-50 group", style: { paddingLeft: `${indent + 8}px` }, children: [_jsx("input", { type: "checkbox", checked: selected.has(node.action_key), onChange: () => onToggleSelect(node.action_key), onClick: (e) => e.stopPropagation(), className: "flex-shrink-0 rounded", "aria-label": `Select ${node.action_key}` }), _jsx("button", { onClick: () => setExpanded((v) => !v), className: "flex-shrink-0 w-4 h-4 flex items-center justify-center text-gray-400 hover:text-gray-700", style: { visibility: hasChildren ? 'visible' : 'hidden' }, "aria-label": expanded ? 'Collapse' : 'Expand', children: expanded
|
|
244
249
|
? _jsx(ChevronDown, { className: "h-3.5 w-3.5" })
|
|
245
|
-
: _jsx(ChevronRight, { className: "h-3.5 w-3.5" }) }),
|
|
250
|
+
: _jsx(ChevronRight, { className: "h-3.5 w-3.5" }) }), _jsxs("span", { className: "flex-1 min-w-0 text-sm text-gray-800 truncate font-medium", children: [node.label, sourceBadge(node.source)] }), _jsx("span", { className: "text-xs text-gray-400 font-mono truncate max-w-[160px]", children: node.action_key }), _jsx("span", { className: statusBadgeClass(node.status), children: node.status }), _jsx("span", { title: node.is_key_event ? 'Key event' : 'Not a key event', className: node.is_key_event ? 'text-yellow-500' : 'text-gray-300', children: _jsx(Star, { className: "h-3.5 w-3.5", fill: node.is_key_event ? 'currentColor' : 'none' }) }), _jsx("button", { onClick: () => setEditing((v) => !v), className: "flex-shrink-0 rounded p-1 text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors opacity-0 group-hover:opacity-100", "aria-label": "Edit action", title: "Edit", children: _jsx(Pencil, { className: "h-3.5 w-3.5" }) })] }), editing && (_jsx(EditForm, { node: node, appId: appId, apiBase: apiBase, allActions: allActions, onSaved: () => { setEditing(false); onRefresh(); }, onCancel: () => setEditing(false) })), hasChildren && expanded && (_jsx("div", { children: node.children.map((child) => (_jsx(ActionRow, { node: child, depth: depth + 1, appId: appId, apiBase: apiBase, allActions: allActions, onRefresh: onRefresh, selected: selected, onToggleSelect: onToggleSelect }, child.action_key))) }))] }));
|
|
246
251
|
}
|
|
247
252
|
const INITIAL_STATE = { loading: true, error: null, tree: [], allActions: [] };
|
|
248
|
-
export function ActionsPanel({ appId, apiBase = '/api/hazo_umetrics' }) {
|
|
253
|
+
export function ActionsPanel({ appId, apiBase = '/api/hazo_umetrics', refreshTrigger = 0 }) {
|
|
249
254
|
const [state, setState] = useState(INITIAL_STATE);
|
|
250
255
|
const [refreshKey, setRefreshKey] = useState(0);
|
|
251
256
|
const [selected, setSelected] = useState(new Set());
|
|
@@ -292,7 +297,7 @@ export function ActionsPanel({ appId, apiBase = '/api/hazo_umetrics' }) {
|
|
|
292
297
|
}
|
|
293
298
|
});
|
|
294
299
|
return () => { cancelled = true; };
|
|
295
|
-
}, [appId, apiBase, refreshKey]);
|
|
300
|
+
}, [appId, apiBase, refreshKey, refreshTrigger]);
|
|
296
301
|
// -------------------------------------------------------------------------
|
|
297
302
|
// Render states
|
|
298
303
|
// -------------------------------------------------------------------------
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"datapoint_panel.d.ts","sourceRoot":"","sources":["../../src/ui/datapoint_panel.tsx"],"names":[],"mappings":"AAQA,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IAC/C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,UAAU,SAAS;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,cAAc,EAAE,CAAC;CACpC;AA0HD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,EAAE,CAAC;CACtB;
|
|
1
|
+
{"version":3,"file":"datapoint_panel.d.ts","sourceRoot":"","sources":["../../src/ui/datapoint_panel.tsx"],"names":[],"mappings":"AAQA,UAAU,cAAc;IACtB,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,CAAC;IAC/C,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;CACpB;AAED,UAAU,SAAS;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,gBAAgB,EAAE,cAAc,EAAE,CAAC;CACpC;AA0HD,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,SAAS,EAAE,CAAC;CACtB;AAeD,wBAAgB,cAAc,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,mBAAmB,+BAuG9E"}
|
|
@@ -24,18 +24,28 @@ function CategoricalCard({ stat, schemaLabel }) {
|
|
|
24
24
|
const title = schemaLabel ? `${stat.key} (${schemaLabel})` : stat.key;
|
|
25
25
|
return (_jsxs("div", { className: "rounded-lg border bg-white p-4 flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h4", { className: "text-xs font-semibold text-gray-700 font-mono", children: title }), _jsxs("span", { className: "text-[10px] text-gray-400", children: [stat.distinct, " distinct \u00B7 ", stat.count, " total"] })] }), stat.top && stat.top.length > 0 ? (_jsx("div", { className: "flex flex-col gap-1", children: stat.top.map((t) => (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "text-[10px] text-gray-600 w-24 truncate", children: t.value }), _jsx(MiniBar, { value: t.count, max: maxCount, color: "#10b981" })] }, t.value))) })) : (_jsx("p", { className: "text-xs text-gray-400", children: "No values." }))] }));
|
|
26
26
|
}
|
|
27
|
+
function preferredAction(actions) {
|
|
28
|
+
// 1. First with a declared datapoint schema
|
|
29
|
+
const withSchema = actions.find((a) => a.datapoint_schema.length > 0);
|
|
30
|
+
if (withSchema)
|
|
31
|
+
return withSchema;
|
|
32
|
+
// 2. First app-domain action (not auto-tracked btn./ui. events)
|
|
33
|
+
const appAction = actions.find((a) => !a.action_key.startsWith('btn.') && !a.action_key.startsWith('ui.'));
|
|
34
|
+
if (appAction)
|
|
35
|
+
return appAction;
|
|
36
|
+
// 3. Fallback to first
|
|
37
|
+
return actions[0];
|
|
38
|
+
}
|
|
27
39
|
export function DatapointPanel({ appId, apiBase, actions }) {
|
|
28
|
-
const
|
|
29
|
-
const [selectedKey, setSelectedKey] = useState(firstWithSchema?.action_key ?? '');
|
|
40
|
+
const [selectedKey, setSelectedKey] = useState(preferredAction(actions)?.action_key ?? '');
|
|
30
41
|
const [stats, setStats] = useState(null);
|
|
31
42
|
const [loading, setLoading] = useState(false);
|
|
32
43
|
const [error, setError] = useState(null);
|
|
33
44
|
useEffect(() => {
|
|
34
45
|
if (!actions.length)
|
|
35
46
|
return;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
setSelectedKey(first.action_key);
|
|
47
|
+
if (!selectedKey)
|
|
48
|
+
setSelectedKey(preferredAction(actions)?.action_key ?? '');
|
|
39
49
|
}, [actions, selectedKey]);
|
|
40
50
|
useEffect(() => {
|
|
41
51
|
if (!selectedKey || !appId)
|
|
@@ -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;
|
|
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"}
|
package/dist/ui/journey_panel.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import {
|
|
3
|
-
import { useState, useEffect } from 'react';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { useState, useEffect, useRef } from 'react';
|
|
4
4
|
// ---------------------------------------------------------------------------
|
|
5
5
|
// Helpers
|
|
6
6
|
// ---------------------------------------------------------------------------
|
|
@@ -21,10 +21,190 @@ function kindColor(kind) {
|
|
|
21
21
|
default: return '#94a3b8';
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
function clampZoom(z) { return Math.max(0.25, Math.min(3, z)); }
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// JourneyFlowGraph — SVG left-to-right DAG visualization
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
const NODE_W = 148;
|
|
29
|
+
const NODE_H = 44;
|
|
30
|
+
const COL_GAP = 200;
|
|
31
|
+
const ROW_GAP = 64;
|
|
32
|
+
const PAD = 24;
|
|
33
|
+
function computeFlowLayout(nodes, edges) {
|
|
34
|
+
const ids = new Set(nodes.map(n => n.id));
|
|
35
|
+
const inDeg = new Map(nodes.map(n => [n.id, 0]));
|
|
36
|
+
const outEdges = new Map(nodes.map(n => [n.id, []]));
|
|
37
|
+
for (const e of edges) {
|
|
38
|
+
if (!ids.has(e.from) || !ids.has(e.to))
|
|
39
|
+
continue;
|
|
40
|
+
inDeg.set(e.to, (inDeg.get(e.to) ?? 0) + 1);
|
|
41
|
+
outEdges.get(e.from)?.push(e.to);
|
|
42
|
+
}
|
|
43
|
+
// Shortest-path rank assignment (BFS distance from a source). Journey graphs
|
|
44
|
+
// contain cycles (users revisit steps); longest-path ranking would let
|
|
45
|
+
// back-edges inflate every node to the same max column. Shortest-path sets
|
|
46
|
+
// each node's rank exactly once on first visit, so columns stay stable.
|
|
47
|
+
const rank = new Map();
|
|
48
|
+
const queue = [];
|
|
49
|
+
// Seed with all source nodes (in-degree 0), processed highest-count first.
|
|
50
|
+
const sources = nodes.filter((n) => (inDeg.get(n.id) ?? 0) === 0).sort((a, b) => b.count - a.count);
|
|
51
|
+
for (const n of sources) {
|
|
52
|
+
rank.set(n.id, 0);
|
|
53
|
+
queue.push(n.id);
|
|
54
|
+
}
|
|
55
|
+
// Pure-cycle fallback: every node has an in-edge → seed from highest count.
|
|
56
|
+
if (queue.length === 0) {
|
|
57
|
+
const top = [...nodes].sort((a, b) => b.count - a.count)[0];
|
|
58
|
+
if (top) {
|
|
59
|
+
rank.set(top.id, 0);
|
|
60
|
+
queue.push(top.id);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let qi = 0;
|
|
64
|
+
while (qi < queue.length) {
|
|
65
|
+
const cur = queue[qi++];
|
|
66
|
+
const r = rank.get(cur) ?? 0;
|
|
67
|
+
for (const next of (outEdges.get(cur) ?? [])) {
|
|
68
|
+
if (!rank.has(next)) {
|
|
69
|
+
rank.set(next, r + 1);
|
|
70
|
+
queue.push(next);
|
|
71
|
+
} // set once
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// Nodes unreachable from any source → trailing column after the deepest rank.
|
|
75
|
+
let maxAssigned = 0;
|
|
76
|
+
for (const r of rank.values())
|
|
77
|
+
if (r > maxAssigned)
|
|
78
|
+
maxAssigned = r;
|
|
79
|
+
for (const n of nodes) {
|
|
80
|
+
if (!rank.has(n.id))
|
|
81
|
+
rank.set(n.id, maxAssigned + 1);
|
|
82
|
+
}
|
|
83
|
+
// Group by rank, sort each column by count desc
|
|
84
|
+
const cols = new Map();
|
|
85
|
+
for (const n of nodes) {
|
|
86
|
+
const r = rank.get(n.id) ?? 0;
|
|
87
|
+
if (!cols.has(r))
|
|
88
|
+
cols.set(r, []);
|
|
89
|
+
cols.get(r).push(n);
|
|
90
|
+
}
|
|
91
|
+
for (const col of cols.values())
|
|
92
|
+
col.sort((a, b) => b.count - a.count);
|
|
93
|
+
// Assign positions
|
|
94
|
+
const pos = new Map();
|
|
95
|
+
for (const [col, colNodes] of cols.entries()) {
|
|
96
|
+
const totalH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP;
|
|
97
|
+
let y = PAD - totalH / 2;
|
|
98
|
+
for (const n of colNodes) {
|
|
99
|
+
pos.set(n.id, { x: PAD + col * (NODE_W + COL_GAP), y });
|
|
100
|
+
y += NODE_H + ROW_GAP;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return pos;
|
|
104
|
+
}
|
|
105
|
+
function JourneyFlowGraph({ graph }) {
|
|
106
|
+
const [zoom, setZoom] = useState(1.0);
|
|
107
|
+
const [pan, setPan] = useState({ x: 0, y: 0 });
|
|
108
|
+
const [dragging, setDragging] = useState(false);
|
|
109
|
+
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
|
110
|
+
const [selectedNode, setSelectedNode] = useState(null);
|
|
111
|
+
const containerRef = useRef(null);
|
|
112
|
+
// Non-passive wheel listener so preventDefault() actually suppresses page scroll.
|
|
113
|
+
// React 17+ registers onWheel as passive, which silently ignores preventDefault.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
const el = containerRef.current;
|
|
116
|
+
if (!el)
|
|
117
|
+
return;
|
|
118
|
+
const onWheel = (e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
setZoom(z => clampZoom(z * (e.deltaY < 0 ? 1.1 : 0.9)));
|
|
121
|
+
};
|
|
122
|
+
el.addEventListener('wheel', onWheel, { passive: false });
|
|
123
|
+
return () => el.removeEventListener('wheel', onWheel);
|
|
124
|
+
}, []);
|
|
125
|
+
if (graph.nodes.length === 0)
|
|
126
|
+
return null;
|
|
127
|
+
const pos = computeFlowLayout(graph.nodes, graph.edges);
|
|
128
|
+
// Compute SVG dimensions
|
|
129
|
+
let maxX = 0;
|
|
130
|
+
let maxY = 0;
|
|
131
|
+
let minY = 0;
|
|
132
|
+
for (const p of pos.values()) {
|
|
133
|
+
if (p.x + NODE_W + PAD > maxX)
|
|
134
|
+
maxX = p.x + NODE_W + PAD;
|
|
135
|
+
if (p.y + NODE_H + PAD > maxY)
|
|
136
|
+
maxY = p.y + NODE_H + PAD;
|
|
137
|
+
if (p.y < minY)
|
|
138
|
+
minY = p.y;
|
|
139
|
+
}
|
|
140
|
+
const offsetY = minY < 0 ? -minY + PAD : PAD;
|
|
141
|
+
const svgH = maxY - minY + PAD * 2;
|
|
142
|
+
const svgW = maxX;
|
|
143
|
+
function handleMouseDown(e) {
|
|
144
|
+
if (e.target.closest('[data-node]'))
|
|
145
|
+
return;
|
|
146
|
+
setDragging(true);
|
|
147
|
+
setDragStart({ x: e.clientX - pan.x, y: e.clientY - pan.y });
|
|
148
|
+
}
|
|
149
|
+
function handleMouseMove(e) {
|
|
150
|
+
if (!dragging)
|
|
151
|
+
return;
|
|
152
|
+
setPan({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
|
153
|
+
}
|
|
154
|
+
function handleMouseUp() { setDragging(false); }
|
|
155
|
+
function fitToScreen() {
|
|
156
|
+
if (!containerRef.current)
|
|
157
|
+
return;
|
|
158
|
+
const cw = containerRef.current.clientWidth;
|
|
159
|
+
const ch = containerRef.current.clientHeight;
|
|
160
|
+
const scale = clampZoom(Math.min(cw / svgW, ch / svgH) * 0.9);
|
|
161
|
+
setZoom(scale);
|
|
162
|
+
setPan({ x: (cw - svgW * scale) / 2, y: (ch - svgH * scale) / 2 });
|
|
163
|
+
}
|
|
164
|
+
const highlightedEdges = new Set();
|
|
165
|
+
const highlightedNodes = new Set();
|
|
166
|
+
if (selectedNode) {
|
|
167
|
+
highlightedNodes.add(selectedNode);
|
|
168
|
+
graph.edges.forEach((edge, i) => {
|
|
169
|
+
if (edge.from === selectedNode || edge.to === selectedNode) {
|
|
170
|
+
highlightedEdges.add(i);
|
|
171
|
+
highlightedNodes.add(edge.from);
|
|
172
|
+
highlightedNodes.add(edge.to);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
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
|
+
})] }) })] }));
|
|
28
208
|
}
|
|
29
209
|
function SessionDetail({ session, index, profile }) {
|
|
30
210
|
const [open, setOpen] = useState(index === 0);
|
|
@@ -40,14 +220,18 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
|
|
|
40
220
|
const [result, setResult] = useState(null);
|
|
41
221
|
const [loading, setLoading] = useState(false);
|
|
42
222
|
const [error, setError] = useState(null);
|
|
43
|
-
const [canvasAvailable, setCanvasAvailable] = useState(false);
|
|
44
223
|
const [userProfiles, setUserProfiles] = useState(new Map());
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
224
|
+
// Persistent set of all user IDs ever seen (so the filter dropdown keeps its
|
|
225
|
+
// full option list even while a single-user filter is active).
|
|
226
|
+
const [knownUserIds, setKnownUserIds] = useState([]);
|
|
227
|
+
// Internal filter (falls back to the userId prop when unset, e.g. from the
|
|
228
|
+
// Behavior tab's single-user selection).
|
|
229
|
+
const [selectedUserId, setSelectedUserId] = useState('');
|
|
230
|
+
const effectiveUserId = selectedUserId || userId;
|
|
231
|
+
function userLabel(id) {
|
|
232
|
+
const p = userProfiles.get(id);
|
|
233
|
+
return p?.name ?? p?.email ?? `${id.slice(0, 12)}…`;
|
|
234
|
+
}
|
|
51
235
|
useEffect(() => {
|
|
52
236
|
if (!appId)
|
|
53
237
|
return;
|
|
@@ -55,8 +239,8 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
|
|
|
55
239
|
setLoading(true);
|
|
56
240
|
setError(null);
|
|
57
241
|
const params = new URLSearchParams({ app_id: appId });
|
|
58
|
-
if (
|
|
59
|
-
params.set('user_id',
|
|
242
|
+
if (effectiveUserId)
|
|
243
|
+
params.set('user_id', effectiveUserId);
|
|
60
244
|
if (sessionId)
|
|
61
245
|
params.set('session_id', sessionId);
|
|
62
246
|
fetch(`${apiBase}/analytics/journey?${params.toString()}`, { credentials: 'include' })
|
|
@@ -68,14 +252,21 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
|
|
|
68
252
|
setResult(data);
|
|
69
253
|
setLoading(false);
|
|
70
254
|
// Resolve user profiles for all sessions that have a user_id
|
|
71
|
-
if (
|
|
255
|
+
if (data?.sessions) {
|
|
72
256
|
const ids = [...new Set(data.sessions.map((s) => s.user_id).filter((id) => Boolean(id)))];
|
|
73
|
-
|
|
257
|
+
// Grow the known-users set (never shrink — keeps the filter options stable)
|
|
258
|
+
setKnownUserIds((prev) => [...new Set([...prev, ...ids])]);
|
|
259
|
+
if (resolveUserProfiles && ids.length > 0) {
|
|
74
260
|
resolveUserProfiles(ids)
|
|
75
261
|
.then((profiles) => {
|
|
76
262
|
if (cancelled)
|
|
77
263
|
return;
|
|
78
|
-
setUserProfiles(
|
|
264
|
+
setUserProfiles((prev) => {
|
|
265
|
+
const next = new Map(prev);
|
|
266
|
+
for (const p of profiles)
|
|
267
|
+
next.set(p.user_id, p);
|
|
268
|
+
return next;
|
|
269
|
+
});
|
|
79
270
|
})
|
|
80
271
|
.catch(() => { });
|
|
81
272
|
}
|
|
@@ -88,8 +279,8 @@ export function JourneyPanel({ appId, apiBase, userId, sessionId, resolveUserPro
|
|
|
88
279
|
setLoading(false);
|
|
89
280
|
});
|
|
90
281
|
return () => { cancelled = true; };
|
|
91
|
-
}, [appId, apiBase,
|
|
282
|
+
}, [appId, apiBase, effectiveUserId, sessionId]);
|
|
92
283
|
const sessionCount = result?.sessions.length ?? 0;
|
|
93
284
|
const totalSteps = result?.sessions.reduce((s, sess) => s + sess.steps.length, 0) ?? 0;
|
|
94
|
-
return (_jsxs("div", { className: "flex flex-col gap-4 p-4", children: [_jsxs("div", { className: "flex 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"] }),
|
|
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." }))] }));
|
|
95
286
|
}
|
|
@@ -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":"
|
|
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"}
|
package/dist/ui/metrics_panel.js
CHANGED
|
@@ -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 })
|
|
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
|
}
|
|
@@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS hazo_umetrics_action_def (
|
|
|
60
60
|
url TEXT, -- nullable, actions without URL leave null
|
|
61
61
|
is_key_event BOOLEAN NOT NULL DEFAULT FALSE,
|
|
62
62
|
status TEXT NOT NULL DEFAULT 'unclassified', -- unclassified | active | archived
|
|
63
|
+
source TEXT NOT NULL DEFAULT 'manual',
|
|
63
64
|
datapoint_schema JSONB NOT NULL DEFAULT '[]',
|
|
64
65
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
65
66
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
@@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS hazo_umetrics_action_def (
|
|
|
60
60
|
url TEXT, -- nullable, actions without URL leave null
|
|
61
61
|
is_key_event INTEGER NOT NULL DEFAULT 0, -- 0/1 boolean
|
|
62
62
|
status TEXT NOT NULL DEFAULT 'unclassified', -- unclassified | active | archived
|
|
63
|
+
source TEXT NOT NULL DEFAULT 'manual', -- 'auto' | 'manual'
|
|
63
64
|
datapoint_schema TEXT NOT NULL DEFAULT '[]',
|
|
64
65
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
65
66
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hazo_umetrics",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.1",
|
|
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",
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
"build:test-app:pg": "HAZO_CONNECT_TYPE=postgrest npm run build:test-app"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
|
-
"hazo_canvas": "^1.9.0",
|
|
46
45
|
"hazo_core": "^1.2.0",
|
|
47
46
|
"hazo_dataviz": "^0.3.0",
|
|
48
47
|
"hazo_logs": "^2.0.3",
|
|
@@ -58,9 +57,6 @@
|
|
|
58
57
|
"react-dom": "^18.0.0 || ^19.0.0"
|
|
59
58
|
},
|
|
60
59
|
"peerDependenciesMeta": {
|
|
61
|
-
"hazo_canvas": {
|
|
62
|
-
"optional": true
|
|
63
|
-
},
|
|
64
60
|
"hazo_logs": {
|
|
65
61
|
"optional": true
|
|
66
62
|
},
|