hazo_umetrics 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # hazo_umetrics — Change Log
2
2
 
3
+ ## 1.10.0 — Per-app windowed metrics (registry + Total/24h/7d/28d/90d + graph)
4
+
5
+ **New features (minor — additive; existing `MetricsPanel`/`statSnapshotJob` unaffected):**
6
+
7
+ ### Per-app metric registry + windowed read (`src/server/metrics.ts`, new)
8
+ - `registerMetrics({ app_id, metrics })` / `getRegisteredMetrics(app_id)` — module-level `Map<app_id, MetricDef[]>`, idempotent/merge-by-`(app_id, key)` (HMR/cold-start safe). Mirrors `registerStatCollector` but keyed per app; code is the single source of truth (`stat_def` stays dormant).
9
+ - `getMetricWindows(adapter, { app_id, scope_id?, days=90 })` → `MetricWindow[]`. Per registered metric: `ga4` → `pending` (all-null, empty series); `db` → per-key `getStatSeries`, collapse to one point per UTC day (last-write-wins), `total = latest`, `delta_Nd = total − value_as_of(now−Nd)` where `value_as_of` walks back to the last daily point `≤ target` (gap-day safe); no point ≤ target → `null`. Per-key reads only — never pulls `ui.*`/`action.*` rows from the shared table.
10
+ - `runMetricSnapshot(adapter, { app_id })` — records one row per `db` metric via `collect`; **idempotent per UTC day** (skips a metric already snapshotted today). Guards nightly + manual re-runs.
11
+ - `metricSnapshotJob: MaintenanceJob` — `type: 'hazo_umetrics.metric_snapshot'`, `defaultCron: '0 3 * * *'`.
12
+ - `backfillDailySnapshots(adapter, { app_id, metric_key, valueAsOf, from, to? })` — one-time historical fill, one row per UTC day, skips days already present.
13
+
14
+ ### Types (`src/server/types.ts`)
15
+ - Added `MetricDef` (`key/label/format/source:'db'|'ga4'/collect?/backfill?`), `MetricSeriesPoint`, `MetricWindow`.
16
+
17
+ ### `recordStat` (`src/server/stats.ts`) — optional `captured_at`
18
+ - Added an optional `captured_at?: string` param (non-breaking; defaults to now) so snapshot/backfill can write day-stamped rows.
19
+
20
+ ### API (`src/api/index.ts`)
21
+ - `createStatHandlers` gains `getMetricWindows` — `GET …/stats/windows?app_id=&days=`, gated on `metrics.view`, returns `{ windows }`. Mirrors the `getStatSeries` handler.
22
+
23
+ ### UI (`src/ui/metrics_tab.tsx`, new)
24
+ - `MetricsTab({ appId })` — fetches `/api/hazo_umetrics/stats/windows`, renders **WindowGrid** (row per metric: `Total | 24h | 7d | 28d | 90d`, sign-coloured deltas, `null`→"—", `ga4` rows show a muted "GA4 pending" badge) + **Graph** (metric `<select>` + `[24h][7d][28d][90d][All]` filter → `LineChart`). 401/403 → same `metrics.view` empty state as `MetricsPanel`.
25
+
26
+ ### Tests / test-app
27
+ - New `__tests__/metrics.test.ts` (8 cases: window diffing, gap days, before-first-point null, ga4 pending, same-day collapse, snapshot idempotency, backfill idempotency).
28
+ - Test-app: new `/metrics-windows` demo page + `stats/windows` route + `register_window_metrics` module; seeder now emits 90-day daily snapshots (with deliberate gap days) for demo `db` metrics.
29
+
30
+ ## 1.9.2 — Journey panel: name-first user labels, calmer flow-graph arrows
31
+
32
+ **Fixes / UI polish (patch — no public API or type changes):**
33
+
34
+ ### `IdentityDropdown` — show name before email
35
+ - `labelFor(id)` now falls back **name → email → short-uuid** (was email → name → short-uuid), matching `SessionDetail`'s ordering. The raw uuid only shows when a profile resolves to neither a name nor an email.
36
+
37
+ ### `JourneyFlowGraph` — arrows no longer render "messed up"
38
+ - Arrowhead `<marker>` now sets `markerUnits="userSpaceOnUse"` so the head stays a constant size instead of scaling with edge `strokeWidth` (thick edges previously got oversized, broken-looking heads).
39
+ - Edge routing simplified: forward edges always use a clean right→left horizontal cubic (steep forward edges no longer get shoved into top/bottom ports). Back-edge and same-column vertical routing unchanged; both `multiSidePorts`/`backEdgeLoop` props remain as escape hatches.
40
+ - Curvature clamped: control distance is now `max(30, min(dist*0.4, 120))` (was `max(40, dist*0.5)`) so long edges don't balloon.
41
+
42
+ ## 1.9.1 — Journey panel: absolute per-step timestamps
43
+
44
+ ### `SessionDetail` — absolute per-step timestamps
45
+ - Each step row now shows a local `HH:MM:SS` clock time (`fmtClock`) alongside the existing relative gap; the session header shows the start date (`fmtDateTime`). Data (`step.captured_at`) was already present, just unrendered.
46
+
3
47
  ## 1.9.0 — Search queries: per-term last-searched timestamps, second-precision "over time"
4
48
 
5
49
  **New features (minor — no code-level breaking changes):**
@@ -55,6 +55,12 @@ export declare function createStatHandlers(options: ApiFactoryOptions): {
55
55
  * Appends a new stat reading.
56
56
  */
57
57
  recordStat(req: Request): Promise<Response>;
58
+ /**
59
+ * GET /...?app_id=<id>[&days=<n>]
60
+ * Returns windowed roll-ups (total/d1/d7/d28/d90 + daily series) for
61
+ * every metric registered against app_id via registerMetrics().
62
+ */
63
+ getMetricWindows(req: Request): Promise<Response>;
58
64
  };
