@uptimizr/db 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,7 @@
9
9
  * Invariant: no multi-tenant concepts here. Filtering is by `project_id` and the
10
10
  * optional scene/source/session dimensions only.
11
11
  */
12
- import { ParamBag, cameraModeClause, dayRangeClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
12
+ import { ParamBag, cameraModeClause, dayRangeClause, meshClause, rangeClause, regionClause, sceneClause, sessionClause, sourceClause, } from "./dialect.js";
13
13
  /** World/gaze voxel coordinates derive from the raycast `hit_point` vector. */
14
14
  const HIT_POINT_COLS = { x: "hit_point[1]", y: "hit_point[2]", z: "hit_point[3]" };
15
15
  /** Floor-plan cells derive from the camera `position` vector (Y is height). */
@@ -84,6 +84,48 @@ export function buildPointerHeatmap(projectId, opts, d) {
84
84
  query_params: bag.values,
85
85
  };
86
86
  }
87
+ /**
88
+ * Per-mesh texture-space (UV) heatmap (#149): bin the `uv` texture coordinates of
89
+ * interaction events (`pointer_click`, `mesh_interaction`, `hover_dwell`) on one
90
+ * mesh into a `bins x bins` grid over the object's own `[0, 1]` UV space. This is
91
+ * the surface-attention companion to the world-space voxel heatmap — "which part
92
+ * of the product model gets attention", independent of where the object sits in
93
+ * the scene. `uv` rides in the JSON `payload` (no promoted column), so it is read
94
+ * with the dialect's `jsonFloat`. The promoted `mesh` column already coalesces a
95
+ * pointer hit's `hitMesh` with an interaction's `mesh`, so one filter spans all
96
+ * three event types. Output matches {@link buildPointerHeatmap}'s `HeatmapBinRow`
97
+ * (`gx`, `gy`, `count`) so it reuses the 2D heatmap renderer.
98
+ */
99
+ export function buildMeshUvHeatmap(projectId, opts, d) {
100
+ const bag = new ParamBag(d);
101
+ const pid = bag.add("projectId", "string", projectId);
102
+ const bins = bag.add("bins", "u32", opts.bins ?? 50);
103
+ const range = rangeClause(bag, opts);
104
+ const scene = sceneClause(bag, opts);
105
+ const source = sourceClause(bag, opts);
106
+ const session = sessionClause(bag, opts);
107
+ const mesh = meshClause(bag, opts);
108
+ const u = d.jsonFloat("payload", "uv", "0");
109
+ const v = d.jsonFloat("payload", "uv", "1");
110
+ return {
111
+ query: `
112
+ SELECT gx, gy, count() AS count
113
+ FROM (
114
+ SELECT
115
+ floor(${u} * ${bins}) AS gx,
116
+ floor(${v} * ${bins}) AS gy
117
+ FROM events
118
+ WHERE project_id = ${pid}
119
+ AND event_type IN ('pointer_click', 'mesh_interaction', 'hover_dwell')
120
+ AND ${u} IS NOT NULL
121
+ AND ${v} IS NOT NULL${range}${scene}${source}${session}${mesh}
122
+ ) AS uv_hits
123
+ GROUP BY gx, gy
124
+ ORDER BY count DESC
125
+ `,
126
+ query_params: bag.values,
127
+ };
128
+ }
87
129
  /**
88
130
  * World-space (3D) pointer heatmap: voxel-bin the raycast hit points of pointer
89
131
  * events into a uniform grid of `cellSize`-sized cubes. Results are capped to the
@@ -251,6 +293,55 @@ export function buildCameraDirectionHeatmap(projectId, opts, d) {
251
293
  query_params: bag.values,
252
294
  };
253
295
  }
296
+ /**
297
+ * 360° view-coverage histogram (#146): how much of a 3D object each session
298
+ * actually looked at, bucketed across sessions. Each session's `camera_sample`
299
+ * `direction` samples are binned into the **same** azimuth/elevation grid as the
300
+ * view-direction dome ({@link buildCameraDirectionHeatmap}); the fraction of the
301
+ * `bins × bins` cells a session visited is its coverage score (0–100%). Sessions
302
+ * are then grouped into four coverage buckets — `0` (0–25%), `25` (25–50%), `50`
303
+ * (50–75%), `75` (75–100%) — answering "how many visitors saw <25% of the
304
+ * product". A single integer `bin_id = azimuth_bin * bins + elevation_bin` keeps
305
+ * the distinct-cell count cross-dialect (no `COUNT(DISTINCT a, b)`), and
306
+ * `least(floor(pct / 25), 3)` folds a full-coverage (100%) session into the top
307
+ * bucket instead of a spurious fifth `100` bucket. Purely derived from the
308
+ * existing `camera_sample` stream — no schema change.
309
+ */
310
+ export function buildViewCoverageHistogram(projectId, opts, d) {
311
+ const bag = new ParamBag(d);
312
+ const pid = bag.add("projectId", "string", projectId);
313
+ const bins = bag.add("bins", "u32", opts.bins ?? 36);
314
+ const range = rangeClause(bag, opts);
315
+ const scene = sceneClause(bag, opts);
316
+ const session = sessionClause(bag, opts);
317
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
318
+ return {
319
+ query: `
320
+ SELECT
321
+ least(floor(coverage_pct / 25), 3) * 25 AS bucket,
322
+ count() AS sessions
323
+ FROM (
324
+ SELECT
325
+ session_id,
326
+ count(DISTINCT bin_id) * 100.0 / (${bins} * ${bins}) AS coverage_pct
327
+ FROM (
328
+ SELECT
329
+ session_id,
330
+ floor((atan2(direction[3], direction[1]) + pi()) / (2 * pi()) * ${bins}) * ${bins}
331
+ + floor((asin(direction[2] / greatest(${d.vectorNorm("direction")}, 1e-6)) + pi() / 2) / pi() * ${bins}) AS bin_id
332
+ FROM events
333
+ WHERE project_id = ${pid}
334
+ AND event_type = 'camera_sample'
335
+ AND length(direction) = 3${range}${scene}${session}${cameraMode}
336
+ ) binned
337
+ GROUP BY session_id
338
+ ) per_session
339
+ GROUP BY bucket
340
+ ORDER BY bucket ASC
341
+ `,
342
+ query_params: bag.values,
343
+ };
344
+ }
254
345
  /**
255
346
  * Top-down "floor plan" camera-position heatmap (ADR 0026): bin `camera_sample`
256
347
  * world positions onto the X/Z ground plane in `cellSize`-sized cells, tracking the
@@ -699,6 +790,59 @@ export function buildMeshDwell(projectId, opts, d) {
699
790
  query_params: bag.values,
700
791
  };
701
792
  }
793
+ /**
794
+ * Blind-spot / never-noticed meshes (#143): the inverse of the Top-meshes and
795
+ * part-popularity leaderboards. Cross-references what was *rendered* against what
796
+ * was *engaged with* — per mesh, the total `mesh_visibility` on-screen time vs.
797
+ * the count of `mesh_interaction` events and the `hover_dwell` hesitation. A mesh
798
+ * with high visibility but zero (or near-zero) interaction + hover is a blind
799
+ * spot: a product detail nobody noticed, or a prop/room that renders but nobody
800
+ * investigates.
801
+ *
802
+ * Engagement is deliberately the two *active-attention* signals the issue names —
803
+ * `mesh_interaction` (the source-neutral pick/hover/drag signal, ADR 0011) and
804
+ * `hover_dwell` (hover-without-action, #48). Passive gaze (`camera_sample`) is
805
+ * not engagement, and raw `pointer_click` is excluded because a mesh-hitting click
806
+ * already surfaces as a `mesh_interaction`. Both durations live in the shared
807
+ * `visible_ms` column (events.ts maps `mesh_visibility.visibleMs` and
808
+ * `hover_dwell.dwellMs` onto it), so the whole report is one grouped scan.
809
+ *
810
+ * `HAVING sum(visible_ms) WHERE mesh_visibility > 0` keeps only meshes that were
811
+ * actually seen (a blind spot must first be visible). Ranked by engagement
812
+ * ascending, then visibility descending, so the most-seen-yet-least-touched
813
+ * meshes rank first.
814
+ */
815
+ export function buildMeshBlindSpots(projectId, opts, d) {
816
+ const bag = new ParamBag(d);
817
+ const pid = bag.add("projectId", "string", projectId);
818
+ const range = rangeClause(bag, opts);
819
+ const scene = sceneClause(bag, opts);
820
+ const session = sessionClause(bag, opts);
821
+ const limit = bag.add("limit", "u32", opts.limit ?? 25);
822
+ return {
823
+ query: `
824
+ SELECT
825
+ mesh,
826
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) AS visible_ms,
827
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN 1 ELSE 0 END) AS vis_samples,
828
+ sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END) AS interactions,
829
+ sum(CASE WHEN event_type = 'hover_dwell' THEN visible_ms ELSE 0 END) AS hover_ms,
830
+ sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) AS hover_episodes
831
+ FROM events
832
+ WHERE project_id = ${pid}
833
+ AND event_type IN ('mesh_visibility', 'mesh_interaction', 'hover_dwell')
834
+ AND mesh != ''${range}${scene}${session}
835
+ GROUP BY mesh
836
+ HAVING sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) > 0
837
+ ORDER BY
838
+ sum(CASE WHEN event_type = 'mesh_interaction' THEN 1 ELSE 0 END)
839
+ + sum(CASE WHEN event_type = 'hover_dwell' THEN 1 ELSE 0 END) ASC,
840
+ sum(CASE WHEN event_type = 'mesh_visibility' THEN visible_ms ELSE 0 END) DESC
841
+ LIMIT ${limit}
842
+ `,
843
+ query_params: bag.values,
844
+ };
845
+ }
702
846
  /**
703
847
  * Interaction-kind breakdown (#72): per-mesh counts of each interaction *kind*
704
848
  * (hover / pick / click / drag / select / squeeze / grab / release / teleport)
@@ -731,6 +875,73 @@ export function buildMeshInteractionKinds(projectId, opts, d) {
731
875
  query_params: bag.values,
732
876
  };
733
877
  }
878
+ /**
879
+ * Reachability report (#151): how far each interacted mesh sat from where the
880
+ * user actually stood. ASOF-join every `mesh_interaction` that carries a world
881
+ * `point` (→ `hit_point`) to the nearest **preceding** `camera_sample` in the
882
+ * same session, take the Euclidean standpoint→hit distance, and histogram it per
883
+ * mesh in `bucketSize`-wide world-unit bands. Meshes/UI whose interactions
884
+ * cluster in far bands are consistently reached from an uncomfortable range —
885
+ * actionable feedback for VR UI placement and first-person layout.
886
+ *
887
+ * The standpoint is the click-time camera **position** (the shared coordinate
888
+ * frame, ADR 0018); interactions with no preceding camera sample in range can't
889
+ * be measured, so the inner ASOF join drops them. Same nearest-in-time caveat as
890
+ * the click-gaze / navigation joins: camera samples are frequent enough that the
891
+ * approximation is sound for discrete interaction events. Honors the shared
892
+ * scene/source/session filters (source constrains the *interaction* side only —
893
+ * a `camera_sample`'s `source` is the realized `'mouse'` default, ADR 0011).
894
+ */
895
+ export function buildReachability(projectId, opts, d) {
896
+ const bag = new ParamBag(d);
897
+ const pid = bag.add("projectId", "string", projectId);
898
+ const bucketSize = bag.add("bucketSize", "f64", opts.bucketSize ?? 0.5);
899
+ const range = rangeClause(bag, opts);
900
+ const scene = sceneClause(bag, opts);
901
+ const source = sourceClause(bag, opts);
902
+ const session = sessionClause(bag, opts);
903
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
904
+ return {
905
+ query: `
906
+ SELECT
907
+ seg.mesh AS mesh,
908
+ floor(seg.dist / ${bucketSize}) AS bucket,
909
+ count() AS count,
910
+ avg(seg.dist) AS avg_distance
911
+ FROM (
912
+ SELECT
913
+ i.mesh AS mesh,
914
+ sqrt(
915
+ (i.hx - m.px) * (i.hx - m.px) +
916
+ (i.hy - m.py) * (i.hy - m.py) +
917
+ (i.hz - m.pz) * (i.hz - m.pz)
918
+ ) AS dist
919
+ FROM (
920
+ SELECT session_id, ts, mesh,
921
+ hit_point[1] AS hx, hit_point[2] AS hy, hit_point[3] AS hz
922
+ FROM events
923
+ WHERE project_id = ${pid}
924
+ AND event_type = 'mesh_interaction'
925
+ AND mesh != ''
926
+ AND length(hit_point) = 3${range}${scene}${source}${session}
927
+ ) AS i
928
+ ${d.asofInnerJoin} (
929
+ SELECT session_id, ts,
930
+ position[1] AS px, position[2] AS py, position[3] AS pz
931
+ FROM events
932
+ WHERE project_id = ${pid}
933
+ AND event_type = 'camera_sample'
934
+ AND length(position) = 3${range}${scene}${session}
935
+ ) AS m
936
+ ON i.session_id = m.session_id AND i.ts >= m.ts
937
+ ) AS seg
938
+ GROUP BY mesh, bucket
939
+ ORDER BY count DESC
940
+ LIMIT ${limit}
941
+ `,
942
+ query_params: bag.values,
943
+ };
944
+ }
734
945
  /**
735
946
  * Dead-click rate (#46): of all `pointer_click` events, how many hit nothing
736
947
  * (the hit-test missed, so `mesh` is empty / no `hitMesh`). A high dead-click
@@ -956,6 +1167,142 @@ export function buildStabilityCounts(projectId, opts, d) {
956
1167
  query_params: bag.values,
957
1168
  };
958
1169
  }
1170
+ /**
1171
+ * Opt-in engine-diagnostic counts from `graphics_diagnostic` (ADR 0021 part 2):
1172
+ * the fully-crossed `(severity, category, backend)` group with the total incident
1173
+ * count per cell, so the dashboard can derive the by-category, by-severity, and
1174
+ * by-backend breakdowns from a single query by summing.
1175
+ *
1176
+ * The diagnostic fields ride in the `payload` JSON (they are not promoted
1177
+ * columns, per ADR 0004 — nothing is promoted unless an aggregation needs it),
1178
+ * so `severity` / `category` / `backend` are read with `jsonText` and `backend`
1179
+ * `coalesce`s to `''` ("unknown") when the connector omitted it.
1180
+ *
1181
+ * **Rollup-or-marker (ADR 0021 decision 4).** Each event carries *either* one
1182
+ * discrete incident (no `count`) *or* a per-session rollup (`count = N`). The
1183
+ * incident total is `sum(coalesce(count, 1))`, so a marker folds in as 1 and a
1184
+ * rollup as N — markers and rollups land in the same counters. Capture is off by
1185
+ * default, so the common case is an empty result.
1186
+ */
1187
+ export function buildGraphicsDiagnosticCounts(projectId, opts, d) {
1188
+ const bag = new ParamBag(d);
1189
+ const pid = bag.add("projectId", "string", projectId);
1190
+ const range = rangeClause(bag, opts);
1191
+ const scene = sceneClause(bag, opts);
1192
+ const session = sessionClause(bag, opts);
1193
+ const severity = d.jsonText("payload", "severity");
1194
+ const category = d.jsonText("payload", "category");
1195
+ const backend = d.jsonText("payload", "backend");
1196
+ const count = d.jsonInt("payload", "count");
1197
+ return {
1198
+ query: `
1199
+ SELECT
1200
+ ${severity} AS severity,
1201
+ ${category} AS category,
1202
+ coalesce(${backend}, '') AS backend,
1203
+ sum(coalesce(${count}, 1)) AS incidents
1204
+ FROM events
1205
+ WHERE project_id = ${pid} AND event_type = 'graphics_diagnostic'${range}${scene}${session}
1206
+ GROUP BY severity, category, backend
1207
+ ORDER BY incidents DESC
1208
+ `,
1209
+ query_params: bag.values,
1210
+ };
1211
+ }
1212
+ /**
1213
+ * Spatial error heatmap (issue #154): voxel-bin the world `position` of
1214
+ * positioned `runtime_error` and `graphics_diagnostic` events into a uniform grid
1215
+ * of `cellSize`-sized cubes, returning the busiest `limit` voxels. This reveals
1216
+ * *where* in the scene things break — errors/crashes clustering around specific
1217
+ * geometry, a shader-heavy area, or a level region — instead of only *when*.
1218
+ *
1219
+ * `position` is best-effort connector-side (the camera pose at the moment the
1220
+ * error/diagnostic fired), so only rows that carry a full 3-vector participate
1221
+ * (`length(position) = 3`); errors from pages with no 3D connector are naturally
1222
+ * excluded. Reuses the already-promoted `position` column — no migration.
1223
+ *
1224
+ * Optional {@link ErrorHeatmapOptions} filters (severity/category/errorKind) read
1225
+ * from the `payload` JSON, mirroring {@link buildGraphicsDiagnosticCounts}: a
1226
+ * severity/category filter narrows to engine diagnostics, an errorKind filter
1227
+ * narrows to JS errors. `region` drill-down and `scene`/`session` scoping compose
1228
+ * like the other spatial heatmaps.
1229
+ */
1230
+ export function buildErrorHeatmap(projectId, opts, d) {
1231
+ const bag = new ParamBag(d);
1232
+ const pid = bag.add("projectId", "string", projectId);
1233
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
1234
+ const range = rangeClause(bag, opts);
1235
+ const scene = sceneClause(bag, opts);
1236
+ const session = sessionClause(bag, opts);
1237
+ const region = regionClause(bag, opts, POSITION_COLS);
1238
+ const limit = bag.add("limit", "u32", opts.limit ?? 1000);
1239
+ const filters = [];
1240
+ if (opts.severity != null && opts.severity.length > 0) {
1241
+ filters.push(`${d.jsonText("payload", "severity")} = ${bag.add("severity", "string", opts.severity)}`);
1242
+ }
1243
+ if (opts.category != null && opts.category.length > 0) {
1244
+ filters.push(`${d.jsonText("payload", "category")} = ${bag.add("category", "string", opts.category)}`);
1245
+ }
1246
+ if (opts.errorKind != null && opts.errorKind.length > 0) {
1247
+ filters.push(`${d.jsonText("payload", "kind")} = ${bag.add("errorKind", "string", opts.errorKind)}`);
1248
+ }
1249
+ const filter = filters.length ? ` AND ${filters.join(" AND ")}` : "";
1250
+ return {
1251
+ query: `
1252
+ SELECT
1253
+ floor(position[1] / ${cellSize}) AS vx,
1254
+ floor(position[2] / ${cellSize}) AS vy,
1255
+ floor(position[3] / ${cellSize}) AS vz,
1256
+ count() AS count
1257
+ FROM events
1258
+ WHERE project_id = ${pid}
1259
+ AND event_type IN ('runtime_error', 'graphics_diagnostic')
1260
+ AND length(position) = 3${range}${scene}${session}${region}${filter}
1261
+ GROUP BY vx, vy, vz
1262
+ ORDER BY count DESC
1263
+ LIMIT ${limit}
1264
+ `,
1265
+ query_params: bag.values,
1266
+ };
1267
+ }
1268
+ /**
1269
+ * Always-on rendering-technology mix from `session_start.graphics` (ADR 0021 part
1270
+ * 1): the fully-crossed `(api, backend, api_version, shading_language)` group with
1271
+ * one session count per cell, so the dashboard can derive the by-api, by-backend,
1272
+ * by-version, and by-shading-language breakdowns from a single query by summing.
1273
+ *
1274
+ * The graphics fields ride in the `payload` JSON (they are not promoted columns,
1275
+ * per ADR 0004 — nothing is promoted unless an aggregation needs it), so each is
1276
+ * read with `jsonText` and `coalesce`s to `''` ("unknown") when the connector
1277
+ * omitted it. Unlike the opt-in `graphics_diagnostic` counts, `session_start` is
1278
+ * always-on, so a populated result is the common case.
1279
+ */
1280
+ export function buildRenderingTechnology(projectId, opts, d) {
1281
+ const bag = new ParamBag(d);
1282
+ const pid = bag.add("projectId", "string", projectId);
1283
+ const range = rangeClause(bag, opts);
1284
+ const scene = sceneClause(bag, opts);
1285
+ const session = sessionClause(bag, opts);
1286
+ const api = d.jsonText("payload", "graphics", "api");
1287
+ const backend = d.jsonText("payload", "graphics", "backend");
1288
+ const apiVersion = d.jsonText("payload", "graphics", "apiVersion");
1289
+ const shadingLanguage = d.jsonText("payload", "graphics", "shadingLanguage");
1290
+ return {
1291
+ query: `
1292
+ SELECT
1293
+ coalesce(${api}, '') AS api,
1294
+ coalesce(${backend}, '') AS backend,
1295
+ coalesce(${apiVersion}, '') AS api_version,
1296
+ coalesce(${shadingLanguage}, '') AS shading_language,
1297
+ count() AS sessions
1298
+ FROM events
1299
+ WHERE project_id = ${pid} AND event_type = 'session_start'${range}${scene}${session}
1300
+ GROUP BY api, backend, api_version, shading_language
1301
+ ORDER BY sessions DESC
1302
+ `,
1303
+ query_params: bag.values,
1304
+ };
1305
+ }
959
1306
  /**
960
1307
  * Capability / fidelity transitions from `capability_change` (#49, design §E):
961
1308
  * per (kind, from, to), how many times the app reported that fallback or
@@ -1214,6 +1561,82 @@ export function buildJankRate(projectId, opts, d) {
1214
1561
  query_params: bag.values,
1215
1562
  };
1216
1563
  }
1564
+ /**
1565
+ * Perf-correlated churn (#144): does a stutter actually cost sessions? Correlates
1566
+ * perf dips against early session end. Of the sessions that ended in range
1567
+ * (`sessions`), `churn_sessions` ended within `windowMs` of an FPS dip (a
1568
+ * `frame_perf` sample below `fpsThreshold`) or a `compile_stall` of at least
1569
+ * `stallMs` — the felt hitches that plausibly drove the user away, as opposed to
1570
+ * background noise that the perf-distribution panel averages over.
1571
+ *
1572
+ * Semantics — a session churns iff it has a `session_end` **and** at least one
1573
+ * qualifying dip whose timestamp lies in `[end - windowMs, end]` (its earliest
1574
+ * `session_end` is the anchor). `fps_churn_sessions` / `stall_churn_sessions`
1575
+ * attribute the cause; a session whose window held both is counted in each cause
1576
+ * column but only once in `churn_sessions`, so the cause columns can sum to more
1577
+ * than the total.
1578
+ *
1579
+ * Implementation — an `ends` CTE (each session's first `session_end`) joined to a
1580
+ * `dips` CTE (the qualifying `frame_perf` / `compile_stall` rows) on `session_id`,
1581
+ * with the window enforced through the dialect's `epochMs` so the timestamp math
1582
+ * is engine-neutral. This uses only `JOIN` / `min` / `max` / `count` — **no window
1583
+ * or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
1584
+ * (scale tier). Aggregating over the (possibly empty) `correlated` set always
1585
+ * yields one row; the `sessions` denominator is an uncorrelated scalar sub-select
1586
+ * so it stands even when nothing churned. Privacy (ADR 0003): aggregate counts
1587
+ * only, no per-session identifiers leave the query.
1588
+ */
1589
+ export function buildPerfChurn(projectId, opts, d) {
1590
+ const bag = new ParamBag(d);
1591
+ const pid = bag.add("projectId", "string", projectId);
1592
+ const range = rangeClause(bag, opts);
1593
+ const scene = sceneClause(bag, opts);
1594
+ const session = sessionClause(bag, opts);
1595
+ const windowMs = bag.add("windowMs", "u32", opts.windowMs ?? 30_000);
1596
+ const fpsThreshold = bag.add("fpsThreshold", "f64", opts.fpsThreshold ?? 30);
1597
+ const stallMs = bag.add("stallMs", "f64", opts.stallMs ?? 100);
1598
+ const dipTs = d.epochMs("dips.ts");
1599
+ const endTs = d.epochMs("ends.end_ts");
1600
+ return {
1601
+ query: `
1602
+ WITH ends AS (
1603
+ SELECT session_id, min(ts) AS end_ts
1604
+ FROM events
1605
+ WHERE project_id = ${pid} AND event_type = 'session_end'${range}${scene}${session}
1606
+ GROUP BY session_id
1607
+ ),
1608
+ dips AS (
1609
+ SELECT
1610
+ session_id,
1611
+ ts,
1612
+ CASE WHEN event_type = 'frame_perf' THEN 1 ELSE 0 END AS is_fps,
1613
+ CASE WHEN event_type = 'compile_stall' THEN 1 ELSE 0 END AS is_stall
1614
+ FROM events
1615
+ WHERE project_id = ${pid}${range}${scene}${session}
1616
+ AND (
1617
+ (event_type = 'frame_perf' AND fps < ${fpsThreshold})
1618
+ OR (event_type = 'compile_stall' AND visible_ms >= ${stallMs})
1619
+ )
1620
+ ),
1621
+ correlated AS (
1622
+ SELECT
1623
+ ends.session_id AS session_id,
1624
+ max(dips.is_fps) AS had_fps,
1625
+ max(dips.is_stall) AS had_stall
1626
+ FROM ends JOIN dips ON ends.session_id = dips.session_id
1627
+ WHERE ${dipTs} <= ${endTs} AND ${dipTs} >= ${endTs} - ${windowMs}
1628
+ GROUP BY ends.session_id
1629
+ )
1630
+ SELECT
1631
+ (SELECT count() FROM ends) AS sessions,
1632
+ count() AS churn_sessions,
1633
+ sum(had_fps) AS fps_churn_sessions,
1634
+ sum(had_stall) AS stall_churn_sessions
1635
+ FROM correlated
1636
+ `,
1637
+ query_params: bag.values,
1638
+ };
1639
+ }
1217
1640
  /**
1218
1641
  * FPS segmented by device class, computed **per-session then aggregated** (ADR
1219
1642
  * 0028 §2). Each session's median FPS is attributed to the graphics backend,
@@ -1467,6 +1890,47 @@ export function buildSceneCoverage(projectId, opts, d) {
1467
1890
  query_params: bag.values,
1468
1891
  };
1469
1892
  }
1893
+ /**
1894
+ * Spatial FPS heatmap (#145): voxel-bin `frame_perf` samples by their captured
1895
+ * camera `position` into a uniform grid of `cellSize`-sized cubes, reporting each
1896
+ * occupied cell's sample count, mean FPS, and worst single FPS. This answers
1897
+ * *where* performance degrades ("FPS is bad in the boss room"), the spatial
1898
+ * complement to the time-bucketed {@link buildPerfDistribution}/{@link buildFpsHistogram}.
1899
+ *
1900
+ * It reads the same promoted `position` column the camera-position heatmaps use —
1901
+ * `frame_perf` now carries an optional camera position, filled by the connector at
1902
+ * sample time — so no join against the separately-sampled `camera_sample` stream is
1903
+ * needed. Rows are ordered worst-FPS-first so the capped top-`limit` slice surfaces
1904
+ * the jankiest cells rather than an arbitrary corner of the scene.
1905
+ */
1906
+ export function buildPerfHeatmap(projectId, opts, d) {
1907
+ const bag = new ParamBag(d);
1908
+ const pid = bag.add("projectId", "string", projectId);
1909
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 1);
1910
+ const range = rangeClause(bag, opts);
1911
+ const scene = sceneClause(bag, opts);
1912
+ const session = sessionClause(bag, opts);
1913
+ const limit = bag.add("limit", "u32", opts.limit ?? 2000);
1914
+ return {
1915
+ query: `
1916
+ SELECT
1917
+ floor(position[1] / ${cellSize}) AS vx,
1918
+ floor(position[2] / ${cellSize}) AS vy,
1919
+ floor(position[3] / ${cellSize}) AS vz,
1920
+ count() AS samples,
1921
+ avg(fps) AS avg_fps,
1922
+ min(fps) AS min_fps
1923
+ FROM events
1924
+ WHERE project_id = ${pid}
1925
+ AND event_type = 'frame_perf'
1926
+ AND length(position) = 3${range}${scene}${session}
1927
+ GROUP BY vx, vy, vz
1928
+ ORDER BY avg_fps ASC
1929
+ LIMIT ${limit}
1930
+ `,
1931
+ query_params: bag.values,
1932
+ };
1933
+ }
1470
1934
  /**
1471
1935
  * Camera distance / zoom distribution (derived, scene-metrics §B): histogram the
1472
1936
  * distance from the camera *position* of each `camera_sample` to a reference
@@ -1557,6 +2021,91 @@ export function buildNavigationStats(projectId, opts, d) {
1557
2021
  query_params: bag.values,
1558
2022
  };
1559
2023
  }
2024
+ /**
2025
+ * Path-retrace / backtracking ratio (#153): a confusion signal derived from the
2026
+ * same `camera_sample` position stream that feeds desire lines, surfaced as a
2027
+ * per-scene leaderboard. It answers "which areas do visitors keep re-walking?" —
2028
+ * a high backtrack ratio flags a dead end, a missed cue, or a puzzle that isn't
2029
+ * reading clearly.
2030
+ *
2031
+ * Algorithm — the coarse-grid revisit proxy (the cheap first cut, not true
2032
+ * reverse-segment retracing): bin each session's positions onto a `cellSize`
2033
+ * X/Z grid, then collapse consecutive samples in the same cell into ordered cell
2034
+ * *entries* (so standing still / dwelling never counts) via an ASOF self-join to
2035
+ * the immediately preceding sample. A row is an entry when it has no predecessor
2036
+ * (the session's first sample) or its cell differs from the predecessor's. The
2037
+ * `present` sentinel makes the unmatched-predecessor test engine-agnostic:
2038
+ * DuckDB null-fills a LEFT-join miss while ClickHouse zero-fills it, so `present`
2039
+ * (`1` only on a real match) is the portable "has a predecessor" flag.
2040
+ *
2041
+ * Per (session, scene): `revisits = entries − distinct_cells` — every entry into
2042
+ * a cell beyond its first is a re-entry. Pooled per scene, the leaderboard
2043
+ * reports `backtrack_ratio = Σ revisits / Σ entries` alongside the raw counts.
2044
+ * Only plain `count()` and a dedup subquery are used (no multi-column
2045
+ * `COUNT(DISTINCT …)`), so DuckDB and ClickHouse agree.
2046
+ */
2047
+ export function buildBacktrackRatio(projectId, opts, d) {
2048
+ const bag = new ParamBag(d);
2049
+ const pid = bag.add("projectId", "string", projectId);
2050
+ const cellSize = bag.add("cellSize", "f64", opts.cellSize ?? 2);
2051
+ const range = rangeClause(bag, opts);
2052
+ const scene = sceneClause(bag, opts);
2053
+ const session = sessionClause(bag, opts);
2054
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
2055
+ const sampleSelect = `
2056
+ SELECT session_id, scene_id AS scene, ts,
2057
+ floor(position[1] / ${cellSize}) AS gx,
2058
+ floor(position[3] / ${cellSize}) AS gz
2059
+ FROM events
2060
+ WHERE project_id = ${pid}
2061
+ AND event_type = 'camera_sample'
2062
+ AND length(position) = 3${range}${scene}${session}`;
2063
+ // Ordered cell entries (consecutive same-cell samples collapsed): a sample is
2064
+ // an entry when it has no predecessor or its cell changed vs. the predecessor.
2065
+ const entries = `
2066
+ SELECT c.session_id AS session_id, c.scene AS scene, c.gx AS gx, c.gz AS gz
2067
+ FROM (${sampleSelect}
2068
+ ) AS c
2069
+ ${d.asofLeftJoin} (
2070
+ SELECT session_id, ts, 1 AS present, gx, gz FROM (${sampleSelect}
2071
+ ) AS s
2072
+ ) AS m
2073
+ ON c.session_id = m.session_id AND c.ts > m.ts
2074
+ WHERE m.present IS NULL OR m.present = 0 OR c.gx <> m.gx OR c.gz <> m.gz`;
2075
+ return {
2076
+ query: `
2077
+ SELECT
2078
+ scene,
2079
+ count() AS sessions,
2080
+ sum(total_entries) AS entries,
2081
+ sum(revisits) AS revisits,
2082
+ CASE WHEN sum(total_entries) > 0
2083
+ THEN sum(revisits) * 1.0 / sum(total_entries) ELSE 0 END AS backtrack_ratio
2084
+ FROM (
2085
+ SELECT
2086
+ e.session_id AS session_id,
2087
+ e.scene AS scene,
2088
+ count() AS total_entries,
2089
+ count() - dc.distinct_cells AS revisits
2090
+ FROM (${entries}
2091
+ ) AS e
2092
+ JOIN (
2093
+ SELECT session_id, scene, count() AS distinct_cells
2094
+ FROM (
2095
+ SELECT DISTINCT session_id, scene, gx, gz FROM (${entries}
2096
+ ) AS de
2097
+ ) AS ded
2098
+ GROUP BY session_id, scene
2099
+ ) AS dc ON e.session_id = dc.session_id AND e.scene = dc.scene
2100
+ GROUP BY e.session_id, e.scene, dc.distinct_cells
2101
+ ) AS per_session
2102
+ GROUP BY scene
2103
+ ORDER BY backtrack_ratio DESC, entries DESC
2104
+ LIMIT ${limit}
2105
+ `,
2106
+ query_params: bag.values,
2107
+ };
2108
+ }
1560
2109
  /** XR input sources that distinguish hand-tracking, controllers and gaze. */
