diff --git a/backend/charts/monitoring/dashboards/general/omi-core-features.json b/backend/charts/monitoring/dashboards/general/omi-core-features.json index 6ae810d2b87..61082229ac3 100644 --- a/backend/charts/monitoring/dashboards/general/omi-core-features.json +++ b/backend/charts/monitoring/dashboards/general/omi-core-features.json @@ -3871,6 +3871,116 @@ ], "title": "Rejections by class", "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Failed requests per second by lane, largest ten. A lane that fails on low traffic barely shows here, so read this with the error rate panel. Reads a recording rule, so it lags about 5 minutes.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 1, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "normal" + } + }, + "unit": "ops" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 51 + }, + "id": 91, + "interval": "5m", + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "topk(10, sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m{outcome=\"error\"}) > 0)", + "legendFormat": "{{lane_id}}", + "refId": "A" + } + ], + "title": "Gateway errors by lane", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "Share of each lane's requests that failed, for lanes above about 1 request a minute, highest ten. Reads a recording rule, so it lags about 5 minutes.", + "fieldConfig": { + "defaults": { + "custom": { + "drawStyle": "line", + "fillOpacity": 12, + "lineWidth": 1, + "showPoints": "never", + "stacking": { + "group": "A", + "mode": "none" + } + }, + "unit": "percent" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 51 + }, + "id": 92, + "interval": "5m", + "options": { + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "expr": "topk(10, 100 * sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m{outcome=\"error\"}) / (sum by (lane_id) (lane_outcome:llm_gateway_requests:rate5m_offset5m) > 0.0167) > 0)", + "legendFormat": "{{lane_id}}", + "refId": "A" + } + ], + "title": "Gateway error rate by lane", + "type": "timeseries" } ], "title": "AI responses — the LLM gateway behind chat and extraction", diff --git a/web/admin/grafana/build_wall_dashboard.py b/web/admin/grafana/build_wall_dashboard.py index 42e3a5091d3..a2958a1c0c5 100644 --- a/web/admin/grafana/build_wall_dashboard.py +++ b/web/admin/grafana/build_wall_dashboard.py @@ -69,6 +69,7 @@ STAT_H = 3 STRIP_H = 1 WALL_H = 27 +FUNNEL_H = 9 # One value size and label size on every stat; sized so the widest value # ("10,287", "$72.4K") fits a three-value column at 1440 px. STAT_VALUE_SIZE, STAT_LABEL_SIZE = 26, 13 @@ -588,6 +589,36 @@ def curve_options(panel): d["noValue"] = "Cohorts still filling" # an empty frame, not "Cannot visualize data" +def funnel_row(): + """Below the TV's fixed wall: a full-width row reached by scrolling.""" + proxy = ("Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as " + "organic, and pairing is itself an early success, so the gap between the two is overstated. ") + series_colors = [color("Device", MAGENTA), color("Organic", NEUTRAL)] + funnel = chart("Funnel · device vs organic", + proxy + "Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old " + "enough to measure it. Onboarded counts only people who arrived on iOS, Android or Mac, the " + "clients that report it. Install is not shown because PostHog sees nobody before sign-up; first " + "summary is not shown because it has no event.", + [target("activation", "funnel", [("step", "Step", "string"), ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, kind="barchart", overrides=series_colors, + extra=barchart_options(max_value=100, value_size=13)) + weekly = chart("Activated · device vs organic", + proxy + "Week-one activation for each matured weekly cohort, dated by its last day.", + [target("activation", "funnelWeekly", [DATE, ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, soft_min=0, time_from="60d", overrides=series_colors) + platforms = chart("Funnel by platform", + proxy + "Week-one activation in the latest matured weekly new-user cohort, by the platform users " + "first arrived on, with its size in the label. Small platforms move a lot week to week.", + [target("activation", "funnelPlatforms", [("platform", "Platform", "string"), + ("Device", "Device", "number"), + ("Organic", "Organic", "number")])], + unit="percent", decimals=0, kind="barchart", overrides=series_colors, + extra=barchart_options(horizontal=True, max_value=100, value_size=13)) + return [(11, funnel), (7, weekly), (6, platforms)] + + def build_wall_board(): panels = [] for name, items in columns().items(): @@ -602,6 +633,11 @@ def build_wall_board(): panels.append({**panel, "gridPos": {"x": x, "y": y, "w": w, "h": h}}) y += h assert y == WALL_H, (name, y) # every column fills the TV exactly, no scroll + x = 0 + for w, panel in funnel_row(): + panels.append({**panel, "gridPos": {"x": x, "y": WALL_H, "w": w, "h": FUNNEL_H}}) + x += w + assert x == 24 for i, panel in enumerate(panels, 1): panel["id"] = i return {"uid": "omi-tv-v2", "title": "Omi TV · v3", "schemaVersion": 39, "version": 1, "timezone": "utc", diff --git a/web/admin/grafana/dashboards/omi-tv-v2.json b/web/admin/grafana/dashboards/omi-tv-v2.json index 28a701dd0e9..577024bca38 100644 --- a/web/admin/grafana/dashboards/omi-tv-v2.json +++ b/web/admin/grafana/dashboards/omi-tv-v2.json @@ -6981,6 +6981,458 @@ "h": 1 }, "id": 30 + }, + { + "type": "barchart", + "title": "Funnel \u00b7 device vs organic", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Latest matured weekly new-user cohort, except Kept, which uses the newest cohort old enough to measure it. Onboarded counts only people who arrived on iOS, Android or Mac, the clients that report it. Install is not shown because PostHog sees nobody before sign-up; first summary is not shown because it has no event.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnel", + "columns": [ + { + "selector": "step", + "text": "Step", + "type": "string" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "fillOpacity": 88, + "gradientMode": "none", + "lineWidth": 0, + "axisPlacement": "auto", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false + }, + "noValue": "Loading\u2026", + "decimals": 0, + "min": 0, + "max": 100 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + }, + "orientation": "vertical", + "barWidth": 0.8, + "groupWidth": 0.75, + "text": { + "valueSize": 13 + }, + "showValue": "never", + "stacking": "none", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + "fullHighlight": false, + "barRadius": 0.08 + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "Step": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "gridPos": { + "x": 0, + "y": 27, + "w": 11, + "h": 9 + }, + "id": 31 + }, + { + "type": "timeseries", + "title": "Activated \u00b7 device vs organic", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation for each matured weekly cohort, dated by its last day.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnelWeekly", + "columns": [ + { + "selector": "date", + "text": "date", + "type": "timestamp" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "drawStyle": "line", + "lineWidth": 2, + "fillOpacity": 0, + "gradientMode": "none", + "showPoints": "never", + "pointSize": 5, + "spanNulls": true, + "barAlignment": 0, + "axisPlacement": "left", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false, + "axisCenteredZero": false, + "stacking": { + "mode": "none", + "group": "A" + }, + "lineInterpolation": "smooth", + "thresholdsStyle": { + "mode": "off" + } + }, + "noValue": "Loading\u2026", + "decimals": 0 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "date": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "timeFrom": "60d", + "hideTimeOverride": true, + "gridPos": { + "x": 11, + "y": 27, + "w": 7, + "h": 9 + }, + "id": 32 + }, + { + "type": "barchart", + "title": "Funnel by platform", + "description": "Device = paired an Omi within 48 hours of first seen; other paired hardware counts as organic. It is a proxy: a buyer who never paired counts as organic, and pairing is itself an early success, so the gap between the two is overstated. Week-one activation in the latest matured weekly new-user cohort, by the platform users first arrived on, with its size in the label. Small platforms move a lot week to week.", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "yesoreyeram-infinity-datasource", + "uid": "omi-admin-api" + }, + "type": "json", + "source": "url", + "format": "table", + "parser": "backend", + "url": "http://127.0.0.1:8899/api/omi/stats/wall/series/activation", + "url_options": { + "method": "GET" + }, + "root_selector": "frames.funnelPlatforms", + "columns": [ + { + "selector": "platform", + "text": "Platform", + "type": "string" + }, + { + "selector": "Device", + "text": "Device", + "type": "number" + }, + { + "selector": "Organic", + "text": "Organic", + "type": "number" + } + ] + } + ], + "fieldConfig": { + "defaults": { + "unit": "percent", + "color": { + "mode": "fixed", + "fixedColor": "#e8e6df" + }, + "custom": { + "fillOpacity": 88, + "gradientMode": "none", + "lineWidth": 0, + "axisPlacement": "auto", + "axisLabel": "", + "axisSoftMin": 0, + "axisBorderShow": false + }, + "noValue": "Loading\u2026", + "decimals": 0, + "min": 0, + "max": 100 + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Device" + }, + "properties": [ + { + "id": "displayName", + "value": "Device" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#d55181" + } + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Organic" + }, + "properties": [ + { + "id": "displayName", + "value": "Organic" + }, + { + "id": "color", + "value": { + "mode": "fixed", + "fixedColor": "#a8a69e" + } + } + ] + } + ] + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "tooltip": { + "mode": "multi", + "sort": "none" + }, + "orientation": "horizontal", + "barWidth": 0.8, + "groupWidth": 0.75, + "text": { + "valueSize": 13 + }, + "showValue": "always", + "stacking": "none", + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0, + "fullHighlight": false, + "barRadius": 0.08 + }, + "transformations": [ + { + "id": "organize", + "options": { + "indexByName": { + "Platform": 0, + "Device": 1, + "Organic": 2 + }, + "excludeByName": {}, + "renameByName": {} + } + } + ], + "gridPos": { + "x": 18, + "y": 27, + "w": 6, + "h": 9 + }, + "id": 33 } ], "links": [], diff --git a/web/admin/grafana/test_build_wall_dashboard.py b/web/admin/grafana/test_build_wall_dashboard.py index f1faa38849f..87fca2ef34a 100644 --- a/web/admin/grafana/test_build_wall_dashboard.py +++ b/web/admin/grafana/test_build_wall_dashboard.py @@ -24,6 +24,14 @@ def is_text_line(panel): class WallV2Contract(unittest.TestCase): + def test_funnel_row_sits_below_the_tv_wall(self): + from build_wall_dashboard import WALL_H + row = [p for p in load("omi-tv-v2")["panels"] if p["gridPos"]["y"] >= WALL_H] + self.assertEqual({p["gridPos"]["y"] for p in row}, {WALL_H}) + self.assertEqual(sum(p["gridPos"]["w"] for p in row), 24) + self.assertEqual({p["targets"][0]["root_selector"] for p in row}, + {"frames.funnel", "frames.funnelWeekly", "frames.funnelPlatforms"}) + def test_checked_in_wall_matches_builder(self): from build_wall_dashboard import build_wall_board self.assertEqual(load("omi-tv-v2"), build_wall_board()) @@ -51,7 +59,8 @@ def test_story_columns_stat_above_charts_on_one_screen(self): board = load("omi-tv-v2") for name in COLUMNS: x, w = GEOMETRY[name] - column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x), key=lambda p: p["gridPos"]["y"]) + column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x and p["gridPos"]["y"] < WALL_H), + key=lambda p: p["gridPos"]["y"]) self.assertEqual(column[0]["type"], "stat") self.assertTrue(column[0]["title"].startswith(name), column[0]["title"]) self.assertEqual(column[0]["gridPos"]["y"], 0) @@ -90,7 +99,7 @@ def test_charts_are_labelled_in_plain_english(self): def test_each_stat_has_an_api_delta_strip_in_field_order(self): import re - from build_wall_dashboard import BAD_MARK, COLUMNS, GEOMETRY, GOOD_MARK, STAT_H + from build_wall_dashboard import BAD_MARK, COLUMNS, GEOMETRY, GOOD_MARK, STAT_H, WALL_H board = load("omi-tv-v2") source = (HERE.parent / "lib/wall-series.ts").read_text() block = re.search(r"DELTA_STRIPS[^=]*= \{(.*?)\};", source, re.S).group(1) @@ -99,7 +108,8 @@ def test_each_stat_has_an_api_delta_strip_in_field_order(self): self.assertIn('BAD_MARK = "\\u2060"', source) for name in COLUMNS: x, _ = GEOMETRY[name] - column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x), key=lambda p: p["gridPos"]["y"]) + column = sorted((p for p in board["panels"] if p["gridPos"]["x"] == x and p["gridPos"]["y"] < WALL_H), + key=lambda p: p["gridPos"]["y"]) head, strip = column[0], column[1] self.assertTrue(is_strip(strip)) self.assertFalse(head["options"]["showPercentChange"]) # native change is relative only diff --git a/web/admin/lib/__tests__/wall-series.test.ts b/web/admin/lib/__tests__/wall-series.test.ts index 673e8950daa..336811065e0 100644 --- a/web/admin/lib/__tests__/wall-series.test.ts +++ b/web/admin/lib/__tests__/wall-series.test.ts @@ -17,6 +17,7 @@ import { STORE_FAIL_LIMIT, UNIT_DAYS, warmSeries, + funnelFrames, } from "../wall-series"; import { resetWallCache, wallPayload } from "../wall-cache"; import { @@ -1723,3 +1724,77 @@ describe("variable margin frames (David 2026-10-23)", () => { ); }); }); + +describe("funnelFrames", () => { + const now = new Date("2026-11-02T12:00:00Z"); + // period, entry, device, people, onboarded, conv1, conv7, activated, kept + const deviceFunnel = Object.assign( + [ + [0, "ios", 1, 40, 36, 20, 30, 28, 0], + [0, "ios", 0, 60, 54, 6, 12, 12, 0], + [0, "android", 0, 100, 80, 10, 20, 25, 0], + [4, "ios", 1, 10, 9, 5, 7, 7, 5], + [4, "ios", 0, 90, 80, 9, 18, 20, 3], + ], + { sourceRefreshedAt: null } + ); + + it("splits the latest cohort by paired and shows Kept from the older one", () => { + const { funnel } = funnelFrames({ deviceFunnel }, now); + expect(funnel[0]).toEqual({ + step: "Share of new users", + Device: 20, + Organic: 80, + }); + expect(funnel.find((r) => r.step === "Activated")).toEqual({ + step: "Activated", + Device: 70, + Organic: 23.1, + }); + expect(funnel.at(-1)).toEqual({ + step: "Kept, weeks 2–5 (older cohort)", + Device: 50, + Organic: 3.3, + }); + }); + + it("counts Onboarded only on clients that report it", () => { + const rows = Object.assign( + [ + [0, "ios", 0, 10, 5, 0, 0, 0, 0], + [0, "windows", 0, 10, 0, 0, 0, 0, 0], + ], + { sourceRefreshedAt: null } + ); + const { funnel } = funnelFrames({ deviceFunnel: rows }, now); + expect(funnel[1]).toEqual({ + step: "Onboarded (phone and Mac)", + Device: null, + Organic: 50, + }); + }); + + it("orders platforms by size and leaves an absent split empty", () => { + const { funnelPlatforms } = funnelFrames({ deviceFunnel }, now); + expect(funnelPlatforms).toEqual([ + { platform: "iOS · 100", Device: 70, Organic: 20 }, + { platform: "Android · 100", Device: null, Organic: 25 }, + ]); + }); + + it("dates weeks by the cohort's last day, oldest first, skipping missing ones", () => { + const { funnelWeekly } = funnelFrames({ deviceFunnel }, now); + expect(funnelWeekly.map((r) => r.date)).toEqual([ + "2026-09-27T00:00:00Z", + "2026-10-25T00:00:00Z", + ]); + }); + + it("is empty when the leg has not filled", () => { + expect(funnelFrames({}, now)).toEqual({ + funnel: [], + funnelPlatforms: [], + funnelWeekly: [], + }); + }); +}); diff --git a/web/admin/lib/wall-posthog.ts b/web/admin/lib/wall-posthog.ts index 9b7f67a246f..3bb6478178b 100644 --- a/web/admin/lib/wall-posthog.ts +++ b/web/admin/lib/wall-posthog.ts @@ -442,6 +442,44 @@ export const CAPTURE_SEGMENTS = [ "No capture", ] as const; +/** One matured weekly new-user cohort split by platform and by "device": paired + * Omi hardware within 48 hours of first seen. Mobile and macOS both send + * `device_vendor` 'omi' for Omi and Omi Glass; `type` covers older mobile + * builds. Other vendors (Apple Watch, Limitless, …) also send Device Paired and + * stay organic. A buyer who never paired counts as organic, so the split is a + * proxy. Kept (activated, then active in days 7–35) needs the + * scan to run 28 days past `end`, so it is complete only once `end` is at least + * 28 days old; later scan days that have not happened yet read nothing. + * Columns: entry, device, people, onboarded, conv1, conv7, activated, kept. + */ +export function deviceFunnelQuery(end: Date) { + const cohortEnd = shiftDay(end, -7); + const start = shiftDay(cohortEnd, -7); + return `WITH ${raw( + shiftDay(start, -180), + shiftDay(end, 28), + `AND person.created_at >= ${sqlTime( + start + )} AND person.created_at < ${sqlTime(cohortEnd)}` + )}, + people AS (SELECT actor,min(anchor) a, + coalesce(nullIf(argMinIf(p,tuple(timestamp,${PLATFORM_RANK_SQL}),p!='unknown'),''),'unknown') entry, + min(timestamp) first_event, + countIf(event='Device Paired' AND (properties.device_vendor='omi' OR properties.type IN ('omi','openglass')) + AND timestamp < anchor + interval 2 day) > 0 device, + countIf(event='Onboarding Completed' AND timestamp < anchor + interval 7 day) > 0 onboarded, + countIf(cap AND qt >= anchor AND qt < anchor + interval 1 day) > 0 conv1, + countIf(cap AND qt >= anchor AND qt < anchor + interval 7 day) > 0 conv7, + uniqExactIf(${UTC_DAY( + "qt" + )},(fg OR cap) AND qt >= anchor AND qt < anchor + interval 7 day) days7, + countIf((fg OR cap) AND qt >= anchor + interval 7 day AND qt < anchor + interval 35 day) > 0 later + FROM qualified GROUP BY actor) + SELECT entry,device,count(),countIf(onboarded),countIf(conv1),countIf(conv7), + countIf(days7>=2),countIf(days7>=2 AND later) + FROM people WHERE first_event >= a GROUP BY entry,device LIMIT 100`; +} + /** Week-one activation by first-week capture source for the latest matured * weekly cohort: the snapshot cohort (same anchor, 180-day history check and * ≥2 qualified days), so segment people sum to the activation denominator. diff --git a/web/admin/lib/wall-series.ts b/web/admin/lib/wall-series.ts index ed65e2e376e..4f250187188 100644 --- a/web/admin/lib/wall-series.ts +++ b/web/admin/lib/wall-series.ts @@ -25,6 +25,7 @@ import { AggregateRows, CAPTURE_SEGMENTS, captureSourceQuery, + deviceFunnelQuery, CHURNED_ROW, cohortQuery, DAYS_ROW_PREFIX, @@ -206,6 +207,8 @@ export interface Legs { growth?: AggregateRows; activationHistory?: AggregateRows; captureSource?: AggregateRows; + /** Weekly cohorts by platform and paired-within-48h; period 0 is the newest. */ + deviceFunnel?: AggregateRows; retainedHistory?: AggregateRows; establishedHistory?: AggregateRows; curve?: AggregateRows; @@ -241,7 +244,7 @@ type LegName = keyof Legs; const LEGS_FOR: Record = { headline: ["finops", "mauPrior", "billing"], growth: ["growth"], - activation: ["activationHistory", "captureSource"], + activation: ["activationHistory", "captureSource", "deviceFunnel"], retention: ["curve", "retainedHistory", "establishedHistory", "growth"], revenue: ["finops", "mauPrior", "mauDaily", "shopify"], health: [ @@ -359,6 +362,17 @@ export function legJobs( ), captureSource: () => read({ kind: "capture", end }, captureSourceQuery(end)), + // One cohort per read; partial cohorts are kept and only the missing retried. + deviceFunnel: () => + periods(FUNNEL_WEEKS, async (p) => + Object.assign( + (await sources.query(deviceFunnelQuery(back(p)))).map((r) => [ + 0, + ...r, + ]), + { sourceRefreshedAt: null } + ) + ), retainedHistory: () => history( (stop, n) => cohortQuery(stop, 37, n), @@ -601,6 +615,97 @@ const CAPTURE_LABELS: Record<(typeof CAPTURE_SEGMENTS)[number], string> = { "No capture": "No capture", }; +/** Weekly cohorts on the funnel row; the oldest two are old enough to show Kept. */ +const FUNNEL_WEEKS = 6; +const FUNNEL_KEPT_PERIOD = 4; +const FUNNEL_PLATFORMS: Record = { + ios: "iOS", + android: "Android", + macos: "macOS", + windows: "Windows", + web: "Web", +}; +// Columns of a `deviceFunnel` row: period, entry, device, then these counts. +const PEOPLE = 3; +const ACTIVATED = 7; +const KEPT = 8; +// Only these clients send Onboarding Completed (Windows and web do not), so +// the Onboarded step counts people who entered on one of them. +const ONBOARDING_PLATFORMS: unknown[] = ["ios", "android", "macos"]; +const ONBOARDED = 4; +const FUNNEL_STEPS = [ + ["Conversation, day 1", 5], + ["Conversation, week 1", 6], + ["Activated", ACTIVATED], +] as const; + +export function funnelFrames(legs: Legs, now: Date) { + const all = (legs.deviceFunnel || []) as unknown as unknown[][]; + const week = (p: number) => all.filter((r) => Number(r[0]) === p); + const device = (rows: unknown[][]) => rows.filter((r) => Number(r[2]) === 1); + const organic = (rows: unknown[][]) => rows.filter((r) => Number(r[2]) !== 1); + const count = (rows: unknown[][], column: number) => + rows.reduce((sum, r) => sum + Number(r[column]), 0); + const percent = (part: number, whole: number) => + whole ? Math.round((1000 * part) / whole) / 10 : null; + const rate = (rows: unknown[][], column: number) => + percent(count(rows, column), count(rows, PEOPLE)); + const split = (step: string, rows: unknown[][], column: number): Row => ({ + step, + Device: rate(device(rows), column), + Organic: rate(organic(rows), column), + }); + + const latest = week(0); + const people = count(latest, PEOPLE); + const funnel: Row[] = people + ? [ + { + step: "Share of new users", + Device: percent(count(device(latest), PEOPLE), people), + Organic: percent(count(organic(latest), PEOPLE), people), + }, + split( + "Onboarded (phone and Mac)", + latest.filter((r) => ONBOARDING_PLATFORMS.includes(r[1])), + ONBOARDED + ), + ...FUNNEL_STEPS.map(([step, column]) => split(step, latest, column)), + ] + : []; + const older = week(FUNNEL_KEPT_PERIOD); + if (people && count(older, PEOPLE)) + funnel.push(split("Kept, weeks 2–5 (older cohort)", older, KEPT)); + + // Users ride in the label, largest platform first, as on Activation by capture. + const funnelPlatforms: Row[] = Object.entries(FUNNEL_PLATFORMS) + .map(([key, platform]) => ({ + platform, + rows: latest.filter((r) => r[1] === key), + })) + .filter(({ rows }) => count(rows, PEOPLE) > 0) + .sort((a, b) => count(b.rows, PEOPLE) - count(a.rows, PEOPLE)) + .map(({ platform, rows }) => ({ + platform: `${platform} · ${count(rows, PEOPLE).toLocaleString("en-US")}`, + Device: rate(device(rows), ACTIVATED), + Organic: rate(organic(rows), ACTIVATED), + })); + + // Dated by the cohort's last day, as the other cohort charts are. + const stop = shiftDay(utcDay(now), -7); + const funnelWeekly: Row[] = []; + for (let p = FUNNEL_WEEKS - 1; p >= 0; p--) { + const rows = week(p); + if (rows.length) + funnelWeekly.push({ + date: ts(isoDay(shiftDay(stop, -7 * p - 1))), + Device: rate(device(rows), ACTIVATED), + Organic: rate(organic(rows), ACTIVATED), + }); + } + return { funnel, funnelPlatforms, funnelWeekly }; +} + export function activationFrames( legs: Legs, snapshot: WallPayload | null, @@ -635,6 +740,7 @@ export function activationFrames( capture: captured, sources, maturing: maturingBand(stop, now), + ...funnelFrames(legs, now), }; }