59
65
  export declare function createExperimentHandlers(options: ApiFactoryOptions): {
60
66
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
package/dist/api/index.js CHANGED
@@ -6,6 +6,7 @@
6
6
  // or callers supply their own `getAuth` implementation.
7
7
  import { createCrudService } from 'hazo_connect/server';
8
8
  import { recordStat as _recordStat, getLatestStat as _getLatestStat, getStatSeries as _getStatSeries, } from '../server/stats.js';
9
+ import { getMetricWindows as _getMetricWindows } from '../server/metrics.js';
9
10
  import { resolveVariant } from '../server/flags.js';
10
11
  import { getActivitySummary as _getActivitySummary, getActivityTrend as _getActivityTrend, } from '../server/activity.js';
11
12
  import { ensureActionDef as _ensureActionDef, listActions as _listActions, getActionTree as _getActionTree, updateActionDef as _updateActionDef, updateActionDefBulk as _updateActionDefBulk, applyLinksBulk as _applyLinksBulk, getLinks as _getLinks, addLink as _addLink, removeLink as _removeLink, } from '../server/actions.js';
@@ -162,6 +163,30 @@ export function createStatHandlers(options) {
162
163
  });
163
164
  return jsonOk({ recorded: true }, 201);
164
165
  },
166
+ /**
167
+ * GET /...?app_id=<id>[&days=<n>]
168
+ * Returns windowed roll-ups (total/d1/d7/d28/d90 + daily series) for
169
+ * every metric registered against app_id via registerMetrics().
170
+ */
171
+ async getMetricWindows(req) {
172
+ const url = new URL(req.url);
173
+ const app_id = url.searchParams.get('app_id') ?? '';
174
+ const daysParam = url.searchParams.get('days');
175
+ const days = daysParam != null ? parseInt(daysParam, 10) : 90;
176
+ const gate = await gateAuth(req, {
177
+ required_permissions: ['metrics.view'],
178
+ app_id: app_id || undefined,
179
+ getAuth,
180
+ });
181
+ if (!gate.ok)
182
+ return gate.response;
183
+ if (!app_id) {
184
+ return jsonError('BAD_REQUEST', 'app_id is required', 400);
185
+ }
186
+ const adapter = await options.getAdapter();
187
+ const windows = await _getMetricWindows(adapter, { app_id, days });
188
+ return jsonOk({ windows });
189
+ },
165
190
  };
166
191
  }