1561
2110
  const XR_SOURCES = "('xr-controller', 'hand', 'gaze', 'transient')";
1562
2111
  /**
@@ -1754,6 +2303,54 @@ export function buildXrAbandonment(projectId, opts, d) {
1754
2303
  query_params: bag.values,
1755
2304
  };
1756
2305
  }
2306
+ /**
2307
+ * XR locomotion & comfort (#148): per session that used an XR input source, its
2308
+ * locomotion-style breakdown and wall-clock span. This turns the existing
2309
+ * `camera_gesture` / `mesh_interaction` streams into a comfort signal for VR
2310
+ * developers — constant smooth `fly` locomotion (a motion-sickness risk) vs.
2311
+ * teleport-dominant sessions — and lets the consumer correlate heavy locomotion
2312
+ * with early exits (a short span, a discomfort / "rage-quit" proxy).
2313
+ *
2314
+ * A teleport emits **both** a `camera_gesture { kind: "fly" }` and a
2315
+ * `mesh_interaction { kind: "teleport" }` (ADR 0025), so `fly_gestures` counts
2316
+ * every fly (smooth + teleport) while `teleports` isolates the discrete jumps;
2317
+ * the consumer derives smooth locomotion as `fly_gestures - teleports`. Sessions
2318
+ * with no XR input are omitted entirely (same XR-session gate as
2319
+ * {@link buildXrAbandonment}). Wall-clock bounds are engine-specific and excluded
2320
+ * from parity; the counts are compared.
2321
+ */
2322
+ export function buildXrLocomotionComfort(projectId, opts, d) {
2323
+ const bag = new ParamBag(d);
2324
+ const pid = bag.add("projectId", "string", projectId);
2325
+ const range = rangeClause(bag, opts);
2326
+ const scene = sceneClause(bag, opts);
2327
+ const session = sessionClause(bag, opts);
2328
+ const limit = bag.add("limit", "u32", opts.limit ?? 500);
2329
+ return {
2330
+ query: `
2331
+ SELECT
2332
+ session_id,
2333
+ sum(CASE WHEN event_type = 'camera_gesture' AND name = 'fly' THEN 1 ELSE 0 END) AS fly_gestures,
2334
+ sum(CASE WHEN event_type = 'camera_gesture' AND name = 'navigate' THEN 1 ELSE 0 END) AS navigate_gestures,
2335
+ sum(CASE WHEN event_type = 'mesh_interaction' AND name = 'teleport' THEN 1 ELSE 0 END) AS teleports,
2336
+ sum(CASE WHEN event_type = 'camera_gesture' AND name IN ('fly', 'navigate') THEN visible_ms ELSE 0 END) AS locomotion_ms,
2337
+ min(ts) AS started_at,
2338
+ max(ts) AS ended_at
2339
+ FROM events
2340
+ WHERE project_id = ${pid}${range}${scene}${session}
2341
+ AND session_id IN (
2342
+ SELECT session_id
2343
+ FROM events
2344
+ WHERE project_id = ${pid}
2345
+ AND source IN ${XR_SOURCES}
2346
+ )
2347
+ GROUP BY session_id
2348
+ ORDER BY locomotion_ms DESC
2349
+ LIMIT ${limit}
2350
+ `,
2351
+ query_params: bag.values,
2352
+ };
2353
+ }
1757
2354
  /**
1758
2355
  * Render one funnel step's predicate against the wide `events` columns (ADR
1759
2356
  * 0038). Every field compiles to plain equality on a promoted column, so the
@@ -1838,4 +2435,272 @@ export function buildFunnel(projectId, opts, d) {
1838
2435
  query_params: bag.values,
1839
2436
  };
1840
2437
  }
2438
+ /**
2439
+ * Canned scene/level retention funnel (#147): session counts flowing scene →
2440
+ * scene in the order they were observed, built directly from `scene_change`
2441
+ * markers with **no caller-authored steps** (the zero-config complement to the
2442
+ * ADR 0038 funnel). Each `scene_change` envelope carries the scene now active in
2443
+ * `scene_id`, so a session's ordered `scene_change` targets are the levels it
2444
+ * moved through; every **consecutive pair** is a directed link.
2445
+ *
2446
+ * Semantics — for one session, order its `scene_change` events by `ts`; the link
2447
+ * `A → B` exists whenever `B`'s marker is the *next* `scene_change` after an `A`
2448
+ * marker. A link's weight is the number of **distinct sessions** that made that
2449
+ * consecutive transition, so it reads as level-to-level retention. Sessions with
2450
+ * a single `scene_change` contribute no link (there is no "from").
2451
+ *
2452
+ * Implementation — `sc` is the per-session ordered `scene_change` stream; `nxt`
2453
+ * finds, for each marker, the timestamp of the very next marker in the same
2454
+ * session via a self-join + `MIN` (no window/ASOF functions, so it renders
2455
+ * identically on DuckDB and ClickHouse — the same parity discipline as
2456
+ * {@link buildFunnel}); joining that back to `sc` resolves the `to_scene`. The
2457
+ * final `GROUP BY` counts distinct sessions per `(from_scene, to_scene)` pair.
2458
+ * A same-timestamp tie between two markers can fan out to multiple `to` rows;
2459
+ * this is a benign edge case for a preset over human-paced scene switches.
2460
+ */
2461
+ export function buildSceneRetention(projectId, opts, d) {
2462
+ const bag = new ParamBag(d);
2463
+ const pid = bag.add("projectId", "string", projectId);
2464
+ const range = rangeClause(bag, opts);
2465
+ const limit = bag.add("limit", "u32", opts.limit ?? 100);
2466
+ return {
2467
+ query: `
2468
+ WITH sc AS (
2469
+ SELECT session_id, ts, scene_id
2470
+ FROM events
2471
+ WHERE project_id = ${pid} AND event_type = 'scene_change'${range}
2472
+ ),
2473
+ nxt AS (
2474
+ SELECT a.session_id AS session_id, a.ts AS from_ts, a.scene_id AS from_scene,
2475
+ min(b.ts) AS to_ts
2476
+ FROM sc a JOIN sc b ON b.session_id = a.session_id AND b.ts > a.ts
2477
+ GROUP BY a.session_id, a.ts, a.scene_id
2478
+ ),
2479
+ links AS (
2480
+ SELECT nxt.session_id AS session_id, nxt.from_scene AS from_scene,
2481
+ sc.scene_id AS to_scene
2482
+ FROM nxt JOIN sc ON sc.session_id = nxt.session_id AND sc.ts = nxt.to_ts
2483
+ )
2484
+ SELECT from_scene, to_scene, count(DISTINCT session_id) AS sessions
2485
+ FROM links
2486
+ GROUP BY from_scene, to_scene
2487
+ ORDER BY sessions DESC, from_scene ASC, to_scene ASC
2488
+ LIMIT ${limit}
2489
+ `,
2490
+ query_params: bag.values,
2491
+ };
2492
+ }
2493
+ /** Default load-time band boundaries (ms) for the load→bounce funnel (#152). */
2494
+ const DEFAULT_LOAD_BANDS = [1000, 3000, 5000];
2495
+ /**
2496
+ * Interaction event types that count as post-load engagement (#152). A session
2497
+ * that produces none of these at/after its initial load is a "bounce". Mirrors
2498
+ * the issue's `pointer_*` / `mesh_interaction` / `camera_gesture` set.
2499
+ */
2500
+ const INTERACTION_EVENT_TYPES = "('pointer_move', 'pointer_down', 'pointer_up', 'pointer_click', 'mesh_interaction', 'camera_gesture')";
2501
+ /**
2502
+ * Load → bounce/abandon funnel (#152): bucket sessions by their initial load
2503
+ * time and report how many **bounced** per band — a bounce being a session that
2504
+ * produced no interaction event (`pointer_*` / `mesh_interaction` /
2505
+ * `camera_gesture`) at or after its first `asset_load`. Turns "slow load costs
2506
+ * you customers" into a concrete per-band number.
2507
+ *
2508
+ * Semantics — a session's load time is the `loadMs` of its **earliest**
2509
+ * `asset_load` (the initial scene load). Engagement is any interaction event in
2510
+ * the same session at a timestamp `>=` that load event's timestamp, across
2511
+ * scenes — bounce is a session-level signal, so the engagement check is not
2512
+ * re-bounded by the range's `until` (a load near the window's end is not counted
2513
+ * as a false bounce). Sessions with no `asset_load` in scope are excluded.
2514
+ * `loadMs` lives in the `payload` JSON (it is not a promoted column), so it is
2515
+ * read with `jsonInt`.
2516
+ *
2517
+ * Bands come from `opts.bands` (ascending exclusive upper bounds in ms), or the
2518
+ * `[1000, 3000, 5000]` default → four bands. The builder emits a plain `CASE`
2519
+ * over the bound band values plus `JOIN` / `min` / `count` / `sum` — **no window
2520
+ * or ASOF functions** — so it renders identically on DuckDB (OSS) and ClickHouse
2521
+ * (scale tier). Band labels are the caller's concern.
2522
+ */
2523
+ export function buildLoadBounceFunnel(projectId, opts, d) {
2524
+ const bag = new ParamBag(d);
2525
+ const pid = bag.add("projectId", "string", projectId);
2526
+ const range = rangeClause(bag, opts);
2527
+ const scene = sceneClause(bag, opts);
2528
+ const loadMs = d.jsonInt("payload", "loadMs");
2529
+ const bands = opts.bands != null && opts.bands.length > 0 ? opts.bands : DEFAULT_LOAD_BANDS;
2530
+ const bandCase = `CASE\n${bands
2531
+ .map((upper, i) => ` WHEN load_ms < ${bag.add(`band${i}`, "f64", upper)} THEN ${i}`)
2532
+ .join("\n")}\n ELSE ${bands.length}\n END`;
2533
+ return {
2534
+ query: `
2535
+ WITH first_load AS (
2536
+ SELECT session_id, min(ts) AS load_ts
2537
+ FROM events
2538
+ WHERE project_id = ${pid} AND event_type = 'asset_load'${range}${scene}
2539
+ GROUP BY session_id
2540
+ ),
2541
+ load_ms AS (
2542
+ SELECT fl.session_id AS session_id, fl.load_ts AS load_ts,
2543
+ min(${loadMs}) AS load_ms
2544
+ FROM events AS e JOIN first_load AS fl
2545
+ ON e.session_id = fl.session_id AND e.ts = fl.load_ts
2546
+ WHERE e.project_id = ${pid} AND e.event_type = 'asset_load'
2547
+ GROUP BY fl.session_id, fl.load_ts
2548
+ ),
2549
+ engaged AS (
2550
+ SELECT lm.session_id AS session_id, count() AS interactions
2551
+ FROM events AS e JOIN load_ms AS lm
2552
+ ON e.session_id = lm.session_id
2553
+ WHERE e.project_id = ${pid}
2554
+ AND e.event_type IN ${INTERACTION_EVENT_TYPES}
2555
+ AND e.ts >= lm.load_ts
2556
+ GROUP BY lm.session_id
2557
+ )
2558
+ SELECT
2559
+ ${bandCase} AS band,
2560
+ count() AS sessions,
2561
+ sum(CASE WHEN coalesce(eng.interactions, 0) = 0 THEN 1 ELSE 0 END) AS bounced
2562
+ FROM load_ms AS lm LEFT JOIN engaged AS eng ON lm.session_id = eng.session_id
2563
+ WHERE lm.load_ms IS NOT NULL
2564
+ GROUP BY band
2565
+ ORDER BY band ASC
2566
+ `,
2567
+ query_params: bag.values,
2568
+ };
2569
+ }
2570
+ function leaderboardPredicate(bag, step, prefix) {
2571
+ const parts = [`event_type = ${bag.add(`${prefix}Type`, "string", step.type)}`];
2572
+ if (step.name != null && step.name.length > 0) {
2573
+ parts.push(`name = ${bag.add(`${prefix}Name`, "string", step.name)}`);
2574
+ }
2575
+ if (step.mesh != null && step.mesh.length > 0) {
2576
+ parts.push(`mesh = ${bag.add(`${prefix}Mesh`, "string", step.mesh)}`);
2577
+ }
2578
+ return parts.join(" AND ");
2579
+ }
2580
+ /**
2581
+ * Variant → conversion leaderboard for product configurators (#150).
2582
+ *
2583
+ * A **variant** is an event matching the `variant` predicate (default: every
2584
+ * `custom` event), grouped by its promoted `name` column — the color / material /
2585
+ * SKU discriminator configurators emit as custom-event names (payload `props` are
2586
+ * not portably queryable, so `name` is the grouping key; ADR 0038). Per variant
2587
+ * the leaderboard reports:
2588
+ *
2589
+ * - **views** — how many matching events fired, and over how many distinct
2590
+ * **sessions**;
2591
+ * - **conversions** — distinct sessions that fired the optional `conversion`
2592
+ * event at or after their first view of that variant (ordered, first-touch);
2593
+ * `0` when no `conversion` predicate is supplied. The consumer derives the rate
2594
+ * as `conversions / sessions`;
2595
+ * - **avg_dwell_ms** — the mean gap from each view to the next *boundary* in the
2596
+ * same session: a later view of a **different** variant (a switch) or a later
2597
+ * conversion event. A re-view of the *same* variant is not a boundary. Views
2598
+ * with no later boundary are excluded from the average.
2599
+ *
2600
+ * Implementation — a CTE chain using only `JOIN` / `min` / `avg` / `count` /
2601
+ * `UNION ALL` (no window or ASOF functions), so it renders identically on DuckDB
2602
+ * (OSS) and ClickHouse (scale tier) (ADR 0020) and is injection-safe. Session
2603
+ * scope (range / scene / camera-mode) applies to both the variant and conversion
2604
+ * event sets. Ranked by views, capped to `limit`.
2605
+ *
2606
+ * The predicates come from the caller (request input / CLI / hosted) — OSS has no
2607
+ * authoring surface (ADR 0038).
2608
+ */
2609
+ export function buildVariantLeaderboard(projectId, opts, d) {
2610
+ const bag = new ParamBag(d);
2611
+ const pid = bag.add("projectId", "string", projectId);
2612
+ const range = rangeClause(bag, opts);
2613
+ const scene = sceneClause(bag, opts);
2614
+ const cameraMode = cameraModeClause(bag, d, projectId, opts);
2615
+ const limit = bag.add("limit", "u32", opts.limit ?? 50);
2616
+ const variantPred = leaderboardPredicate(bag, opts.variant ?? { type: "custom" }, "v");
2617
+ const hasConversion = opts.conversion != null;
2618
+ const conversionPred = hasConversion
2619
+ ? leaderboardPredicate(bag, opts.conversion, "c")
2620
+ : "";
2621
+ // Every variant event: (session, variant name, ts). `name` is the discriminator.
2622
+ const ctes = [
2623
+ `variant_views AS (
2624
+ SELECT session_id, name AS variant, ts
2625
+ FROM events
2626
+ WHERE project_id = ${pid} AND ${variantPred}${range}${scene}${cameraMode}
2627
+ )`,
2628
+ ];
2629
+ // Conversion events (optional): (session, ts), same session scope.
2630
+ if (hasConversion) {
2631
+ ctes.push(`conversions AS (
2632
+ SELECT session_id, ts
2633
+ FROM events
2634
+ WHERE project_id = ${pid} AND ${conversionPred}${range}${scene}${cameraMode}
2635
+ )`);
2636
+ }
2637
+ // Per-view aggregates: total views and distinct sessions per variant.
2638
+ ctes.push(`view_counts AS (
2639
+ SELECT variant, count() AS views, count(DISTINCT session_id) AS sessions
2640
+ FROM variant_views
2641
+ GROUP BY variant
2642
+ )`);
2643
+ // First time each session saw each variant — the ordered anchor for conversion.
2644
+ ctes.push(`first_view AS (
2645
+ SELECT session_id, variant, min(ts) AS t0
2646
+ FROM variant_views
2647
+ GROUP BY session_id, variant
2648
+ )`);
2649
+ // Distinct sessions that converted at/after first seeing the variant (ordered).
2650
+ if (hasConversion) {
2651
+ ctes.push(`converted AS (
2652
+ SELECT fv.variant AS variant, count(DISTINCT fv.session_id) AS conversions
2653
+ FROM first_view fv
2654
+ JOIN conversions c ON c.session_id = fv.session_id AND c.ts >= fv.t0
2655
+ GROUP BY fv.variant
2656
+ )`);
2657
+ }
2658
+ // Boundaries for dwell: every variant view (carrying its variant, is_conv = 0)
2659
+ // plus every conversion event (is_conv = 1). A view's next boundary is the
2660
+ // earliest later boundary that is a conversion OR a different variant.
2661
+ const boundaryParts = [
2662
+ `SELECT session_id, ts, variant AS b_variant, 0 AS is_conv FROM variant_views`,
2663
+ ];
2664
+ if (hasConversion) {
2665
+ boundaryParts.push(`SELECT session_id, ts, '' AS b_variant, 1 AS is_conv FROM conversions`);
2666
+ }
2667
+ ctes.push(`boundaries AS (
2668
+ ${boundaryParts.join("\n UNION ALL ")}
2669
+ )`);
2670
+ // Per view: gap to its next boundary. The JOIN drops views with no boundary,
2671
+ // so they are excluded from the average (as specified).
2672
+ ctes.push(`view_dwell AS (
2673
+ SELECT v.variant AS variant,
2674
+ ${d.epochMs("min(b.ts)")} - ${d.epochMs("v.ts")} AS dwell_ms
2675
+ FROM variant_views v
2676
+ JOIN boundaries b
2677
+ ON b.session_id = v.session_id
2678
+ AND b.ts > v.ts
2679
+ AND NOT (b.is_conv = 0 AND b.b_variant = v.variant)
2680
+ GROUP BY v.session_id, v.variant, v.ts
2681
+ )`);
2682
+ ctes.push(`dwell AS (
2683
+ SELECT variant, avg(dwell_ms) AS avg_dwell_ms
2684
+ FROM view_dwell
2685
+ GROUP BY variant
2686
+ )`);
2687
+ const conversionsSelect = hasConversion ? `coalesce(cv.conversions, 0)` : `0`;
2688
+ const convJoin = hasConversion ? `\n LEFT JOIN converted cv ON cv.variant = vc.variant` : "";
2689
+ return {
2690
+ query: `
2691
+ WITH ${ctes.join(",\n ")}
2692
+ SELECT
2693
+ vc.variant AS variant,
2694
+ vc.views AS views,
2695
+ vc.sessions AS sessions,
2696
+ ${conversionsSelect} AS conversions,
2697
+ coalesce(dw.avg_dwell_ms, 0) AS avg_dwell_ms
2698
+ FROM view_counts vc
2699
+ LEFT JOIN dwell dw ON dw.variant = vc.variant${convJoin}
2700
+ ORDER BY vc.views DESC, vc.variant ASC
2701
+ LIMIT ${limit}
2702
+ `,
2703
+ query_params: bag.values,
2704
+ };
2705
+ }
1841
2706
  //# sourceMappingURL=aggregations.js.map