167
192
  export function createExperimentHandlers(options) {
@@ -1,5 +1,6 @@
1
1
  export * from '../server/types.js';
2
2
  export * from '../server/stats.js';
3
+ export * from '../server/metrics.js';
3
4
  export * from '../server/actions.js';
4
5
  export * from '../server/flags.js';
5
6
  export * from '../server/cookie.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/lib/index.ts"],"names":[],"mappings":"AACA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC;AACzC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC;AACtC,cAAc,sBAAsB,CAAC"}
package/dist/lib/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // hazo_umetrics/src/lib/index.ts — re-exports server-side modules
2
2
  export * from '../server/types.js';
3
3
  export * from '../server/stats.js';
4
+ export * from '../server/metrics.js';
4
5
  export * from '../server/actions.js';
5
6
  export * from '../server/flags.js';
6
7
  export * from '../server/cookie.js';
@@ -0,0 +1,62 @@
1
+ import type { HazoConnectAdapter } from 'hazo_connect/server';
2
+ import type { MetricDef, MetricWindow, MaintenanceJob } from './types.js';
3
+ /** Formats a Date as a 'YYYY-MM-DD' string in UTC. */
4
+ export declare function dayString(d: Date): string;
5
+ /** Returns the 'YYYY-MM-DD' string for `days` days before `from` (UTC). */
6
+ export declare function daysAgoString(days: number, from?: Date): string;
7
+ /**
8
+ * Registers metric definitions for an app. Idempotent by (app_id, key):
9
+ * re-registering replaces the def with the same key, leaves others intact.
10
+ * Safe to call repeatedly (HMR / cold-start) — last registration per key wins.
11
+ */
12
+ export declare function registerMetrics(params: {
13
+ app_id: string;
14
+ metrics: MetricDef[];
15
+ }): void;
16
+ /** Returns the metric defs registered for an app (empty array if none). */
17
+ export declare function getRegisteredMetrics(app_id: string): MetricDef[];
18
+ /**
19
+ * Returns a MetricWindow (total + d1/d7/d28/d90 deltas + daily series) for
20
+ * each metric registered against app_id. ga4-sourced metrics are returned as
21
+ * pending stubs (M1 wires GA4 Data API reads). db-sourced metrics are read
22
+ * from the stat store per-key via getStatSeries (never a shared query), then
23
+ * collapsed to one point per UTC day before diffing.
24
+ */
25
+ export declare function getMetricWindows(adapter: HazoConnectAdapter, params: {
26
+ app_id: string;
27
+ scope_id?: string;
28
+ days?: number;
29
+ }): Promise<MetricWindow[]>;
30
+ /**
31
+ * Runs collect() for every registered db metric (those with a `collect` fn)
32
+ * and appends one stat row per metric — unless a snapshot for today (UTC)
33
+ * already exists, in which case it's skipped. ga4 metrics are never touched.
34
+ */
35
+ export declare function runMetricSnapshot(adapter: HazoConnectAdapter, params: {
36
+ app_id: string;
37
+ }): Promise<Array<{
38
+ key: string;
39
+ value: number;
40
+ skipped: boolean;
41
+ }>>;
42
+ /**
43
+ * MaintenanceJob-shaped export for M1 job wiring — daily metric snapshots.
44
+ */
45
+ export declare const metricSnapshotJob: MaintenanceJob;
46
+ /**
47
+ * Backfills daily snapshot rows for a db metric over [from, to] (UTC,
48
+ * 'YYYY-MM-DD', inclusive; `to` defaults to today). For each day, if a
49
+ * snapshot already exists it's skipped (idempotent); otherwise `valueAsOf`
50
+ * is called for that day and recorded with captured_at = day @ 00:00:00Z.
51
+ */
52
+ export declare function backfillDailySnapshots(adapter: HazoConnectAdapter, params: {
53
+ app_id: string;
54
+ metric_key: string;
55
+ valueAsOf: (date: string) => Promise<number>;
56
+ from: string;
57
+ to?: string;
58
+ }): Promise<{
59
+ inserted: number;
60
+ skipped: number;
61
+ }>;
62
+ //# sourceMappingURL=metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics.d.ts","sourceRoot":"","sources":["../../src/server/metrics.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,SAAS,EAAqB,YAAY,EAAE,cAAc,EAAQ,MAAM,YAAY,CAAC;AAOnG,sDAAsD;AACtD,wBAAgB,SAAS,CAAC,CAAC,EAAE,IAAI,GAAG,MAAM,CAEzC;AAED,2EAA2E;AAC3E,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,IAAiB,GAAG,MAAM,CAE3E;AAyDD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,SAAS,EAAE,CAAA;CAAE,GAAG,IAAI,CAYtF;AAED,2EAA2E;AAC3E,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,CAEhE;AAMD;;;;;;GAMG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAC3D,OAAO,CAAC,YAAY,EAAE,CAAC,CAoDzB;AA0BD;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GACzB,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC,CAAC,CA+BlE;AAED;;GAEG;AACH,eAAO,MAAM,iBAAiB,EAAE,cAM/B,CAAC;AAMF;;;;;GAKG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7C,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,CAAC,EAAE,MAAM,CAAC;CACb,GACA,OAAO,CAAC;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC,CA0BhD"}
@@ -0,0 +1,243 @@
1
+ // hazo_umetrics/src/server/metrics.ts — metric registry, windowed roll-ups
2
+ // (total / d1 / d7 / d28 / d90), daily snapshot job, and historical backfill.
3
+ //
4
+ // Mirrors src/server/stats.ts conventions: append-only readings via
5
+ // recordStat/getStatSeries, module-level registry, MaintenanceJob export.
6
+ import { recordStat, getStatSeries } from './stats.js';
7
+ // ---------------------------------------------------------------------------
8
+ // Day-string helpers (UTC throughout, pure + testable)
9
+ // ---------------------------------------------------------------------------
10
+ /** Formats a Date as a 'YYYY-MM-DD' string in UTC. */
11
+ export function dayString(d) {
12
+ return d.toISOString().slice(0, 10);
13
+ }
14
+ /** Returns the 'YYYY-MM-DD' string for `days` days before `from` (UTC). */
15
+ export function daysAgoString(days, from = new Date()) {
16
+ return dayString(new Date(from.getTime() - days * 86400000));
17
+ }
18
+ /** Enumerates 'YYYY-MM-DD' strings from `from` to `to` inclusive (UTC). */
19
+ function iterateDaysUTC(from, to) {
20
+ const days = [];
21
+ let cursor = new Date(`${from}T00:00:00.000Z`).getTime();
22
+ const end = new Date(`${to}T00:00:00.000Z`).getTime();
23
+ while (cursor <= end) {
24
+ days.push(new Date(cursor).toISOString().slice(0, 10));
25
+ cursor += 86400000;
26
+ }
27
+ return days;
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // Series helpers
31
+ // ---------------------------------------------------------------------------
32
+ /**
33
+ * Collapses raw (ascending-by-captured_at) stat readings to one point per
34
+ * UTC day. Last-write-wins: when multiple readings land on the same day,
35
+ * the one with the latest captured_at (the last one encountered, since
36
+ * input is ascending) wins. Result is sorted ascending by date.
37
+ */
38
+ function collapseToDailyPoints(stats) {
39
+ const byDay = new Map();
40
+ for (const s of stats) {
41
+ byDay.set(s.captured_at.slice(0, 10), s.value);
42
+ }
43
+ return Array.from(byDay.entries())
44
+ .map(([date, value]) => ({ date, value }))
45
+ .sort((a, b) => (a.date < b.date ? -1 : a.date > b.date ? 1 : 0));
46
+ }
47
+ /**
48
+ * Value of the metric as of `targetDate` — the last daily point whose date
49
+ * is <= targetDate (walks back over gap days). Returns null when no such
50
+ * point exists (targetDate predates the first point, or there are no points).
51
+ */
52
+ function valueAsOfDate(points, targetDate) {
53
+ let result = null;
54
+ for (const p of points) {
55
+ if (p.date <= targetDate) {
56
+ result = p.value;
57
+ }
58
+ else {
59
+ break;
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ // ---------------------------------------------------------------------------
65
+ // Metric registry
66
+ // ---------------------------------------------------------------------------
67
+ const _metricsRegistry = new Map();
68
+ /**
69
+ * Registers metric definitions for an app. Idempotent by (app_id, key):
70
+ * re-registering replaces the def with the same key, leaves others intact.
71
+ * Safe to call repeatedly (HMR / cold-start) — last registration per key wins.
72
+ */
73
+ export function registerMetrics(params) {
74
+ const existing = _metricsRegistry.get(params.app_id) ?? [];
75
+ const merged = [...existing];
76
+ for (const def of params.metrics) {
77
+ const idx = merged.findIndex((m) => m.key === def.key);
78
+ if (idx >= 0) {
79
+ merged[idx] = def;
80
+ }
81
+ else {
82
+ merged.push(def);
83
+ }
84
+ }
85
+ _metricsRegistry.set(params.app_id, merged);
86
+ }
87
+ /** Returns the metric defs registered for an app (empty array if none). */
88
+ export function getRegisteredMetrics(app_id) {
89
+ return _metricsRegistry.get(app_id) ?? [];
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Windowed roll-ups
93
+ // ---------------------------------------------------------------------------
94
+ /**
95
+ * Returns a MetricWindow (total + d1/d7/d28/d90 deltas + daily series) for
96
+ * each metric registered against app_id. ga4-sourced metrics are returned as
97
+ * pending stubs (M1 wires GA4 Data API reads). db-sourced metrics are read
98
+ * from the stat store per-key via getStatSeries (never a shared query), then
99
+ * collapsed to one point per UTC day before diffing.
100
+ */
101
+ export async function getMetricWindows(adapter, params) {
102
+ const { app_id, scope_id, days = 90 } = params;
103
+ const now = new Date();
104
+ const since = new Date(now.getTime() - days * 86400000).toISOString();
105
+ const windows = [];
106
+ for (const def of getRegisteredMetrics(app_id)) {
107
+ if (def.source === 'ga4') {
108
+ windows.push({
109
+ metric_key: def.key,
110
+ label: def.label,
111
+ format: def.format,
112
+ source: 'ga4',
113
+ pending: true,
114
+ total: null,
115
+ d1: null,
116
+ d7: null,
117
+ d28: null,
118
+ d90: null,
119
+ series: [],
120
+ });
121
+ continue;
122
+ }
123
+ const stats = await getStatSeries(adapter, { app_id, scope_id, metric_key: def.key, since });
124
+ const points = collapseToDailyPoints(stats);
125
+ const total = points.length > 0 ? points[points.length - 1].value : null;
126
+ const delta = (n) => {
127
+ if (total == null)
128
+ return null;
129
+ const asOf = valueAsOfDate(points, daysAgoString(n, now));
130
+ if (asOf == null)
131
+ return null;
132
+ return total - asOf;
133
+ };
134
+ windows.push({
135
+ metric_key: def.key,
136
+ label: def.label,
137
+ format: def.format,
138
+ source: 'db',
139
+ pending: false,
140
+ total,
141
+ d1: delta(1),
142
+ d7: delta(7),
143
+ d28: delta(28),
144
+ d90: delta(90),
145
+ series: points,
146
+ });
147
+ }
148
+ return windows;
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // Daily snapshot job
152
+ // ---------------------------------------------------------------------------
153
+ /**
154
+ * Checks whether a snapshot row already exists for (app_id, metric_key) on
155
+ * `day` ('YYYY-MM-DD', UTC). Relies on getStatSeries returning rows ascending
156
+ * by captured_at: the first row at/after the start of `day` is either on
157
+ * `day` (a match) or a later day (no match) — so limit:1 suffices.
158
+ */
159
+ async function hasSnapshotForDay(adapter, params) {
160
+ const rows = await getStatSeries(adapter, {
161
+ app_id: params.app_id,
162
+ scope_id: params.scope_id,
163
+ metric_key: params.metric_key,
164
+ since: `${params.day}T00:00:00.000Z`,
165
+ limit: 1,
166
+ });
167
+ return rows.length > 0 && rows[0].captured_at.slice(0, 10) === params.day;
168
+ }
169
+ /**
170
+ * Runs collect() for every registered db metric (those with a `collect` fn)
171
+ * and appends one stat row per metric — unless a snapshot for today (UTC)
172
+ * already exists, in which case it's skipped. ga4 metrics are never touched.
173
+ */
174
+ export async function runMetricSnapshot(adapter, params) {
175
+ const now = new Date();
176
+ const today = dayString(now);
177
+ const results = [];
178
+ const dbMetrics = getRegisteredMetrics(params.app_id).filter((m) => m.source === 'db' && typeof m.collect === 'function');
179
+ for (const metric of dbMetrics) {
180
+ const already = await hasSnapshotForDay(adapter, {
181
+ app_id: params.app_id,
182
+ metric_key: metric.key,
183
+ day: today,
184
+ });
185
+ if (already) {
186
+ results.push({ key: metric.key, value: 0, skipped: true });
187
+ continue;
188
+ }
189
+ const value = await metric.collect(params.app_id);
190
+ await recordStat(adapter, {
191
+ app_id: params.app_id,
192
+ metric_key: metric.key,
193
+ value,
194
+ captured_at: now.toISOString(),
195
+ });
196
+ results.push({ key: metric.key, value, skipped: false });
197
+ }
198
+ return results;
199
+ }
200
+ /**
201
+ * MaintenanceJob-shaped export for M1 job wiring — daily metric snapshots.
202
+ */
203
+ export const metricSnapshotJob = {
204
+ type: 'hazo_umetrics.metric_snapshot',
205
+ defaultCron: '0 3 * * *',
206
+ handler: async ({ app_id, adapter }) => {
207
+ return runMetricSnapshot(adapter, { app_id });
208
+ },
209
+ };
210
+ // ---------------------------------------------------------------------------
211
+ // Historical backfill
212
+ // ---------------------------------------------------------------------------
213
+ /**
214
+ * Backfills daily snapshot rows for a db metric over [from, to] (UTC,
215
+ * 'YYYY-MM-DD', inclusive; `to` defaults to today). For each day, if a
216
+ * snapshot already exists it's skipped (idempotent); otherwise `valueAsOf`
217
+ * is called for that day and recorded with captured_at = day @ 00:00:00Z.
218
+ */
219
+ export async function backfillDailySnapshots(adapter, params) {
220
+ const to = params.to ?? dayString(new Date());
221
+ let inserted = 0;
222
+ let skipped = 0;
223
+ for (const day of iterateDaysUTC(params.from, to)) {
224
+ const already = await hasSnapshotForDay(adapter, {
225
+ app_id: params.app_id,
226
+ metric_key: params.metric_key,
227
+ day,
228
+ });
229
+ if (already) {
230
+ skipped++;
231
+ continue;
232
+ }
233
+ const value = await params.valueAsOf(day);
234
+ await recordStat(adapter, {
235
+ app_id: params.app_id,
236
+ metric_key: params.metric_key,
237
+ value,
238
+ captured_at: `${day}T00:00:00.000Z`,
239
+ });
240
+ inserted++;
241
+ }
242
+ return { inserted, skipped };
243
+ }
@@ -2,6 +2,8 @@ import type { HazoConnectAdapter } from 'hazo_connect/server';
2
2
  import type { Stat, StatCollectorDef, MaintenanceJob } from './types.js';
3
3
  /**
4
4
  * Records a new stat reading. Always inserts — append-only, never upserts.
5
+ * Pass `captured_at` to backdate/day-stamp a row (e.g. for backfill or
6
+ * daily snapshots); defaults to the current timestamp.
5
7
  */
6
8
  export declare function recordStat(adapter: HazoConnectAdapter, params: {
7
9
  app_id: string;
@@ -10,6 +12,7 @@ export declare function recordStat(adapter: HazoConnectAdapter, params: {
10
12
  value: number;
11
13
  unit?: string;
12
14
  dimensions?: Record<string, string | number>;
15
+ captured_at?: string;
13
16
  }): Promise<void>;
14
17
  /**
15
18
  * Returns the most recent reading for a metric in an app, or null if none.
@@ -1 +1 @@
1
- {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/server/stats.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAmDzE;;GAEG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;CAC9C,GACA,OAAO,CAAC,IAAI,CAAC,CAef;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAChE,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAatB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,IAAI,EAAE,CAAC,CAmBjB;AAQD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CAMjE;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GACzB,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAchD;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,cAM7B,CAAC"}
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/server/stats.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC9D,OAAO,KAAK,EAAE,IAAI,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAmDzE;;;;GAIG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACA,OAAO,CAAC,IAAI,CAAC,CAef;AAED;;GAEG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,GAChE,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAatB;AAED;;;GAGG;AACH,wBAAsB,aAAa,CACjC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IACN,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GACA,OAAO,CAAC,IAAI,EAAE,CAAC,CAmBjB;AAQD;;;GAGG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,gBAAgB,GAAG,IAAI,CAMjE;AAED;;;GAGG;AACH,wBAAsB,eAAe,CACnC,OAAO,EAAE,kBAAkB,EAC3B,MAAM,EAAE;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GACzB,OAAO,CAAC,KAAK,CAAC;IAAE,GAAG,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,CAchD;AAED;;;GAGG;AACH,eAAO,MAAM,eAAe,EAAE,cAM7B,CAAC"}
@@ -33,6 +33,8 @@ function getCrud(adapter) {
33
33
  // ---------------------------------------------------------------------------
34
34
  /**
35
35
  * Records a new stat reading. Always inserts — append-only, never upserts.
36
+ * Pass `captured_at` to backdate/day-stamp a row (e.g. for backfill or
37
+ * daily snapshots); defaults to the current timestamp.
36
38
  */
37
39
  export async function recordStat(adapter, params) {
38
40
  const crud = getCrud(adapter);
@@ -46,7 +48,7 @@ export async function recordStat(adapter, params) {
46
48
  value: params.value,
47
49
  unit: params.unit ?? null,
48
50
  dimensions: dimensionsJson,
49
- captured_at: new Date().toISOString(),
51
+ captured_at: params.captured_at ?? new Date().toISOString(),
50
52
  });
51
53
  }
52
54
  /**
@@ -99,4 +99,30 @@ export interface StatCollectorDef {
99
99
  cadence?: string;
100
100
  collect: (app_id: string) => Promise<number>;
101
101
  }
102
+ export interface MetricDef {
103
+ key: string;
104
+ label: string;
105
+ format: StatDef['format'];
106
+ source: 'db' | 'ga4';
107
+ description?: string;
108
+ collect?: (app_id: string) => Promise<number>;
109
+ backfill?: (app_id: string, date: string) => Promise<number>;
110
+ }
111
+ export interface MetricSeriesPoint {
112
+ date: string;
113
+ value: number;
114
+ }
115
+ export interface MetricWindow {
116
+ metric_key: string;
117
+ label: string;
118
+ format: MetricDef['format'];
119
+ source: 'db' | 'ga4';
120
+ pending: boolean;
121
+ total: number | null;
122
+ d1: number | null;
123
+ d7: number | null;
124
+ d28: number | null;
125
+ d90: number | null;
126
+ series: MetricSeriesPoint[];
127
+ }
102
128
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/server/types.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,5 @@
1
1
  export * from './metrics_panel.js';
2
+ export * from './metrics_tab.js';
2
3
  export * from './actions_panel.js';
3
4
  export * from './analytics_panel.js';
4
5
  export * from './datapoint_panel.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAGA,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC;AACrC,cAAc,oBAAoB,CAAC;AACnC,cAAc,2BAA2B,CAAC"}
package/dist/ui/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // hazo_umetrics/src/ui/index.ts — Client-safe UI components (no Node.js imports).
2
2
  // Full MetricsPanel wiring is deferred to M1 interactive session.
3
3
  export * from './metrics_panel.js';
4
+ export * from './metrics_tab.js';
4
5
  export * from './actions_panel.js';
5
6
  export * from './analytics_panel.js';
6
7
  export * from './datapoint_panel.js';
@@ -1 +1 @@
1
- {"version":3,"file":"journey_panel.d.ts","sourceRoot":"","sources":["../../src/ui/journey_panel.tsx"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,KAAK,EAAE,YAAY,CAAC;CACrB;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC1I,iGAAiG;IACjG,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oFAAoF;IACpF,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAotBD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,cAAqB,EAAE,YAAmB,EAAE,EAAE,iBAAiB,+BA+QrJ"}
1
+ {"version":3,"file":"journey_panel.d.ts","sourceRoot":"","sources":["../../src/ui/journey_panel.tsx"],"names":[],"mappings":"AAiBA,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;IACpB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IACtC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,EAAE,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,OAAO,GAAG,OAAO,CAAC;IAC1D,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,KAAK,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD;AAED,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,gBAAgB,EAAE,CAAC;IAC1B,KAAK,EAAE,gBAAgB,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,EAAE,CAAC;IAC3B,KAAK,EAAE,YAAY,CAAC;CACrB;AAMD,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,mBAAmB,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAAC;IAC1I,iGAAiG;IACjG,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,oFAAoF;IACpF,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AAitBD,wBAAgB,YAAY,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,mBAAmB,EAAE,cAAqB,EAAE,YAAmB,EAAE,EAAE,iBAAiB,+BA+QrJ"}
@@ -162,10 +162,7 @@ function buildEdgePath(sx, sy, tx, ty, multiSidePorts, backEdgeLoop) {
162
162
  let exitSide = 'right';
163
163
  let entrySide = 'left';
164
164
  if (dx > COL_TOL) {
165
- // Forward edge. Steep ones enter top/bottom so they don't crowd the left.
166
- if (multiSidePorts && Math.abs(dyc) > Math.max(NODE_H * 1.5, dx * 0.8)) {
167
- entrySide = dyc > 0 ? 'top' : 'bottom';
168
- }
165
+ // Forward edge: clean right -> left horizontal cubic. Keep default ports.
169
166
  }
170
167
  else if (dx < -COL_TOL) {
171
168
  // Back edge: target is to the left.
@@ -184,7 +181,7 @@ function buildEdgePath(sx, sy, tx, ty, multiSidePorts, backEdgeLoop) {
184
181
  const e = portPoint(sx, sy, exitSide);
185
182
  const n = portPoint(tx, ty, entrySide);
186
183
  const dist = Math.hypot(n.px - e.px, n.py - e.py);
187
- const k = Math.max(40, dist * 0.5);
184
+ const k = Math.max(30, Math.min(dist * 0.4, 120));
188
185
  const c1x = e.px + e.ux * k, c1y = e.py + e.uy * k;
189
186
  const c2x = n.px + n.ux * k, c2y = n.py + n.uy * k;
190
187
  const d = `M${e.px},${e.py} C${c1x},${c1y} ${c2x},${c2y} ${n.px},${n.py}`;
@@ -306,7 +303,7 @@ function JourneyFlowGraph({ graph, userFilter, loading, multiSidePorts = true, b
306
303
  ['action', '#6366f1'],
307
304
  ['error', '#ef4444'],
308
305
  ['other', '#94a3b8'],
309
- ].map(([kind, color]) => (_jsxs("label", { className: "flex items-center gap-1 cursor-pointer select-none", title: `Toggle ${kind} nodes`, children: [_jsx("input", { type: "checkbox", checked: visibleKinds[kind], onChange: (e) => setVisibleKinds(v => ({ ...v, [kind]: e.target.checked })), className: "w-3 h-3 cursor-pointer", style: { accentColor: color } }), kind] }, kind))), _jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => setZoom(z => clampZoom(z * 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom in", children: "+" }), _jsx("button", { onClick: () => setZoom(z => clampZoom(z / 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom out", children: "\u2212" }), _jsx("button", { onClick: fitToScreen, className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Fit to screen", children: "\u22A1" }), _jsx("button", { onClick: () => setSelectedNode(null), disabled: !hasSelection, className: `px-1.5 py-0.5 text-xs border rounded flex items-center ${hasSelection ? 'bg-indigo-50 border-indigo-300 text-indigo-600 hover:bg-indigo-100' : 'text-gray-300 cursor-default'}`, title: "Clear selection", children: _jsx(MousePointer2, { className: "w-3 h-3" }) }), _jsx("button", { onClick: toggleFullscreen, className: "px-1.5 py-0.5 text-xs border rounded flex items-center hover:bg-gray-50", title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', children: isFullscreen ? _jsx(Minimize2, { className: "w-3 h-3" }) : _jsx(Maximize2, { className: "w-3 h-3" }) })] })] })] }), _jsxs("div", { ref: containerRef, style: { position: 'relative', height: isFullscreen ? undefined : '500px', flex: isFullscreen ? 1 : undefined, overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab' }, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block', transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom})`, transformOrigin: '0 0', userSelect: 'none' }, children: [_jsx("defs", { children: _jsx("marker", { id: "arrow", markerWidth: "8", markerHeight: "8", refX: "7", refY: "3", orient: "auto", children: _jsx("path", { d: "M0,0 L0,6 L8,3 z", fill: "#94a3b8" }) }) }), vEdges.map((edge, i) => {
306
+ ].map(([kind, color]) => (_jsxs("label", { className: "flex items-center gap-1 cursor-pointer select-none", title: `Toggle ${kind} nodes`, children: [_jsx("input", { type: "checkbox", checked: visibleKinds[kind], onChange: (e) => setVisibleKinds(v => ({ ...v, [kind]: e.target.checked })), className: "w-3 h-3 cursor-pointer", style: { accentColor: color } }), kind] }, kind))), _jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("button", { onClick: () => setZoom(z => clampZoom(z * 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom in", children: "+" }), _jsx("button", { onClick: () => setZoom(z => clampZoom(z / 1.25)), className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Zoom out", children: "\u2212" }), _jsx("button", { onClick: fitToScreen, className: "px-1.5 py-0.5 text-xs border rounded hover:bg-gray-50", title: "Fit to screen", children: "\u22A1" }), _jsx("button", { onClick: () => setSelectedNode(null), disabled: !hasSelection, className: `px-1.5 py-0.5 text-xs border rounded flex items-center ${hasSelection ? 'bg-indigo-50 border-indigo-300 text-indigo-600 hover:bg-indigo-100' : 'text-gray-300 cursor-default'}`, title: "Clear selection", children: _jsx(MousePointer2, { className: "w-3 h-3" }) }), _jsx("button", { onClick: toggleFullscreen, className: "px-1.5 py-0.5 text-xs border rounded flex items-center hover:bg-gray-50", title: isFullscreen ? 'Exit fullscreen' : 'Fullscreen', children: isFullscreen ? _jsx(Minimize2, { className: "w-3 h-3" }) : _jsx(Maximize2, { className: "w-3 h-3" }) })] })] })] }), _jsxs("div", { ref: containerRef, style: { position: 'relative', height: isFullscreen ? undefined : '500px', flex: isFullscreen ? 1 : undefined, overflow: 'hidden', cursor: dragging ? 'grabbing' : 'grab' }, onMouseDown: handleMouseDown, onMouseMove: handleMouseMove, onMouseUp: handleMouseUp, onMouseLeave: handleMouseUp, children: [_jsxs("svg", { width: svgW, height: Math.max(svgH, 200), style: { display: 'block', transform: `translate(${pan.x}px,${pan.y}px) scale(${zoom})`, transformOrigin: '0 0', userSelect: 'none' }, children: [_jsx("defs", { children: _jsx("marker", { id: "arrow", markerWidth: "8", markerHeight: "8", refX: "7", refY: "3", orient: "auto", markerUnits: "userSpaceOnUse", children: _jsx("path", { d: "M0,0 L0,6 L8,3 z", fill: "#94a3b8" }) }) }), vEdges.map((edge, i) => {
310
307
  const from = pos.get(edge.from);
311
308
  const to = pos.get(edge.to);
312
309
  if (!from || !to)
@@ -387,7 +384,7 @@ function IdentityDropdown({ value, onChange, userIds, userSessions, anonCount, p
387
384
  if (id === ANON_IDENTITY)
388
385
  return 'Anonymous';
389
386
  const p = profiles.get(id);
390
- return p?.email ?? p?.name ?? `${id.slice(0, 12)}…`;
387
+ return p?.name ?? p?.email ?? `${id.slice(0, 12)}…`;
391
388
  }
392
389
  function avatarFor(id) {
393
390
  if (id === '' || id === ANON_IDENTITY)
@@ -0,0 +1,30 @@
1
+ interface MetricWindow {
2
+ metric_key: string;
3
+ label: string;
4
+ format: 'count' | 'currency' | 'duration' | 'percent';
5
+ source: 'db' | 'ga4';
6
+ pending: boolean;
7
+ total: number | null;
8
+ d1: number | null;
9
+ d7: number | null;
10
+ d28: number | null;
11
+ d90: number | null;
12
+ series: {
13
+ date: string;
14
+ value: number;
15
+ }[];
16
+ }
17
+ export interface WindowGridProps {
18
+ windows: MetricWindow[];
19
+ }
20
+ export declare function WindowGrid({ windows }: WindowGridProps): import("react").JSX.Element;
21
+ export interface GraphProps {
22
+ windows: MetricWindow[];
23
+ }
24
+ export declare function Graph({ windows }: GraphProps): import("react").JSX.Element;
25
+ export interface MetricsTabProps {
26
+ appId: string;
27
+ }
28
+ export declare function MetricsTab({ appId }: MetricsTabProps): import("react").JSX.Element;
29
+ export {};
30
+ //# sourceMappingURL=metrics_tab.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"metrics_tab.d.ts","sourceRoot":"","sources":["../../src/ui/metrics_tab.tsx"],"names":[],"mappings":"AAUA,UAAU,YAAY;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,GAAG,UAAU,GAAG,UAAU,GAAG,SAAS,CAAC;IACtD,MAAM,EAAE,IAAI,GAAG,KAAK,CAAC;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAClB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACnB,MAAM,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC3C;AAwDD,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,YAAY,EAAE,CAAC;CACzB;AAED,wBAAgB,UAAU,CAAC,EAAE,OAAO,EAAE,EAAE,eAAe,+BAwDtD;AAMD,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,YAAY,EAAE,CAAC;CACzB;AAOD,wBAAgB,KAAK,CAAC,EAAE,OAAO,EAAE,EAAE,UAAU,+BAgF5C;AAMD,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,MAAM,CAAC;CACf;AAgBD,wBAAgB,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,eAAe,+BAiHpD"}
@@ -0,0 +1,147 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useState, useCallback, useMemo } from 'react';
4
+ import { RotateCw } from 'lucide-react';
5
+ import { LineChart } from 'hazo_dataviz/client';
6
+ // ---------------------------------------------------------------------------
7
+ // Helpers
8
+ // ---------------------------------------------------------------------------
9
+ function formatValue(value, format) {
10
+ switch (format) {
11
+ case 'currency': return `$${value.toLocaleString()}`;
12
+ case 'percent': return `${value}%`;
13
+ case 'duration': return `${value}s`;
14
+ default: return value.toLocaleString();
15
+ }
16
+ }
17
+ function formatDelta(value, format) {
18
+ if (value == null) {
19
+ return { text: '—', className: 'text-muted-foreground' };
20
+ }
21
+ if (value === 0) {
22
+ return { text: formatValue(0, format), className: 'text-muted-foreground' };
23
+ }
24
+ const sign = value > 0 ? '+' : '-';
25
+ const magnitude = formatValue(Math.abs(value), format);
26
+ return {
27
+ text: `${sign}${magnitude}`,
28
+ className: value > 0 ? 'text-green-600' : 'text-red-600',
29
+ };
30
+ }
31
+ function formatTrendLabel(date) {
32
+ const d = new Date(date);
33
+ if (Number.isNaN(d.getTime()))
34
+ return date;
35
+ return d.toLocaleString(undefined, { month: 'short', day: 'numeric' });
36
+ }
37
+ const TIME_FILTERS = [
38
+ { key: '1d', label: '24h', days: 1 },
39
+ { key: '7d', label: '7d', days: 7 },
40
+ { key: '28d', label: '28d', days: 28 },
41
+ { key: '90d', label: '90d', days: 90 },
42
+ { key: 'all', label: 'All', days: null },
43
+ ];
44
+ export function WindowGrid({ windows }) {
45
+ if (windows.length === 0) {
46
+ return (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No metrics yet" }));
47
+ }
48
+ return (_jsx("div", { className: "overflow-x-auto rounded-lg border", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b text-xs uppercase tracking-wide text-muted-foreground", children: [_jsx("th", { className: "px-3 py-2 text-left font-medium", children: "Metric" }), _jsx("th", { className: "px-3 py-2 text-right font-medium", children: "Total" }), _jsx("th", { className: "px-3 py-2 text-right font-medium", children: "24h" }), _jsx("th", { className: "px-3 py-2 text-right font-medium", children: "7d" }), _jsx("th", { className: "px-3 py-2 text-right font-medium", children: "28d" }), _jsx("th", { className: "px-3 py-2 text-right font-medium", children: "90d" })] }) }), _jsx("tbody", { children: windows.map((w) => {
49
+ if (w.pending) {
50
+ return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsx("td", { className: "px-3 py-2 font-medium", children: w.label }), _jsx("td", { colSpan: 5, className: "px-3 py-2", children: _jsx("span", { className: "inline-flex items-center gap-1.5 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground", children: "\u25A2 GA4 pending \u2014 connect GA4 to enable" }) })] }, w.metric_key));
51
+ }
52
+ const d1 = formatDelta(w.d1, w.format);
53
+ const d7 = formatDelta(w.d7, w.format);
54
+ const d28 = formatDelta(w.d28, w.format);
55
+ const d90 = formatDelta(w.d90, w.format);
56
+ return (_jsxs("tr", { className: "border-b last:border-b-0", children: [_jsx("td", { className: "px-3 py-2 font-medium", children: w.label }), _jsx("td", { className: "px-3 py-2 text-right font-semibold", children: w.total == null ? '—' : formatValue(w.total, w.format) }), _jsx("td", { className: `px-3 py-2 text-right ${d1.className}`, children: d1.text }), _jsx("td", { className: `px-3 py-2 text-right ${d7.className}`, children: d7.text }), _jsx("td", { className: `px-3 py-2 text-right ${d28.className}`, children: d28.text }), _jsx("td", { className: `px-3 py-2 text-right ${d90.className}`, children: d90.text })] }, w.metric_key));
57
+ }) })] }) }));
58
+ }
59
+ function sliceSeries(series, days) {
60
+ if (days == null)
61
+ return series;
62
+ return series.slice(-days);
63
+ }
64
+ export function Graph({ windows }) {
65
+ const graphable = useMemo(() => windows.filter((w) => !w.pending && w.series.length > 0), [windows]);
66
+ const [metricKey, setMetricKey] = useState(() => graphable[0]?.metric_key ?? null);
67
+ const [timeFilter, setTimeFilter] = useState('28d');
68
+ useEffect(() => {
69
+ if (metricKey == null || !graphable.some((w) => w.metric_key === metricKey)) {
70
+ setMetricKey(graphable[0]?.metric_key ?? null);
71
+ }
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [graphable]);
74
+ if (graphable.length === 0) {
75
+ return (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No trend data yet" }));
76
+ }
77
+ const selected = graphable.find((w) => w.metric_key === metricKey) ?? graphable[0];
78
+ const filterDef = TIME_FILTERS.find((f) => f.key === timeFilter) ?? TIME_FILTERS[2];
79
+ const points = sliceSeries(selected.series, filterDef.days);
80
+ return (_jsxs("div", { className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [_jsx("div", { className: "flex items-center gap-1", children: TIME_FILTERS.map((f) => (_jsx("button", { onClick: () => setTimeFilter(f.key), className: `rounded-md border px-2.5 py-1 text-xs transition-colors ${timeFilter === f.key
81
+ ? 'border-indigo-500 bg-indigo-50 text-indigo-700'
82
+ : 'text-muted-foreground hover:bg-muted'}`, children: f.label }, f.key))) }), _jsxs("label", { className: "flex items-center gap-1.5 text-xs text-muted-foreground", children: ["metric:", _jsx("select", { value: selected.metric_key, onChange: (e) => setMetricKey(e.target.value), className: "rounded-md border px-2 py-1 text-xs", children: graphable.map((w) => (_jsx("option", { value: w.metric_key, children: w.label }, w.metric_key))) })] })] }), points.length < 2 ? (_jsx("div", { className: "rounded-lg border p-4 text-sm text-muted-foreground", children: "No trend data yet" })) : (_jsx("div", { className: "rounded-lg border p-4", children: _jsx(LineChart, { data: points.map((p) => p.value), dates: points.map((p) => formatTrendLabel(p.date)), color: "#6366f1", showTooltip: true }) }))] }));
83
+ }
84
+ const INITIAL_STATE = {
85
+ loading: true,
86
+ unauthenticated: false,
87
+ error: null,
88
+ windows: [],
89
+ };
90
+ export function MetricsTab({ appId }) {
91
+ const [state, setState] = useState(INITIAL_STATE);
92
+ const [refreshKey, setRefreshKey] = useState(0);
93
+ const refresh = useCallback(() => setRefreshKey((k) => k + 1), []);
94
+ useEffect(() => {
95
+ if (!appId)
96
+ return;
97
+ let cancelled = false;
98
+ async function fetchAll() {
99
+ setState(INITIAL_STATE);
100
+ const res = await fetch(`/api/hazo_umetrics/stats/windows?app_id=${encodeURIComponent(appId)}&days=90`).catch(() => null);
101
+ if (res == null) {
102
+ if (!cancelled)
103
+ setState({ ...INITIAL_STATE, loading: false, error: 'Network error' });
104
+ return;
105
+ }
106
+ if (res.status === 401 || res.status === 403) {
107
+ if (!cancelled)
108
+ setState({ ...INITIAL_STATE, loading: false, unauthenticated: true });
109
+ return;
110
+ }
111
+ if (!res.ok) {
112
+ const body = (await res.json().catch(() => null));
113
+ if (!cancelled)
114
+ setState({ ...INITIAL_STATE, loading: false, error: body?.error?.message ?? `HTTP ${res.status}` });
115
+ return;
116
+ }
117
+ const body = (await res.json());
118
+ const windows = body.data?.windows ?? [];
119
+ if (!cancelled) {
120
+ setState({ loading: false, unauthenticated: false, error: null, windows });
121
+ }
122
+ }
123
+ fetchAll().catch((err) => {
124
+ if (!cancelled) {
125
+ const message = err instanceof Error ? err.message : 'Unexpected error';
126
+ setState({ ...INITIAL_STATE, loading: false, error: message });
127
+ }
128
+ });
129
+ return () => { cancelled = true; };
130
+ }, [appId, refreshKey]);
131
+ // -------------------------------------------------------------------------
132
+ // Render states
133
+ // -------------------------------------------------------------------------
134
+ if (state.loading) {
135
+ return (_jsx("div", { className: "flex items-center justify-center p-8 text-sm text-muted-foreground", children: "Loading metrics\u2026" }));
136
+ }
137
+ if (state.unauthenticated) {
138
+ return (_jsxs("div", { className: "flex items-center justify-center rounded-lg border p-8 text-sm text-muted-foreground", children: ["Log in with ", _jsx("span", { className: "mx-1 font-mono", children: "metrics.view" }), " permission to see this page."] }));
139
+ }
140
+ if (state.error != null) {
141
+ return (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700", children: ["Failed to load metrics: ", state.error] }));
142
+ }
143
+ // -------------------------------------------------------------------------
144
+ // Main render
145
+ // -------------------------------------------------------------------------
146
+ return (_jsxs("div", { className: "flex flex-col gap-6 p-4", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("h2", { className: "text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Metrics" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: appId }), _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"] })] })] }), _jsx("section", { children: _jsx(WindowGrid, { windows: state.windows }) }), _jsxs("section", { children: [_jsx("h2", { className: "mb-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground", children: "Graph" }), _jsx(Graph, { windows: state.windows })] })] }));
147
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_umetrics",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Product analytics for hazo apps — GA4 hybrid + first-party stat store + feature flags",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -48,10 +48,10 @@
48
48
  "hazo_config": "^2.4.1",
49
49
  "hazo_connect": "^3.9.2",
50
50
  "hazo_api": "^2.5.1",
51
- "hazo_auth": "^10.8.0",
51
+ "hazo_auth": "^10.8.2",
52
52
  "hazo_secure": "^1.3.0",
53
53
  "hazo_audit": "^2.1.2",
54
- "hazo_ui": "^4.10.0",
54
+ "hazo_ui": "^6.0.0",
55
55
  "next": "^14.0.0 || ^16.0.0",
56
56
  "react": "^18.0.0 || ^19.0.0",
57
57
  "react-dom": "^18.0.0 || ^19.0.0"