From 694bfd4a8daf251d17ea30626415912118e282ce Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 10:52:25 +0100 Subject: [PATCH 01/14] feat(web): Product analytics page: events, funnels and paths Product events were only reachable through the dashboard widget builder and one tab on Web analytics. /product-analytics gives them their own page under Analyze, beside Web analytics: - Events: every event name in the window with its kind, counts and reach, and a Funnel and Paths action per row. - Funnels: the kit step builder, Count by / Within / Bars or Drop-off, a stat rail, the funnel chart and a steps table with time between steps and what leavers did. With no steps it suggests pairs from the org's events. - Paths: an anchor event or page, direction, steps and branches, the paths chart, and the most common next step and where sequences ended. The definition lives in the URL (shareable); typed step names wait for a pause before they run. The Web analytics filter rail moves to the kit and both pages mount it. Web analytics' Events card links to the new page. /lab/product-analytics renders every tab and state over fixtures. --- apps/web/src/lab/entries/product-analytics.ts | 220 ++++++++++++ apps/web/src/lab/registry.ts | 8 + .../page/analytics/analytics.scene.test.ts | 3 +- apps/web/src/page/analytics/model.ts | 20 +- apps/web/src/page/analytics/update.ts | 2 +- apps/web/src/page/analytics/view-breakdown.ts | 26 +- apps/web/src/page/analytics/view-filters.ts | 161 ++------- apps/web/src/page/analytics/view-icons.ts | 24 +- apps/web/src/page/analytics/view-overview.ts | 12 + .../web/src/page/kit/analytics-filter-rail.ts | 218 ++++++++++++ .../web/src/page/product-analytics/command.ts | 88 +++++ .../src/page/product-analytics/definition.ts | 119 +++++++ apps/web/src/page/product-analytics/index.ts | 20 ++ .../web/src/page/product-analytics/message.ts | 73 ++++ apps/web/src/page/product-analytics/model.ts | 78 +++++ .../product-analytics.scene.test.ts | 215 ++++++++++++ .../src/page/product-analytics/requests.ts | 111 +++++++ apps/web/src/page/product-analytics/update.ts | 314 ++++++++++++++++++ .../src/page/product-analytics/view-events.ts | 261 +++++++++++++++ .../page/product-analytics/view-funnels.ts | 286 ++++++++++++++++ .../src/page/product-analytics/view-parts.ts | 82 +++++ .../src/page/product-analytics/view-paths.ts | 307 +++++++++++++++++ apps/web/src/page/product-analytics/view.ts | 121 +++++++ apps/web/src/page/registry.ts | 2 + apps/web/src/route/app-route.ts | 1 + apps/web/src/route/routers.ts | 5 + apps/web/src/route/search.ts | 25 +- apps/web/src/route/session-time-range.ts | 1 + apps/web/src/shell/nav.ts | 1 + packages/views/src/ui/INDEX.md | 1 + 30 files changed, 2627 insertions(+), 178 deletions(-) create mode 100644 apps/web/src/lab/entries/product-analytics.ts create mode 100644 apps/web/src/page/kit/analytics-filter-rail.ts create mode 100644 apps/web/src/page/product-analytics/command.ts create mode 100644 apps/web/src/page/product-analytics/definition.ts create mode 100644 apps/web/src/page/product-analytics/index.ts create mode 100644 apps/web/src/page/product-analytics/message.ts create mode 100644 apps/web/src/page/product-analytics/model.ts create mode 100644 apps/web/src/page/product-analytics/product-analytics.scene.test.ts create mode 100644 apps/web/src/page/product-analytics/requests.ts create mode 100644 apps/web/src/page/product-analytics/update.ts create mode 100644 apps/web/src/page/product-analytics/view-events.ts create mode 100644 apps/web/src/page/product-analytics/view-funnels.ts create mode 100644 apps/web/src/page/product-analytics/view-parts.ts create mode 100644 apps/web/src/page/product-analytics/view-paths.ts create mode 100644 apps/web/src/page/product-analytics/view.ts diff --git a/apps/web/src/lab/entries/product-analytics.ts b/apps/web/src/lab/entries/product-analytics.ts new file mode 100644 index 0000000000..f7570da950 --- /dev/null +++ b/apps/web/src/lab/entries/product-analytics.ts @@ -0,0 +1,220 @@ +import { OrgId } from "@maple/domain" +import { Effect, Schema } from "effect" +import { AsyncData } from "foldkit" +import { toLoadFailure } from "@maple/views/data/load-failure" +import type { PageContext } from "../../page/contract" +import type { Search } from "../../page/product-analytics/definition" +import { Message } from "../../page/product-analytics/message" +import { type EventRow, type FunnelRow, Model, type PathsRow } from "../../page/product-analytics/model" +import { init as pageInit, update as pageUpdate } from "../../page/product-analytics/update" +import { view as pageView } from "../../page/product-analytics/view" +import type { Breakdowns } from "../../page/kit/analytics-filter-rail" +import { defineShellLab } from "../shell-lab" + +// `/lab/product-analytics?state=`: the Product analytics tabs over fixtures, in the real +// shell. Reads and navigation are dropped, so nothing leaves the browser. + +const Scenario = Schema.Literals([ + "events", + "events-empty", + "events-loading", + "funnels", + "funnels-bars", + "funnels-new", + "funnels-error", + "paths", + "paths-new", +]) +type Scenario = typeof Scenario.Type +const isScenario = Schema.is(Scenario) + +const context: PageContext = { + orgId: Schema.decodeSync(OrgId)("org_lab"), + pinnedNamespace: null, + timeZone: "UTC", +} + +const EVENTS: ReadonlyArray = [ + { eventName: "$pageview", kind: "navigation", count: 38_412, sessions: 9_204, persons: 4_812 }, + { eventName: "signup_started", kind: "custom", count: 1_488, sessions: 1_260, persons: 1_206 }, + { eventName: "signup_completed", kind: "custom", count: 742, sessions: 742, persons: 742 }, + { eventName: "query_run", kind: "custom", count: 6_210, sessions: 1_310, persons: 402 }, + { eventName: "onboarding_connected", kind: "custom", count: 388, sessions: 388, persons: 388 }, + { eventName: "dashboard_created", kind: "custom", count: 531, sessions: 470, persons: 311 }, + { eventName: "alert_created", kind: "custom", count: 214, sessions: 201, persons: 162 }, + { eventName: "Home", kind: "screen", count: 1_902, sessions: 640, persons: 188 }, + { eventName: "plan_started", kind: "custom", count: 61, sessions: 61, persons: 61 }, +] + +const PREVIOUS: ReadonlyArray = EVENTS.map((row) => ({ + ...row, + count: Math.round(row.count * 0.89), +})) + +const FUNNEL: ReadonlyArray = [ + { name: "signup_started", value: 1_206 }, + { + name: "signup_completed", + value: 742, + p50Ms: 190_000, + p90Ms: 1_420_000, + leavers: [{ name: "docs_opened", count: 210 }], + }, + { + name: "onboarding_connected", + value: 388, + p50Ms: 6_720_000, + p90Ms: 86_000_000, + leavers: [{ name: "", count: 190 }], + }, + { + name: "plan_started", + value: 61, + p50Ms: 187_000_000, + p90Ms: 520_000_000, + leavers: [{ name: "dashboard_viewed", count: 140 }], + }, +] + +const PATHS: ReadonlyArray = [ + { hop: 1, fromNode: "signup_completed", toNode: "onboarding_viewed", count: 410 }, + { hop: 1, fromNode: "signup_completed", toNode: "dashboard_viewed", count: 160 }, + { hop: 1, fromNode: "signup_completed", toNode: "docs_opened", count: 92 }, + { hop: 1, fromNode: "signup_completed", toNode: "$other", count: 50 }, + { hop: 1, fromNode: "signup_completed", toNode: "", count: 30 }, + { hop: 2, fromNode: "onboarding_viewed", toNode: "onboarding_connected", count: 290 }, + { hop: 2, fromNode: "onboarding_viewed", toNode: "query_run", count: 60 }, + { hop: 2, fromNode: "onboarding_viewed", toNode: "", count: 60 }, + { hop: 2, fromNode: "dashboard_viewed", toNode: "query_run", count: 90 }, + { hop: 2, fromNode: "dashboard_viewed", toNode: "/pricing", count: 40 }, + { hop: 2, fromNode: "dashboard_viewed", toNode: "", count: 30 }, + { hop: 2, fromNode: "docs_opened", toNode: "/pricing", count: 34 }, + { hop: 2, fromNode: "docs_opened", toNode: "", count: 58 }, + { hop: 2, fromNode: "$other", toNode: "$other", count: 50 }, + { hop: 3, fromNode: "onboarding_connected", toNode: "dashboard_created", count: 170 }, + { hop: 3, fromNode: "onboarding_connected", toNode: "alert_created", count: 64 }, + { hop: 3, fromNode: "onboarding_connected", toNode: "", count: 56 }, + { hop: 3, fromNode: "query_run", toNode: "dashboard_created", count: 40 }, + { hop: 3, fromNode: "query_run", toNode: "query_run", count: 84 }, + { hop: 3, fromNode: "query_run", toNode: "", count: 26 }, + { hop: 3, fromNode: "/pricing", toNode: "alert_created", count: 32 }, + { hop: 3, fromNode: "/pricing", toNode: "", count: 42 }, + { hop: 3, fromNode: "$other", toNode: "", count: 50 }, +] + +const BREAKDOWNS: Breakdowns = { + referrerHosts: [ + { name: "google.com", count: 3_200 }, + { name: "github.com", count: 1_900 }, + { name: "news.ycombinator.com", count: 804 }, + ], + countries: [ + { name: "US", count: 6_100 }, + { name: "DE", count: 2_400 }, + { name: "GB", count: 1_300 }, + ], + deviceTypes: [ + { name: "desktop", count: 15_000 }, + { name: "mobile", count: 3_200 }, + ], + browsers: [ + { name: "Chrome", count: 12_000 }, + { name: "Safari", count: 4_100 }, + ], + operatingSystems: [{ name: "Mac OS", count: 9_000 }], + languages: [{ name: "en-US", count: 11_000 }], + utmSources: [], + utmMediums: [], + utmCampaigns: [], + entryPaths: [ + { name: "/", count: 8_100 }, + { name: "/pricing", count: 2_300 }, + { name: "/onboarding", count: 744 }, + ], + exitPaths: [], + hosts: [ + { name: "app.maple.dev", count: 12_400 }, + { name: "maple.dev", count: 8_900 }, + ], +} + +const SIGNUP_TO_PAID: Search["steps"] = FUNNEL.map((row) => ({ kind: "event", eventName: row.name })) + +const searchOf = (scenario: Scenario): Search => { + switch (scenario) { + case "events": + case "events-empty": + case "events-loading": + return {} + case "funnels": + case "funnels-error": + return { tab: "funnels", steps: SIGNUP_TO_PAID } + case "funnels-bars": + return { tab: "funnels", steps: SIGNUP_TO_PAID, view: "bars" } + case "funnels-new": + return { tab: "funnels" } + case "paths": + return { tab: "paths", anchor: { kind: "event", eventName: "signup_completed" } } + case "paths-new": + return { tab: "paths" } + } +} + +const loaded = (data: A) => ({ key: "lab", data: AsyncData.Success({ data }) }) + +const init = (flags: typeof Flags.Type) => { + const { scenario } = flags + const base = pageInit({ search: searchOf(scenario) }, context).model + const model: Model = { + ...base, + window: { startTime: "2026-10-27 00:00:00", endTime: "2026-11-03 00:00:00" }, + breakdowns: loaded(BREAKDOWNS), + events: + scenario === "events-loading" + ? { key: "lab", data: AsyncData.Loading() } + : loaded(scenario === "events-empty" ? [] : EVENTS), + previousEvents: loaded(PREVIOUS), + funnel: + scenario === "funnels-error" + ? { + key: "lab", + data: AsyncData.Failure({ error: toLoadFailure("The warehouse timed out.") }), + } + : loaded(FUNNEL), + paths: loaded(PATHS), + } + return { model } +} + +/** Reads and the window never leave the lab; everything local (popovers, typing pauses) runs. */ +const isLocalCommand = (command: { readonly name: string }): boolean => + !command.name.startsWith("Fetch") && command.name !== "ResolveProductAnalyticsWindow" + +const update = (model: Model, message: Message) => { + const next = pageUpdate(model, message, context) + return { model: next.model, commands: (next.commands ?? []).filter(isLocalCommand) } +} + +const Flags = Schema.Struct({ scenario: Scenario }) + +export const lab = defineShellLab("Product analytics", { + Model, + Message, + Flags, + flags: Effect.sync(() => { + const state = new URLSearchParams(window.location.search).get("state") + return { scenario: isScenario(state) ? state : "events" } + }), + init, + update, + view: (model, h) => + h.submodel({ + slotId: "product-analytics-lab", + model, + view: pageView, + toParentMessage: (message: Message) => message, + viewInputs: { context, isBelowSm: false }, + }), + currentPath: "/product-analytics", + breadcrumbs: [{ label: "Product analytics", isPageHeading: true }], +}) diff --git a/apps/web/src/lab/registry.ts b/apps/web/src/lab/registry.ts index 7819e0ae3d..a6bc26f8de 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -306,6 +306,14 @@ export const LAB_ENTRIES: ReadonlyArray = [ kind: "lab", session: "none", }, + { + path: "/lab/product-analytics", + title: "Product analytics", + description: + "The Events, Funnels and Paths tabs over fixture events: populated, empty, loading, a new funnel's suggestions, a failed funnel, bars and drop-off views, and a paths anchor. Pick one with ?state=.", + kind: "lab", + session: "none", + }, { path: "/lab/errors", title: "Errors list", diff --git a/apps/web/src/page/analytics/analytics.scene.test.ts b/apps/web/src/page/analytics/analytics.scene.test.ts index 22166c1543..2efcf57f8d 100644 --- a/apps/web/src/page/analytics/analytics.scene.test.ts +++ b/apps/web/src/page/analytics/analytics.scene.test.ts @@ -6,7 +6,8 @@ import { describe, expect, test } from "vitest" import { toLoadFailure } from "@maple/views/data/load-failure" import { PageOutMessage, type PageContext } from "../contract" import { Message } from "./message" -import type { Breakdowns, Model, Summary } from "./model" +import type { Breakdowns } from "../kit/analytics-filter-rail" +import type { Model, Summary } from "./model" import { init, panelOf, routeChanged, update } from "./update" import { view as pageView } from "./view" diff --git a/apps/web/src/page/analytics/model.ts b/apps/web/src/page/analytics/model.ts index c4ee903f78..2df6b23243 100644 --- a/apps/web/src/page/analytics/model.ts +++ b/apps/web/src/page/analytics/model.ts @@ -10,30 +10,12 @@ import * as Dialog from "@maple/views/ui/dialog" import * as FilterSections from "@maple/views/ui/filters/filter-sections" import { SignalPresenceData } from "@maple/views/data/signal-presence" import * as TimeRangePicker from "../kit/time-range-picker" +import { Breakdowns } from "../kit/analytics-filter-rail" // `/analytics`. export type Search = typeof AnalyticsSearch.Type -const FacetRow = Schema.Struct({ name: Schema.String, count: Schema.Number }) - -/** `WebAnalyticsBreakdowns`. */ -export const Breakdowns = Schema.Struct({ - referrerHosts: Schema.Array(FacetRow), - countries: Schema.Array(FacetRow), - deviceTypes: Schema.Array(FacetRow), - browsers: Schema.Array(FacetRow), - operatingSystems: Schema.Array(FacetRow), - languages: Schema.Array(FacetRow), - utmSources: Schema.Array(FacetRow), - utmMediums: Schema.Array(FacetRow), - utmCampaigns: Schema.Array(FacetRow), - entryPaths: Schema.Array(FacetRow), - exitPaths: Schema.Array(FacetRow), - hosts: Schema.Array(FacetRow), -}) -export type Breakdowns = typeof Breakdowns.Type - export const AnalyticsEvent = Schema.Struct({ name: Schema.String, events: Schema.Number, sessions: Schema.Number }) export const Summary = Schema.Struct({ diff --git a/apps/web/src/page/analytics/update.ts b/apps/web/src/page/analytics/update.ts index e70267a1dc..2f98d76a4c 100644 --- a/apps/web/src/page/analytics/update.ts +++ b/apps/web/src/page/analytics/update.ts @@ -22,7 +22,6 @@ import { AiReferralPoint, AnalyticsEvent, AnalyticsPage, - Breakdowns, Live, type Model, PageviewsPoint, @@ -33,6 +32,7 @@ import { TraceSample, } from "./model" import { filtersOf, requestSlots, showsOverviewContent, tabOf } from "./requests" +import { Breakdowns } from "../kit/analytics-filter-rail" type Return = PageReturn type Cmd = Command.Command diff --git a/apps/web/src/page/analytics/view-breakdown.ts b/apps/web/src/page/analytics/view-breakdown.ts index 725e5f3d6a..77c7b49685 100644 --- a/apps/web/src/page/analytics/view-breakdown.ts +++ b/apps/web/src/page/analytics/view-breakdown.ts @@ -1,6 +1,7 @@ import { formatNumber, formatPercent } from "@maple/ui/lib/format" import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" +import { linkButton } from "@maple/views/ui/button" import { emptyMessage } from "@maple/views/ui/empty" import { icon } from "@maple/views/ui/icon" import { panel } from "@maple/views/ui/panel" @@ -37,7 +38,12 @@ export type BreakdownDimension = Readonly<{ countLabel?: string }> -export type BreakdownCard = Readonly<{ id: string; dimensions: ReadonlyArray }> +export type BreakdownCard = Readonly<{ + id: string + dimensions: ReadonlyArray + /** A page that goes further with this card's data, beside its filter. */ + link?: Readonly<{ label: string; href: string }> +}> type ShareRow = BreakdownRow & Readonly<{ share: number }> @@ -328,7 +334,23 @@ export const breakdownPanel = ( [h.Class("flex flex-wrap items-center justify-between gap-x-3 gap-y-2 px-3 pt-2.5 pb-2")], [ dimensionTabs(h, card.id, card.dimensions, view.activeTab), - panelFilterInput(h, card.id, state.query, dimension.nounPlural, "min-w-24 max-w-40 flex-1"), + hstack(h, { gap: "2", className: "min-w-24 flex-1 justify-end" }, [ + ...(card.link === undefined + ? [] + : [ + linkButton(h, { variant: "ghost", size: "xs", href: card.link.href }, [ + card.link.label, + icon(h, "ArrowRightIcon"), + ]), + ]), + panelFilterInput( + h, + card.id, + state.query, + dimension.nounPlural, + "min-w-24 max-w-40 flex-1", + ), + ]), ], ), ...(dimension.rows.length === 0 diff --git a/apps/web/src/page/analytics/view-filters.ts b/apps/web/src/page/analytics/view-filters.ts index 1e3aaf6def..6a280e06df 100644 --- a/apps/web/src/page/analytics/view-filters.ts +++ b/apps/web/src/page/analytics/view-filters.ts @@ -1,142 +1,17 @@ -import { WEB_ANALYTICS_UNSET } from "@maple/domain/query-engine" -import { activeFilterChips, FILTER_SECTION_LABEL, hasActiveFilters, trafficIncludes } from "../../components/analytics/filters" -import { countryLabel, languageLabel, referrerLabel, utmLabel } from "../../components/analytics/labels" +import { activeFilterChips } from "../../components/analytics/filters" +import type { Html, HtmlBuilder } from "foldkit/html" import { Option } from "effect" import { AsyncData } from "foldkit" -import type { Html, HtmlBuilder } from "foldkit/html" -import { type FilterOption, filterSection, singleCheckboxFilter } from "@maple/views/ui/filters/filter-section" -import * as FilterSections from "@maple/views/ui/filters/filter-sections" -import { - filterSidebarBody, - filterSidebarEmpty, - filterSidebarError, - filterSidebarFrame, - filterSidebarHeader, - filterSidebarLoading, - filterSidebarDivider, - filterSidebarGroupLabel, -} from "@maple/views/ui/filters/filter-sidebar" -import { icon, type IconName } from "@maple/views/ui/icon" -import { browserIconFor, deviceIconFor } from "../replays/kit/sessions-parts" -import { type FilterKey, Message } from "./message" -import type { Breakdowns, Model } from "./model" -import { filtersOf, tabOf } from "./requests" import { presenceOf } from "@maple/views/data/signal-presence" -import { favicon, isHostLike } from "./view-icons" +import { analyticsFilterRail } from "../kit/analytics-filter-rail" +import { Message } from "./message" +import type { Model } from "./model" +import { filtersOf, tabOf } from "./requests" -// `AnalyticsFilterSidebar`: the page's filter rail. +// The page's filter rail: the kit's, with the Traffic group and the Event section. type H = HtmlBuilder -const toSections = (message: FilterSections.Message) => Message.GotFilterSectionsMessage({ message }) - -const section = ( - h: H, - model: Model, - key: FilterKey, - options: ReadonlyArray, - extra: Readonly<{ - isSearchable?: boolean - getOptionLabel?: (name: string) => string - getOptionIcon?: (name: string) => IconName - renderOptionIcon?: (name: string) => Html | undefined - }> = {}, -) => { - const value = filtersOf(model.search)[key] - return filterSection(h, { - title: FILTER_SECTION_LABEL[key], - options, - selected: value === undefined ? [] : [value], - isSearchable: extra.isSearchable ?? true, - getOptionLabel: extra.getOptionLabel, - getOptionIcon: extra.getOptionIcon, - renderOptionIcon: extra.renderOptionIcon, - sections: model.filterSections, - toSectionsMessage: toSections, - onToggle: (name) => Message.ToggledFilter({ key, value: name }), - }) -} - -const checkboxGroup = (h: H, title: string, children: ReadonlyArray): Html => - h.div([h.Class("pb-3")], [filterSidebarGroupLabel(h, title), ...children]) - -const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { - const filters = filtersOf(model.search) - const canClear = hasActiveFilters(filters) - const isEmpty = Object.values(breakdowns).every((rows) => rows.length === 0) - if (!canClear && isEmpty) return filterSidebarEmpty(h) - const events = Option.getOrElse(AsyncData.getData(model.events.data), () => []) - const hostIcon = (name: string) => favicon(h, model, name, "size-3.5") - return filterSidebarFrame(h, { isWaiting: AsyncData.isRefreshing(model.breakdowns.data) }, [ - filterSidebarHeader(h, canClear ? { onClear: Message.ClickedClearFilters() } : {}), - ...filterSidebarBody( - h, - "analytics-filters", - [ - checkboxGroup(h, FILTER_SECTION_LABEL.traffic, [ - singleCheckboxFilter(h, { - title: "Humans", - isChecked: trafficIncludes(filters.traffic, "humans"), - onToggle: Message.ToggledTraffic({ which: "humans", isOn: !trafficIncludes(filters.traffic, "humans") }), - }), - singleCheckboxFilter(h, { - title: "Bots", - isChecked: trafficIncludes(filters.traffic, "bots"), - onToggle: Message.ToggledTraffic({ which: "bots", isOn: !trafficIncludes(filters.traffic, "bots") }), - }), - ]), - filterSidebarDivider(h), - section(h, model, "host", breakdowns.hosts, { renderOptionIcon: hostIcon }), - section(h, model, "pagePath", breakdowns.entryPaths), - events.length > 0 || filters.eventName !== undefined - ? section( - h, - model, - "eventName", - events.map((event) => ({ name: event.name, count: event.sessions })), - ) - : null, - filterSidebarDivider(h), - section(h, model, "referrerHost", breakdowns.referrerHosts, { - getOptionLabel: referrerLabel, - renderOptionIcon: (name) => - name === WEB_ANALYTICS_UNSET - ? icon(h, "ArrowThroughLineRightIcon", { className: "size-3.5 shrink-0 text-foreground" }) - : hostIcon(name), - }), - section(h, model, "utmSource", breakdowns.utmSources, { - getOptionLabel: utmLabel, - renderOptionIcon: (name) => (isHostLike(name) ? hostIcon(name) : undefined), - }), - section(h, model, "utmMedium", breakdowns.utmMediums, { isSearchable: false, getOptionLabel: utmLabel }), - section(h, model, "utmCampaign", breakdowns.utmCampaigns, { getOptionLabel: utmLabel }), - filterSidebarDivider(h), - checkboxGroup(h, FILTER_SECTION_LABEL.visitorType, [ - singleCheckboxFilter(h, { - title: "New", - isChecked: filters.visitorType === "new", - onToggle: Message.ChangedFilter({ key: "visitorType", value: filters.visitorType === "new" ? undefined : "new" }), - }), - singleCheckboxFilter(h, { - title: "Returning", - isChecked: filters.visitorType === "returning", - onToggle: Message.ChangedFilter({ - key: "visitorType", - value: filters.visitorType === "returning" ? undefined : "returning", - }), - }), - ]), - section(h, model, "country", breakdowns.countries, { getOptionLabel: countryLabel }), - section(h, model, "language", breakdowns.languages, { getOptionLabel: languageLabel }), - filterSidebarDivider(h), - section(h, model, "deviceType", breakdowns.deviceTypes, { isSearchable: false, getOptionIcon: deviceIconFor }), - section(h, model, "browserName", breakdowns.browsers, { isSearchable: false, getOptionIcon: browserIconFor }), - section(h, model, "osName", breakdowns.operatingSystems, { isSearchable: false }), - ].filter((node): node is Html => node !== null), - ), - ]) -} - /** The overview's breakdown cards render (not the AI tab, not the no-sessions setup state). */ export const showsBreakdownCards = (model: Model): boolean => tabOf(model.search) === "overview" && @@ -147,9 +22,21 @@ export const showsBreakdownCards = (model: Model): boolean => // The breakdown cards show this same read's failure in full; when they are not drawn, the rail explains it. export const filtersRail = (h: H, model: Model): Html => - AsyncData.matchData(model.breakdowns.data, { - onEmpty: () => filterSidebarLoading(h, 6), - onFailure: (failure) => - filterSidebarError(h, failure, undefined, showsBreakdownCards(model) ? failure : undefined), - onData: (breakdowns) => loadedRail(h, model, breakdowns), + analyticsFilterRail(h, { + id: "analytics-filters", + breakdowns: model.breakdowns.data, + filters: filtersOf(model.search), + events: Option.getOrElse(AsyncData.getData(model.events.data), () => []).map((event) => ({ + name: event.name, + count: event.sessions, + })), + showsFailureElsewhere: showsBreakdownCards(model), + sections: model.filterSections, + failedFavicons: model.failedFavicons, + onSections: (message) => Message.GotFilterSectionsMessage({ message }), + onToggle: (key, value) => Message.ToggledFilter({ key, value }), + onChange: (key, value) => Message.ChangedFilter({ key, value }), + traffic: { onToggle: (which, isOn) => Message.ToggledTraffic({ which, isOn }) }, + onClear: Message.ClickedClearFilters(), + onFaviconFailed: (host) => Message.FailedFavicon({ host }), }) diff --git a/apps/web/src/page/analytics/view-icons.ts b/apps/web/src/page/analytics/view-icons.ts index 9baec59c42..7988274f8d 100644 --- a/apps/web/src/page/analytics/view-icons.ts +++ b/apps/web/src/page/analytics/view-icons.ts @@ -3,6 +3,7 @@ import { cn } from "@maple/ui/lib/utils" import type { Html, HtmlBuilder } from "foldkit/html" import { icon } from "@maple/views/ui/icon" import { browserIconFor, deviceIconFor } from "../replays/kit/sessions-parts" +import { favicon as kitFavicon, isHostLike } from "../kit/analytics-filter-rail" import { Message, type FilterKey } from "./message" import type { Model } from "./model" @@ -12,25 +13,14 @@ type H = HtmlBuilder const ICON_BOX = "size-4 shrink-0" -const faviconUrl = (host: string): string => `https://favicon.maple.dev/?url=${encodeURIComponent(host)}` - -/** `isHostLike`: a dotted name a favicon could exist for. */ -export const isHostLike = (value: string): boolean => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(value) - /** `Favicon`: the site's icon, or its reserved box once the image failed. */ export const favicon = (h: H, model: Model, host: string, className?: string): Html => - model.failedFavicons.includes(host) - ? h.span([h.Class(cn(ICON_BOX, className)), h.AriaHidden(true)], []) - : h.img([ - h.Src(faviconUrl(host)), - h.Alt(""), - h.AriaHidden(true), - h.Loading("lazy"), - h.Decoding("async"), - h.Referrerpolicy("no-referrer"), - h.OnError(Message.FailedFavicon({ host })), - h.Class(cn(ICON_BOX, "rounded-xs object-contain", className)), - ]) + kitFavicon( + h, + { failed: model.failedFavicons, onFailed: (failed) => Message.FailedFavicon({ host: failed }) }, + host, + className, + ) const ICON_KEYS: ReadonlyArray = ["referrerHost", "host", "utmSource", "browserName", "deviceType"] diff --git a/apps/web/src/page/analytics/view-overview.ts b/apps/web/src/page/analytics/view-overview.ts index 708693afbb..a031c94a9c 100644 --- a/apps/web/src/page/analytics/view-overview.ts +++ b/apps/web/src/page/analytics/view-overview.ts @@ -327,6 +327,18 @@ export const overviewCards = (model: Model): ReadonlyArray `https://favicon.maple.dev/?url=${encodeURIComponent(host)}` + +/** `isHostLike`: a dotted name a favicon could exist for. */ +export const isHostLike = (value: string): boolean => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(value) + +/** `Favicon`: the site's icon, or its reserved box once the image failed. */ +export const favicon = ( + h: HtmlBuilder, + options: Readonly<{ failed: ReadonlyArray; onFailed: (host: string) => M }>, + host: string, + className?: string, +): Html => + options.failed.includes(host) + ? h.span([h.Class(cn(ICON_BOX, className)), h.AriaHidden(true)], []) + : h.img([ + h.Src(faviconUrl(host)), + h.Alt(""), + h.AriaHidden(true), + h.Loading("lazy"), + h.Decoding("async"), + h.Referrerpolicy("no-referrer"), + h.OnError(options.onFailed(host)), + h.Class(cn(ICON_BOX, "rounded-xs object-contain", className)), + ]) + +export type AnalyticsFilterRailOptions = Readonly<{ + /** The sidebar body's scroll slot, unique per page. */ + id: string + breakdowns: AsyncData.AsyncData + filters: AnalyticsFilters + /** The Event section's options; without them the section is left out. */ + events?: ReadonlyArray + /** The Humans/Bots group; product events have no bot split, so that page leaves it out. */ + traffic?: Readonly<{ onToggle: (which: "humans" | "bots", isOn: boolean) => M }> + /** The page already shows this read's failure in full; the rail only points at it. */ + showsFailureElsewhere: boolean + sections: FilterSections.Model + failedFavicons: ReadonlyArray + onSections: (message: FilterSections.Message) => M + onToggle: (key: AnalyticsFilterKey, value: string) => M + onChange: (key: AnalyticsFilterKey, value: string | undefined) => M + onClear: M + onFaviconFailed: (host: string) => M +}> + +const loadedRail = ( + h: HtmlBuilder, + options: AnalyticsFilterRailOptions, + breakdowns: Breakdowns, +): Html => { + const { filters } = options + const canClear = hasActiveFilters(filters) + const isEmpty = Object.values(breakdowns).every((rows) => rows.length === 0) + if (!canClear && isEmpty) return filterSidebarEmpty(h) + const icons = { failed: options.failedFavicons, onFailed: options.onFaviconFailed } + const hostIcon = (name: string) => favicon(h, icons, name, "size-3.5") + const section = ( + key: AnalyticsFilterKey, + items: ReadonlyArray, + extra: Readonly<{ + isSearchable?: boolean + getOptionLabel?: (name: string) => string + getOptionIcon?: (name: string) => IconName + renderOptionIcon?: (name: string) => Html | undefined + }> = {}, + ): Html => { + const value = filters[key] + return filterSection(h, { + title: FILTER_SECTION_LABEL[key], + options: items, + selected: value === undefined ? [] : [value], + isSearchable: extra.isSearchable ?? true, + getOptionLabel: extra.getOptionLabel, + getOptionIcon: extra.getOptionIcon, + renderOptionIcon: extra.renderOptionIcon, + sections: options.sections, + toSectionsMessage: options.onSections, + onToggle: (name) => options.onToggle(key, name), + }) + } + const checkboxGroup = (title: string, children: ReadonlyArray): Html => + h.div([h.Class("pb-3")], [filterSidebarGroupLabel(h, title), ...children]) + const visitorType = (value: "new" | "returning", title: string): Html => + singleCheckboxFilter(h, { + title, + isChecked: filters.visitorType === value, + onToggle: options.onChange("visitorType", filters.visitorType === value ? undefined : value), + }) + const trafficToggle = ( + onToggle: (which: "humans" | "bots", isOn: boolean) => M, + which: "humans" | "bots", + title: string, + ): Html => + singleCheckboxFilter(h, { + title, + isChecked: trafficIncludes(filters.traffic, which), + onToggle: onToggle(which, !trafficIncludes(filters.traffic, which)), + }) + const { events, traffic } = options + return filterSidebarFrame(h, { isWaiting: AsyncData.isRefreshing(options.breakdowns) }, [ + filterSidebarHeader(h, canClear ? { onClear: options.onClear } : {}), + ...filterSidebarBody(h, options.id, [ + ...(traffic !== undefined + ? [ + checkboxGroup(FILTER_SECTION_LABEL.traffic, [ + trafficToggle(traffic.onToggle, "humans", "Humans"), + trafficToggle(traffic.onToggle, "bots", "Bots"), + ]), + filterSidebarDivider(h), + ] + : []), + section("host", breakdowns.hosts, { renderOptionIcon: hostIcon }), + section("pagePath", breakdowns.entryPaths), + ...(events !== undefined && (events.length > 0 || filters.eventName !== undefined) + ? [section("eventName", events)] + : []), + filterSidebarDivider(h), + section("referrerHost", breakdowns.referrerHosts, { + getOptionLabel: referrerLabel, + renderOptionIcon: (name) => + name === WEB_ANALYTICS_UNSET + ? icon(h, "ArrowThroughLineRightIcon", { + className: "size-3.5 shrink-0 text-foreground", + }) + : hostIcon(name), + }), + section("utmSource", breakdowns.utmSources, { + getOptionLabel: utmLabel, + renderOptionIcon: (name) => (isHostLike(name) ? hostIcon(name) : undefined), + }), + section("utmMedium", breakdowns.utmMediums, { isSearchable: false, getOptionLabel: utmLabel }), + section("utmCampaign", breakdowns.utmCampaigns, { getOptionLabel: utmLabel }), + filterSidebarDivider(h), + checkboxGroup(FILTER_SECTION_LABEL.visitorType, [ + visitorType("new", "New"), + visitorType("returning", "Returning"), + ]), + section("country", breakdowns.countries, { getOptionLabel: countryLabel }), + section("language", breakdowns.languages, { getOptionLabel: languageLabel }), + filterSidebarDivider(h), + section("deviceType", breakdowns.deviceTypes, { + isSearchable: false, + getOptionIcon: deviceIconFor, + }), + section("browserName", breakdowns.browsers, { + isSearchable: false, + getOptionIcon: browserIconFor, + }), + section("osName", breakdowns.operatingSystems, { isSearchable: false }), + ]), + ]) +} + +/** `AnalyticsFilterSidebar`: loading rows, the read's failure, or a section per session dimension. */ +export const analyticsFilterRail = (h: HtmlBuilder, options: AnalyticsFilterRailOptions): Html => + AsyncData.matchData(options.breakdowns, { + onEmpty: () => filterSidebarLoading(h, 6), + onFailure: (failure) => + filterSidebarError(h, failure, undefined, options.showsFailureElsewhere ? failure : undefined), + onData: (breakdowns) => loadedRail(h, options, breakdowns), + }) diff --git a/apps/web/src/page/product-analytics/command.ts b/apps/web/src/page/product-analytics/command.ts new file mode 100644 index 0000000000..a04ab9fbd7 --- /dev/null +++ b/apps/web/src/page/product-analytics/command.ts @@ -0,0 +1,88 @@ +import { ProductEventsFunnelWidgetParams, ProductEventsPathsWidgetParams } from "@maple/query-model" +import { + getProductEventNames, + getProductEventsFunnelWidget, + getProductEventsPathsWidget, +} from "@maple/web-data/warehouse/product-events" +import { getWebAnalyticsBreakdowns, WebAnalyticsFilterFields } from "@maple/web-data/warehouse/web-analytics" +import { Clock, Duration, Effect, Schema } from "effect" +import { Command } from "foldkit" +import { toLoadFailure } from "@maple/views/data/load-failure" +import { resolveWindow } from "../../data/time-range" +import { Message, RequestMode, SlotName } from "./message" + +// The page's reads. Every fetch answers `CompletedFetch` with its slot and key; the request itself +// is the web-data function, with the input it was keyed on. + +export const ResolveWindow = Command.define("ResolveProductAnalyticsWindow", { + args: { + mode: RequestMode, + startTime: Schema.optional(Schema.String), + endTime: Schema.optional(Schema.String), + preset: Schema.String, + timeZone: Schema.String, + }, + messages: [Message.CompletedResolveWindow], + execute: ({ mode, startTime, endTime, preset, timeZone }) => + Effect.map(Clock.currentTimeMillis, (nowMs) => + Message.CompletedResolveWindow({ + window: resolveWindow({ startTime, endTime, preset, snap: mode === "Load", timeZone }, nowMs), + mode, + }), + ), +}) + +const Window = { startTime: Schema.String, endTime: Schema.String } + +const define = ( + name: string, + fields: Fields, + run: (input: Schema.Struct["Type"]) => Effect.Effect, +) => + Command.define(name, { + args: { slot: SlotName, key: Schema.String, input: Schema.Struct(fields) }, + messages: [Message.CompletedFetch], + execute: ({ slot, key, input }) => + run(input).pipe( + Effect.mapError(toLoadFailure), + Effect.result, + Effect.map((result) => Message.CompletedFetch({ slot, key, result })), + ), + }) + +export const FetchBreakdowns = define( + "FetchProductAnalyticsBreakdowns", + { ...Window, ...WebAnalyticsFilterFields, limitPerDimension: Schema.optional(Schema.Number) }, + (data) => getWebAnalyticsBreakdowns({ data }), +) +export const FetchEventNames = define( + "FetchProductEventNames", + { ...Window, ...WebAnalyticsFilterFields, limit: Schema.optional(Schema.Number) }, + (data) => getProductEventNames({ data }).pipe(Effect.map((response) => response.data)), +) +export const FetchFunnel = define( + "FetchProductAnalyticsFunnel", + { ...Window, ...ProductEventsFunnelWidgetParams.fields }, + (data) => getProductEventsFunnelWidget({ data }).pipe(Effect.map((response) => response.data)), +) +export const FetchPaths = define( + "FetchProductAnalyticsPaths", + { ...Window, ...ProductEventsPathsWidgetParams.fields }, + (data) => getProductEventsPathsWidget({ data }).pipe(Effect.map((response) => response.data)), +) + +/** Typing in a step holds the URL back this long, so a run waits for the name. */ +const TYPING_PAUSE = Duration.millis(450) + +export const WaitForTypingPause = Command.define("WaitForProductAnalyticsTypingPause", { + args: { version: Schema.Number }, + messages: [Message.PausedTyping], + execute: ({ version }) => Effect.as(Effect.sleep(TYPING_PAUSE), Message.PausedTyping({ version })), +}) + +export const CompleteReloadSpin = Command.define("CompleteProductAnalyticsReloadSpin", { + args: { version: Schema.Number }, + messages: [Message.CompletedReloadSpin], + execute: ({ version }) => + Effect.as(Effect.sleep(Duration.millis(600)), Message.CompletedReloadSpin({ version })), +}) diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts new file mode 100644 index 0000000000..7b4694b324 --- /dev/null +++ b/apps/web/src/page/product-analytics/definition.ts @@ -0,0 +1,119 @@ +import { + DEFAULT_PATHS_BRANCHES, + DEFAULT_PATHS_DEPTH, + FUNNEL_POPULATION_FILTER_FIELDS, + type FunnelKeyBy, + type FunnelPopulationFilters, + type FunnelStep, + type FunnelVariant, + PATHS_MAX_BRANCHES, + PATHS_MAX_DEPTH, + type PathsAnchor, + type PathsDirection, + type ProductEventsFunnelWidgetParams, + type ProductEventsPathsWidgetParams, +} from "@maple/query-model" +import { + completedSteps, + DEFAULT_FUNNEL_KEY_BY, + DEFAULT_FUNNEL_WINDOW_SECONDS, +} from "../../components/funnels/definition" +import type { StepDraft } from "../kit/funnel-steps" +import type { ProductAnalyticsSearch } from "../../route/search" + +// What the URL says: the tab, the population filters, and the funnel and paths definitions. + +export type Search = typeof ProductAnalyticsSearch.Type + +export const TABS = ["events", "funnels", "paths"] as const +export type Tab = (typeof TABS)[number] + +export const tabOf = (search: Search): Tab => TABS.find((tab) => tab === search.tab) ?? "events" + +/** The session filters every read on the page takes, unset ones left out. */ +export const populationOf = (search: Search): FunnelPopulationFilters => + Object.fromEntries( + FUNNEL_POPULATION_FILTER_FIELDS.flatMap((field) => + search[field] === undefined ? [] : [[field, search[field]]], + ), + ) + +export type FunnelDefinition = Readonly<{ + steps: ReadonlyArray + keyBy: FunnelKeyBy + windowSeconds: number + variant: FunnelVariant +}> + +export const funnelOf = (search: Search): FunnelDefinition => ({ + steps: search.steps ?? [], + keyBy: search.keyBy ?? DEFAULT_FUNNEL_KEY_BY, + windowSeconds: + search.within !== undefined && Number.isFinite(search.within) && search.within > 0 + ? search.within + : DEFAULT_FUNNEL_WINDOW_SECONDS, + variant: search.view ?? "dropoff", +}) + +/** The funnel's widget params, or `null` until a step has something to match on. */ +export const funnelParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { + const funnel = funnelOf(search) + const steps = completedSteps(funnel.steps) + if (steps.length === 0) return null + return { + ...populationOf(search), + steps, + keyBy: funnel.keyBy, + windowSeconds: funnel.windowSeconds, + details: funnel.variant === "dropoff", + } +} + +const clamp = (value: number | undefined, fallback: number, max: number): number => + value === undefined || !Number.isFinite(value) ? fallback : Math.min(max, Math.max(1, Math.round(value))) + +export type PathsDefinition = Readonly<{ + anchor: PathsAnchor + direction: PathsDirection + depth: number + branches: number +}> + +export const pathsOf = (search: Search): PathsDefinition => ({ + anchor: search.anchor ?? { kind: "event", eventName: "" }, + direction: search.direction ?? "after", + depth: clamp(search.depth, DEFAULT_PATHS_DEPTH, PATHS_MAX_DEPTH), + branches: clamp(search.branches, DEFAULT_PATHS_BRANCHES, PATHS_MAX_BRANCHES), +}) + +/** The event or page an anchor names, trimmed; `""` while it names nothing. */ +export const anchorNameOf = (anchor: PathsAnchor): string => + (anchor.kind === "event" ? anchor.eventName : anchor.pagePath).trim() + +/** The paths widget params, or `null` until the anchor names an event or a page. */ +export const pathsParamsOf = (search: Search): ProductEventsPathsWidgetParams | null => { + const paths = pathsOf(search) + if (anchorNameOf(paths.anchor) === "") return null + return { + ...populationOf(search), + anchor: paths.anchor, + direction: paths.direction, + depth: paths.depth, + branches: paths.branches, + } +} + +/** A draft step as the URL keeps it: no typed attribute filter (this page offers none). */ +export const toFunnelStep = (draft: StepDraft): FunnelStep => { + if (draft.kind !== "event") return draft + const { filterClause: _filterClause, ...step } = draft + return step +} + +/** The paths anchor of a draft; a session step has no place in a sequence. */ +export const toAnchor = (draft: StepDraft): PathsAnchor => { + if (draft.kind === "page") return draft + if (draft.kind === "session") return { kind: "event", eventName: "" } + const { filterClause: _filterClause, ...step } = draft + return step +} diff --git a/apps/web/src/page/product-analytics/index.ts b/apps/web/src/page/product-analytics/index.ts new file mode 100644 index 0000000000..c9bf81cc53 --- /dev/null +++ b/apps/web/src/page/product-analytics/index.ts @@ -0,0 +1,20 @@ +import { definePage } from "../contract" +import { Message } from "./message" +import { Model } from "./model" +import { contextChanged, init, routeChanged, update } from "./update" +import { view } from "./view" + +/** `/product-analytics`: product events, funnels and paths. One instance per org. */ +export const page = definePage( + "ProductAnalytics", + { Model, Message }, + { + routes: ["ProductAnalytics"], + init, + update, + routeChanged, + contextChanged, + view, + breadcrumbs: () => [{ label: "Product analytics", isPageHeading: true }], + }, +) diff --git a/apps/web/src/page/product-analytics/message.ts b/apps/web/src/page/product-analytics/message.ts new file mode 100644 index 0000000000..508f4b3456 --- /dev/null +++ b/apps/web/src/page/product-analytics/message.ts @@ -0,0 +1,73 @@ +import { + FUNNEL_POPULATION_FILTER_FIELDS, + FunnelKeyBy, + FunnelStep, + FunnelVariant, + PathsAnchor, + PathsDirection, +} from "@maple/query-model" +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import * as Chart from "@maple/views/charts/chart" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" +import * as Select from "@maple/views/ui/select" +import { TimeWindow } from "../../data/time-range" +import { StepEdit, StepScope } from "../kit/funnel-steps" +import * as TimeRangePicker from "../kit/time-range-picker" +import { EventKindFilter } from "./model" + +export const RequestMode = Schema.Literals(["Load", "Refresh"]) +export type RequestMode = typeof RequestMode.Type + +/** Every Keyed slot of the page, by its Model field. */ +export const SlotName = Schema.Literals(["breakdowns", "events", "previousEvents", "funnel", "paths"]) +export type SlotName = typeof SlotName.Type + +/** A population filter: a `session_replays` dimension, one value each. */ +export const FilterKey = Schema.Literals(FUNNEL_POPULATION_FILTER_FIELDS) +export type FilterKey = typeof FilterKey.Type + +export const Message = defineMessageUnion({ + CompletedResolveWindow: { window: TimeWindow, mode: RequestMode }, + /** Any slot's answer; `key` is what it was requested under, so a stale one is dropped. */ + CompletedFetch: { + slot: SlotName, + key: Schema.String, + result: Schema.Result(Schema.Unknown, LoadFailure), + }, + ClickedReload: {}, + CompletedReloadSpin: { version: Schema.Number }, + ClickedResetTimeRange: {}, + ClickedRetry: { slot: SlotName }, + /** A rail option: `key` is any analytics filter; the page keeps its own (`FilterKey`). */ + ToggledFilter: { key: Schema.String, value: Schema.String }, + ChangedFilter: { key: Schema.String, value: Schema.UndefinedOr(Schema.String) }, + ClickedClearFilters: {}, + FailedFavicon: { host: Schema.String }, + ChangedEventsQuery: { value: Schema.String }, + PickedEventsKind: { kind: EventKindFilter }, + /** The empty list's Clear: the name filter and the kind both go. */ + ClickedClearEventsFilters: {}, + /** An Events row's Funnel: a new funnel starting at that event. */ + ClickedStartFunnel: { step: FunnelStep }, + /** An Events row's Paths, or a paths table's "Paths from here". */ + ClickedStartPaths: { anchor: PathsAnchor }, + ClickedSuggestedFunnel: { steps: Schema.Array(FunnelStep) }, + ChangedStep: { scope: StepScope, edit: StepEdit }, + GotStepComboMessage: { id: Schema.String, message: Combobox.Message }, + GotSelectMessage: { id: Schema.String, message: Select.Message }, + /** A typed step value held still long enough to run. */ + PausedTyping: { version: Schema.Number }, + PickedKeyBy: { keyBy: FunnelKeyBy }, + PickedWindow: { seconds: Schema.Number }, + PickedVariant: { variant: FunnelVariant }, + PickedDirection: { direction: PathsDirection }, + PickedDepth: { depth: Schema.Number }, + PickedBranches: { branches: Schema.Number }, + GotFilterSectionsMessage: { message: FilterSections.Message }, + GotTimePickerMessage: { message: TimeRangePicker.Message }, + GotChartMessage: { message: Chart.Message }, +}) +export type Message = typeof Message.Type diff --git a/apps/web/src/page/product-analytics/model.ts b/apps/web/src/page/product-analytics/model.ts new file mode 100644 index 0000000000..cd69ebd55a --- /dev/null +++ b/apps/web/src/page/product-analytics/model.ts @@ -0,0 +1,78 @@ +import { Schema } from "effect" +import * as Chart from "@maple/views/charts/chart" +import { KeyedSchema } from "@maple/views/data/keyed" +import { LoadFailure } from "@maple/views/data/load-failure" +import * as Combobox from "@maple/views/ui/combobox" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" +import * as Select from "@maple/views/ui/select" +import { TimeWindow } from "../../data/time-range" +import { ProductAnalyticsSearch } from "../../route/search" +import { Breakdowns } from "../kit/analytics-filter-rail" +import { StepDraft } from "../kit/funnel-steps" +import * as TimeRangePicker from "../kit/time-range-picker" + +// `/product-analytics`: product events by name, funnels over them, and the paths around one. + +/** `ProductEventName`: one event name in the window, how often and by how many. */ +export const EventRow = Schema.Struct({ + eventName: Schema.String, + /** `navigation` for page views, `custom` for `track()` calls, `screen` for mobile screens. */ + kind: Schema.String, + count: Schema.Number, + sessions: Schema.Number, + persons: Schema.Number, +}) +export type EventRow = typeof EventRow.Type + +/** `track()` calls and server events; page views (`navigation`) and mobile screens are the others. */ +export const isCustomEvent = (row: EventRow): boolean => row.kind !== "navigation" && row.kind !== "screen" + +/** `FunnelWidgetRow`: one step's bar, with the drop-off view's timing and leavers. */ +export const FunnelRow = Schema.Struct({ + name: Schema.String, + value: Schema.Number, + p50Ms: Schema.optionalKey(Schema.Number), + p90Ms: Schema.optionalKey(Schema.Number), + leavers: Schema.optionalKey(Schema.Array(Schema.Struct({ name: Schema.String, count: Schema.Number }))), +}) +export type FunnelRow = typeof FunnelRow.Type + +/** `PathsWidgetRow`: one hop between two nodes. */ +export const PathsRow = Schema.Struct({ + hop: Schema.Number, + fromNode: Schema.String, + toNode: Schema.String, + count: Schema.Number, +}) +export type PathsRow = typeof PathsRow.Type + +export const EventKindFilter = Schema.Literals(["all", "custom", "navigation", "screen"]) +export type EventKindFilter = typeof EventKindFilter.Type + +export const Model = Schema.Struct({ + search: ProductAnalyticsSearch, + window: Schema.NullOr(TimeWindow), + breakdowns: KeyedSchema(Breakdowns, LoadFailure), + events: KeyedSchema(Schema.Array(EventRow), LoadFailure), + previousEvents: KeyedSchema(Schema.Array(EventRow), LoadFailure), + funnel: KeyedSchema(Schema.Array(FunnelRow), LoadFailure), + paths: KeyedSchema(Schema.Array(PathsRow), LoadFailure), + /** The funnel steps as edited; the URL catches up on a pick, or once typing pauses. */ + steps: Schema.Array(StepDraft), + anchor: StepDraft, + /** The steps and anchor this page last wrote to the URL, so its own navigation keeps the draft. */ + committed: Schema.String, + /** Bumped by each typed edit; only the last one's pause commits. */ + typingVersion: Schema.Number, + stepCombos: Schema.Record(Schema.String, Combobox.Model), + selects: Schema.Record(Schema.String, Select.Model), + eventsQuery: Schema.String, + eventsKind: EventKindFilter, + failedFavicons: Schema.Array(Schema.String), + filterSections: FilterSections.Model, + timePicker: TimeRangePicker.Model, + chart: Chart.Model, + refreshVersion: Schema.Number, + isReloading: Schema.Boolean, +}) +export type Model = typeof Model.Type diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts new file mode 100644 index 0000000000..65cd6b7124 --- /dev/null +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -0,0 +1,215 @@ +import { OrgId } from "@maple/domain" +import { Option, Result, Schema } from "effect" +import { fromString } from "foldkit/url" +import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" +import * as Chart from "@maple/views/charts/chart" +import { MeasureChartContainer } from "@maple/views/charts/chart-mounts" +import { describe, expect, test } from "vitest" +import { StepEdit } from "../kit/funnel-steps" +import { urlToAppRoute } from "../../route" +import type { PageContext } from "../contract" +import type { Search } from "./definition" +import { Message, type SlotName } from "./message" +import type { EventRow, FunnelRow, Model } from "./model" +import { init, routeChanged, update } from "./update" +import { view as pageView } from "./view" + +const orgId = Schema.decodeSync(OrgId)("org_acme") +const context: PageContext = { orgId, pinnedNamespace: null, timeZone: "UTC" } +const window = { startTime: "2026-10-27 00:00:00", endTime: "2026-11-03 00:00:00" } + +const pageUpdate = (model: Model, message: Message) => update(model, message, context) +const view = withViewInputs(pageView, { context })() +const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => + (commands ?? []).map(({ name }) => name) + +const resolved = (search: Search = {}) => + pageUpdate(init({ search }, context).model, Message.CompletedResolveWindow({ window, mode: "Load" })) + +const settled = (model: Model, slot: SlotName, value: unknown): Model => + pageUpdate( + model, + Message.CompletedFetch({ slot, key: model[slot].key ?? "", result: Result.succeed(value) }), + ).model + +/** Where the page's navigation goes, decoded by the app's router. */ +const searchOf = (outMessage: unknown): Partial => { + const url = + typeof outMessage === "object" && + outMessage !== null && + "url" in outMessage && + typeof outMessage.url === "string" + ? outMessage.url + : "" + const route = urlToAppRoute(Option.getOrThrow(fromString(`https://app.maple.test${url}`))) + return route._tag === "ProductAnalytics" ? route.search : {} +} + +const typing = (current: Model, value: string) => + pageUpdate( + current, + Message.ChangedStep({ + scope: "funnel", + edit: StepEdit.ChangedText({ index: 0, field: "eventName", value }), + }), + ) + +const EVENTS: ReadonlyArray = [ + { eventName: "$pageview", kind: "navigation", count: 38_412, sessions: 9_204, persons: 4_812 }, + { eventName: "signup_started", kind: "custom", count: 1_488, sessions: 1_260, persons: 1_206 }, + { eventName: "signup_completed", kind: "custom", count: 742, sessions: 742, persons: 742 }, +] + +const FUNNEL: ReadonlyArray = [ + { name: "signup_started", value: 1_206 }, + { name: "signup_completed", value: 742, p50Ms: 41_000, leavers: [{ name: "docs_opened", count: 120 }] }, +] + +const SIGNUP: Search["steps"] = [ + { kind: "event", eventName: "signup_started" }, + { kind: "event", eventName: "signup_completed" }, +] + +describe("product analytics", () => { + test("the Events tab reads the rail's options and this window's and the previous window's events", () => { + const sent = names(resolved().commands) + expect(sent).toContain("FetchProductAnalyticsBreakdowns") + expect(sent.filter((name) => name === "FetchProductEventNames")).toHaveLength(2) + expect(sent).not.toContain("FetchProductAnalyticsFunnel") + }) + + test("a funnel runs only once a step names something, with the drop-off details by default", () => { + expect(names(resolved({ tab: "funnels" }).commands)).not.toContain("FetchProductAnalyticsFunnel") + const run = resolved({ tab: "funnels", steps: SIGNUP, country: "DE" }).commands?.find( + ({ name }) => name === "FetchProductAnalyticsFunnel", + ) + expect(run?.args).toMatchObject({ + input: { steps: SIGNUP, keyBy: "person", windowSeconds: 86_400, details: true, country: "DE" }, + }) + }) + + test("paths wait for an anchor", () => { + expect(names(resolved({ tab: "paths" }).commands)).not.toContain("FetchProductAnalyticsPaths") + const run = resolved({ + tab: "paths", + anchor: { kind: "event", eventName: "signup_completed" }, + }).commands?.find(({ name }) => name === "FetchProductAnalyticsPaths") + expect(run?.args).toMatchObject({ input: { direction: "after", depth: 3, branches: 4 } }) + }) + + test("an Events row's Funnel opens the Funnels tab with that event as step 1", () => { + const model = settled(resolved().model, "events", EVENTS) + const search = searchOf( + pageUpdate( + model, + Message.ClickedStartFunnel({ step: { kind: "event", eventName: "signup_started" } }), + ).outMessage, + ) + expect(search).toMatchObject({ + tab: "funnels", + steps: [{ kind: "event", eventName: "signup_started" }], + }) + }) + + test("a typed step name reaches the URL after a pause, and only the last pause counts", () => { + const model = resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "" }] }).model + const first = typing(model, "sign") + expect(first.outMessage).toBeUndefined() + expect(names(first.commands)).toContain("WaitForProductAnalyticsTypingPause") + const second = typing(first.model, "signup") + expect( + pageUpdate(second.model, Message.PausedTyping({ version: first.model.typingVersion })).outMessage, + ).toBeUndefined() + const committed = pageUpdate( + second.model, + Message.PausedTyping({ version: second.model.typingVersion }), + ) + expect(searchOf(committed.outMessage).steps).toEqual([{ kind: "event", eventName: "signup" }]) + }) + + test("a filter change mid-typing carries the typed step, and its pause no longer writes", () => { + const typed = typing( + resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "" }] }).model, + "signup", + ) + const toEvents = pageUpdate(typed.model, Message.ChangedFilter({ key: "country", value: "DE" })) + expect(searchOf(toEvents.outMessage).steps).toEqual([{ kind: "event", eventName: "signup" }]) + expect( + pageUpdate(toEvents.model, Message.PausedTyping({ version: typed.model.typingVersion })) + .outMessage, + ).toBeUndefined() + }) + + test("a row's Funnel replaces a half-typed funnel, even when the URL already starts there", () => { + const start = resolved({ + tab: "funnels", + steps: [{ kind: "event", eventName: "signup_started" }], + }).model + const added = pageUpdate( + start, + Message.ChangedStep({ scope: "funnel", edit: StepEdit.Added() }), + ).model + const clicked = pageUpdate( + added, + Message.ClickedStartFunnel({ step: { kind: "event", eventName: "signup_started" } }), + ) + expect(clicked.model.steps).toEqual([{ kind: "event", eventName: "signup_started" }]) + }) + + test("the page's own URL write keeps the draft; Back to another definition replaces it", () => { + const committed = pageUpdate( + resolved({ tab: "funnels" }).model, + Message.ChangedStep({ scope: "funnel", edit: StepEdit.Added() }), + ) + const written: Search = { tab: "funnels", steps: [{ kind: "event", eventName: "" }] } + // Typing continued before the route arrived: the draft is ahead of the URL and stays. + const ahead = { ...committed.model, steps: [{ kind: "event" as const, eventName: "sig" }] } + expect(routeChanged(ahead, { search: written }, context).model.steps).toEqual(ahead.steps) + expect( + routeChanged(ahead, { search: { tab: "funnels", steps: SIGNUP } }, context).model.steps, + ).toEqual(SIGNUP) + }) + + test("the rail's traffic and event keys do not apply here", () => { + const model = resolved().model + expect( + pageUpdate(model, Message.ToggledFilter({ key: "traffic", value: "bots" })).outMessage, + ).toBeUndefined() + expect( + searchOf(pageUpdate(model, Message.ToggledFilter({ key: "country", value: "DE" })).outMessage) + .country, + ).toBe("DE") + }) + + test("the Events tab lists every name with a way into a funnel", () => { + scene( + { update: pageUpdate, view }, + given(settled(resolved().model, "events", EVENTS)), + expectView(text("signup_completed")).toExist(), + expectView(text("Page view")).toExist(), + expectView(text("Top custom event")).toExist(), + ) + }) + + test("an org without events is told where they come from", () => { + scene( + { update: pageUpdate, view }, + given(settled(resolved().model, "events", [])), + expectView(text("No product events in this window")).toExist(), + ) + }) + + test("a funnel's answer draws its conversion, biggest drop and what leavers did", () => { + const model = settled(resolved({ tab: "funnels", steps: SIGNUP }).model, "funnel", FUNNEL) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve( + MeasureChartContainer, + Chart.Message.ResizedChartContainer({ width: 800, height: 320 }), + ), + expectView(text("Biggest drop")).toExist(), + expectView(text("docs_opened")).toExist(), + ) + }) +}) diff --git a/apps/web/src/page/product-analytics/requests.ts b/apps/web/src/page/product-analytics/requests.ts new file mode 100644 index 0000000000..d14524074c --- /dev/null +++ b/apps/web/src/page/product-analytics/requests.ts @@ -0,0 +1,111 @@ +import { Option } from "effect" +import { Command } from "foldkit" +import * as Keyed from "@maple/views/data/keyed" +import { requestKey } from "@maple/views/data/request-key" +import { previousWindow } from "../../components/analytics/previous-window" +import type { TimeWindow } from "../../data/time-range" +import type { Resources } from "../../platform/resources" +import type { PageContext } from "../contract" +import { FetchBreakdowns, FetchEventNames, FetchFunnel, FetchPaths } from "./command" +import { funnelParamsOf, pathsParamsOf, populationOf, tabOf } from "./definition" +import type { Message, SlotName } from "./message" +import type { Model } from "./model" + +// The reads the page makes, each keyed by its input: the rail's options always, then the tab's own. + +type Cmd = Command.Command + +const BREAKDOWN_LIMIT = 50 +export const EVENTS_LIMIT = 200 + +type Request = Readonly<{ slot: SlotName; fn: string; input: object; command: (key: string) => Cmd }> + +const plan = (model: Model, window: TimeWindow): ReadonlyArray => { + const population = populationOf(model.search) + const { startTime, endTime } = window + const breakdownsInput = { startTime, endTime, limitPerDimension: BREAKDOWN_LIMIT, ...population } + const events = (slot: SlotName, range: TimeWindow): Request => { + const input = { ...range, limit: EVENTS_LIMIT, ...population } + return { + slot, + fn: "getProductEventNames", + input, + command: (key) => FetchEventNames({ slot, key, input }), + } + } + const rail: Request = { + slot: "breakdowns", + fn: "getWebAnalyticsBreakdowns", + input: breakdownsInput, + command: (key) => FetchBreakdowns({ slot: "breakdowns", key, input: breakdownsInput }), + } + switch (tabOf(model.search)) { + case "events": + return [ + rail, + events("events", window), + events("previousEvents", previousWindow(startTime, endTime)), + ] + case "funnels": { + const params = funnelParamsOf(model.search) + const input = params === null ? null : { startTime, endTime, ...params } + return [ + rail, + events("events", window), + ...(input === null + ? [] + : [ + { + slot: "funnel" as const, + fn: "getProductEventsFunnelWidget", + input, + command: (key: string) => FetchFunnel({ slot: "funnel", key, input }), + }, + ]), + ] + } + case "paths": { + const params = pathsParamsOf(model.search) + const input = params === null ? null : { startTime, endTime, ...params } + return [ + rail, + events("events", window), + ...(input === null + ? [] + : [ + { + slot: "paths" as const, + fn: "getProductEventsPathsWidget", + input, + command: (key: string) => FetchPaths({ slot: "paths", key, input }), + }, + ]), + ] + } + } +} + +/** Requests every slot the tab reads; a slot whose key held (and that answered) stays put. */ +export const requestSlots = ( + model: Model, + context: PageContext, + window: TimeWindow, + mode: Keyed.RequestMode, + only?: ReadonlyArray, +): { readonly model: Model; readonly commands: ReadonlyArray } => + plan(model, window) + .filter((request) => only === undefined || only.includes(request.slot)) + .reduce<{ model: Model; commands: ReadonlyArray }>( + (state, request) => { + const key = requestKey(context.orgId, request.fn, { data: request.input }) + const current: Keyed.Keyed = state.model[request.slot] + return Option.match(Keyed.request(current, key, mode), { + onNone: () => state, + onSome: (slot) => ({ + model: { ...state.model, [request.slot]: slot }, + commands: [...state.commands, request.command(key)], + }), + }) + }, + { model, commands: [] }, + ) diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts new file mode 100644 index 0000000000..14ee5a9aa1 --- /dev/null +++ b/apps/web/src/page/product-analytics/update.ts @@ -0,0 +1,314 @@ +import { FUNNEL_MAX_STEPS, FUNNEL_POPULATION_FILTER_FIELDS } from "@maple/query-model" +import { Option, Result, Schema, Struct } from "effect" +import { Command } from "foldkit" +import * as Chart from "@maple/views/charts/chart" +import * as Keyed from "@maple/views/data/keyed" +import { type LoadFailure, toLoadFailure } from "@maple/views/data/load-failure" +import * as FilterSections from "@maple/views/ui/filters/filter-sections" +import * as Select from "@maple/views/ui/select" +import { toggleFilterValue } from "../../components/analytics/filters" +import type { Resources } from "../../platform/resources" +import { AppRoute, hrefOf } from "../../route" +import { type PageContext, PageOutMessage, type PageReturn } from "../contract" +import { Breakdowns } from "../kit/analytics-filter-rail" +import { + applyAnchorEdit, + applyStepEdit, + type StepEdit, + type StepScope, + stepComboUpdate, + stepEditOf, +} from "../kit/funnel-steps" +import { routeWindowStep, snapModeOf } from "../kit/route-window" +import type { PresetValue } from "../kit/time-controls" +import * as TimeRangePicker from "../kit/time-range-picker" +import { CompleteReloadSpin, ResolveWindow, WaitForTypingPause } from "./command" +import { anchorNameOf, pathsOf, type Search, toAnchor, toFunnelStep } from "./definition" +import { FilterKey, Message, type RequestMode, SlotName } from "./message" +import { EventRow, FunnelRow, type Model, PathsRow } from "./model" +import { requestSlots } from "./requests" + +type Return = PageReturn +type Cmd = Command.Command + +export const DEFAULT_PRESET: PresetValue = "1w" + +const SLOT_SCHEMAS = { + breakdowns: Breakdowns, + events: Schema.Array(EventRow), + previousEvents: Schema.Array(EventRow), + funnel: Schema.Array(FunnelRow), + paths: Schema.Array(PathsRow), +} satisfies Record + +const decodeFailure: LoadFailure = toLoadFailure("Couldn't read the response") + +/** Folds an answer into every slot that asked under its key, decoded with that slot's schema. */ +const settleSlots = (model: Model, key: string, result: Result.Result): Model => + SlotName.literals.reduce((next, name) => { + if (next[name].key !== key) return next + const decoded = Result.match(result, { + onFailure: (failure) => Result.fail(failure), + onSuccess: (value) => + Option.match(Schema.decodeUnknownOption(SLOT_SCHEMAS[name])(value), { + onNone: () => Result.fail(decodeFailure), + onSome: (data) => Result.succeed(data), + }), + }) + const slot: Keyed.Keyed = next[name] + return { ...next, [name]: Keyed.accept(slot, key, decoded) } + }, model) + +const resolveCommand = (model: Model, context: PageContext, mode: RequestMode): Cmd => + ResolveWindow({ + mode, + startTime: model.search.startTime, + endTime: model.search.endTime, + preset: model.search.timePreset ?? DEFAULT_PRESET, + timeZone: context.timeZone, + }) + +const searchHref = (search: Search) => hrefOf(AppRoute.ProductAnalytics({ search })) + +/** + * Goes to `search`, whose steps and anchor become the drafts. A pending typing pause is cancelled: + * the URL now holds whatever the drafts were. + */ +const navigate = (model: Model, search: Search, isReplace = false): Return => ({ + model: { ...model, ...draftsOf(search), typingVersion: model.typingVersion + 1 }, + outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace }), +}) + +const withRequests = (model: Model, context: PageContext, mode: Keyed.RequestMode): Return => + model.window === null ? { model } : requestSlots(model, context, model.window, mode) + +// The step drafts. The URL holds the definition that runs; the drafts hold what is being edited. + +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)) + +/** The steps and anchor a search carries, as one comparable string. */ +const committedOf = (search: Search): string => + encodeJson({ steps: search.steps ?? [], anchor: search.anchor ?? null }) + +const draftsOf = (search: Search): Pick => ({ + steps: search.steps ?? [], + anchor: pathsOf(search).anchor, + committed: committedOf(search), +}) + +/** The search with the drafts written in, so a tab or filter change carries what is being typed. */ +export const withDrafts = (model: Model): Search => { + const steps = model.steps.map(toFunnelStep) + const anchor = toAnchor(model.anchor) + return { + ...model.search, + steps: steps.length === 0 ? undefined : steps, + anchor: anchorNameOf(anchor) === "" ? undefined : anchor, + } +} + +/** The drafts written to the URL (replacing the entry: each edit is not a Back step). */ +const commit = (model: Model): Return => navigate(model, withDrafts(model), true) + +const applyEdit = (model: Model, scope: StepScope, edit: StepEdit): Model => + scope === "funnel" + ? { ...model, steps: applyStepEdit(model.steps, edit, FUNNEL_MAX_STEPS) } + : { ...model, anchor: applyAnchorEdit(model.anchor, edit) } + +/** A typed value waits for a pause before it runs; anything picked runs now. */ +const edited = (model: Model, scope: StepScope, edit: StepEdit, isTyping: boolean): Return => { + const next = applyEdit(model, scope, edit) + if (!isTyping) return commit(next) + const typingVersion = next.typingVersion + 1 + return { model: { ...next, typingVersion }, commands: [WaitForTypingPause({ version: typingVersion })] } +} + +const lift = ( + model: Model, + commands: ReadonlyArray> | undefined, + wrap: (inner: A) => Message, + patch: Partial, +): Return => ({ model: { ...model, ...patch }, commands: Command.mapMessages(commands ?? [], wrap) }) + +/** `onClearFilters`: the window, the tab and the definitions stay; the filters go. */ +const clearedFilters = (search: Search): Search => Struct.omit(search, FUNNEL_POPULATION_FILTER_FIELDS) + +/** The rail speaks every analytics filter; this page keeps the population ones. */ +const decodeFilterKey = Schema.decodeUnknownOption(FilterKey) + +const withFilter = (search: Search, key: FilterKey, value: string | undefined): Search => ({ + ...search, + [key]: value === "" ? undefined : value, +}) + +// ENTRY POINTS + +export const init = (route: { readonly search: Search }, context: PageContext): Return => { + const model: Model = { + search: route.search, + window: null, + breakdowns: Keyed.idle(), + events: Keyed.idle(), + previousEvents: Keyed.idle(), + funnel: Keyed.idle(), + paths: Keyed.idle(), + ...draftsOf(route.search), + typingVersion: 0, + stepCombos: {}, + selects: {}, + eventsQuery: "", + eventsKind: "all", + failedFavicons: [], + filterSections: FilterSections.init(), + timePicker: TimeRangePicker.init("product-analytics-time-range"), + chart: Chart.init(), + refreshVersion: 0, + isReloading: false, + } + return { + model, + commands: [ + resolveCommand(model, context, "Load"), + Command.mapMessage(FilterSections.ReadRemembered(), (message) => + Message.GotFilterSectionsMessage({ message }), + ), + ], + } +} + +export const routeChanged = ( + model: Model, + route: { readonly search: Search }, + context: PageContext, +): Return => { + if (searchHref(model.search) === searchHref(route.search)) return { model } + // A definition this page did not write (Back, a row's Funnel button) replaces the drafts. + const drafts = + committedOf(route.search) === model.committed + ? {} + : { ...draftsOf(route.search), typingVersion: model.typingVersion + 1 } + const next: Model = { ...model, ...drafts, search: route.search } + return routeWindowStep(model.search, route.search, { + resolve: (): Return => ({ + model: next, + commands: [resolveCommand(next, context, snapModeOf(model.refreshVersion))], + }), + requestAll: (mode) => withRequests(next, context, mode), + }) +} + +export const contextChanged = (model: Model, context: PageContext): Return => + withRequests(model, context, "Load") + +export const update = (model: Model, message: Message, context: PageContext): Return => + Message.match(message, { + CompletedResolveWindow: ({ window, mode }) => withRequests({ ...model, window }, context, mode), + CompletedFetch: ({ key, result }) => ({ model: settleSlots(model, key, result) }), + ClickedReload: () => { + const reloaded = { ...model, refreshVersion: model.refreshVersion + 1, isReloading: true } + return { + model: reloaded, + commands: [ + resolveCommand(reloaded, context, "Refresh"), + CompleteReloadSpin({ version: reloaded.refreshVersion }), + ], + } + }, + CompletedReloadSpin: ({ version }) => + version === model.refreshVersion ? { model: { ...model, isReloading: false } } : { model }, + ClickedResetTimeRange: () => + navigate(model, { + ...withDrafts(model), + startTime: undefined, + endTime: undefined, + timePreset: DEFAULT_PRESET, + }), + ClickedRetry: ({ slot }) => + model.window === null ? { model } : requestSlots(model, context, model.window, "Refresh", [slot]), + ToggledFilter: ({ key: raw, value }) => + Option.match(decodeFilterKey(raw), { + onNone: () => ({ model }), + onSome: (key) => { + const current = model.search[key] + return navigate( + model, + withFilter(withDrafts(model), key, toggleFilterValue(current, value)), + ) + }, + }), + ChangedFilter: ({ key: raw, value }) => + Option.match(decodeFilterKey(raw), { + onNone: () => ({ model }), + onSome: (key) => navigate(model, withFilter(withDrafts(model), key, value)), + }), + ClickedClearFilters: () => navigate(model, clearedFilters(withDrafts(model))), + FailedFavicon: ({ host }) => + model.failedFavicons.includes(host) + ? { model } + : { model: { ...model, failedFavicons: [...model.failedFavicons, host] } }, + ChangedEventsQuery: ({ value }) => ({ model: { ...model, eventsQuery: value } }), + PickedEventsKind: ({ kind }) => ({ model: { ...model, eventsKind: kind } }), + ClickedClearEventsFilters: () => ({ model: { ...model, eventsQuery: "", eventsKind: "all" } }), + ClickedStartFunnel: ({ step }) => + navigate(model, { ...withDrafts(model), tab: "funnels", steps: [step] }), + ClickedStartPaths: ({ anchor }) => navigate(model, { ...withDrafts(model), tab: "paths", anchor }), + ClickedSuggestedFunnel: ({ steps }) => + navigate(model, { ...withDrafts(model), tab: "funnels", steps }), + ChangedStep: ({ scope, edit }) => edited(model, scope, edit, edit._tag === "ChangedText"), + GotStepComboMessage: ({ id, message: inner }) => { + const next = stepComboUpdate(model.stepCombos, id, inner, (m) => + Message.GotStepComboMessage({ id, message: m }), + ) + const lifted: Model = { ...model, stepCombos: next.combos } + if (Option.isNone(next.edit)) return { model: lifted, commands: next.commands } + const step = edited(lifted, next.edit.value.scope, next.edit.value.edit, next.isTyping) + return { ...step, commands: [...next.commands, ...(step.commands ?? [])] } + }, + GotSelectMessage: ({ id, message: inner }) => { + const next = Select.update( + model.selects[id] ?? Select.init(id, { value: "", selectedIndex: null }), + inner, + ) + const lifted = lift(model, next.commands, (m) => Message.GotSelectMessage({ id, message: m }), { + selects: { ...model.selects, [id]: next.model }, + }) + const target = + next.outMessage === undefined ? Option.none() : stepEditOf(id, next.outMessage.value) + if (Option.isNone(target)) return lifted + const step = edited(lifted.model, target.value.scope, target.value.edit, false) + return { ...step, commands: [...(lifted.commands ?? []), ...(step.commands ?? [])] } + }, + PausedTyping: ({ version }) => (version === model.typingVersion ? commit(model) : { model }), + PickedKeyBy: ({ keyBy }) => navigate(model, { ...withDrafts(model), keyBy }, true), + PickedWindow: ({ seconds }) => navigate(model, { ...withDrafts(model), within: seconds }, true), + PickedVariant: ({ variant }) => navigate(model, { ...withDrafts(model), view: variant }, true), + PickedDirection: ({ direction }) => navigate(model, { ...withDrafts(model), direction }, true), + PickedDepth: ({ depth }) => navigate(model, { ...withDrafts(model), depth }, true), + PickedBranches: ({ branches }) => navigate(model, { ...withDrafts(model), branches }, true), + GotFilterSectionsMessage: ({ message: inner }) => { + const next = FilterSections.update(model.filterSections, inner) + return lift(model, next.commands, (m) => Message.GotFilterSectionsMessage({ message: m }), { + filterSections: next.model, + }) + }, + GotTimePickerMessage: ({ message: inner }) => { + const next = TimeRangePicker.update(model.timePicker, inner) + const lifted = lift(model, next.commands, (m) => Message.GotTimePickerMessage({ message: m }), { + timePicker: next.model, + }) + if (next.outMessage === undefined || next.outMessage._tag === "RejectedRange") return lifted + return { + ...navigate(lifted.model, { + ...withDrafts(lifted.model), + ...TimeRangePicker.searchPatchOf(next.outMessage), + }), + commands: lifted.commands, + } + }, + GotChartMessage: ({ message: inner }) => { + const next = Chart.update(model.chart, inner) + return lift(model, next.commands, (m) => Message.GotChartMessage({ message: m }), { + chart: next.model, + }) + }, + }) diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts new file mode 100644 index 0000000000..ffb9a81e19 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -0,0 +1,261 @@ +import { formatNumber, pluralize } from "@maple/ui/lib/format" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { button } from "@maple/views/ui/button" +import { compactSearchInput } from "@maple/views/ui/compact-search-input" +import { badge } from "@maple/views/ui/display" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { meter } from "@maple/views/ui/meter" +import { panel, panelHeader } from "@maple/views/ui/panel" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { filteredEmpty } from "@maple/views/ui/signal-empty-state" +import { hstack, vstack } from "@maple/views/ui/stack" +import { + numericTableCell, + table, + tableBody, + tableCell, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" +import { tableSkeleton } from "@maple/views/ui/table-skeleton" +import { typography } from "@maple/views/ui/typography" +import { docsLink } from "../kit/signal-empty-state" +import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" +import { Message } from "./message" +import { type EventKindFilter, type EventRow, isCustomEvent, type Model } from "./model" +import { EVENTS_LIMIT } from "./requests" +import { emptyPanel } from "./view-parts" + +// The Events tab: every event name in the window, and the way into a funnel or paths from one. + +type H = HtmlBuilder + +const kindOf = (row: EventRow) => + row.kind === "navigation" + ? { label: "Page view", variant: "info" as const } + : row.kind === "screen" + ? { label: "Screen", variant: "done" as const } + : { label: "Custom", variant: "live" as const } + +const KIND_FILTERS: ReadonlyArray<{ value: EventKindFilter; label: string }> = [ + { value: "all", label: "All" }, + { value: "custom", label: "Custom" }, + { value: "navigation", label: "Page views" }, + { value: "screen", label: "Screens" }, +] + +const sum = (rows: ReadonlyArray): number => rows.reduce((total, row) => total + row.count, 0) + +/** `+12% vs previous period`, or nothing when the previous window is empty or still loading. */ +const deltaOf = (model: Model, current: number): string | undefined => { + const previous = Option.getOrUndefined(AsyncData.getData(model.previousEvents.data)) + const before = previous === undefined ? 0 : sum(previous) + if (before === 0) return undefined + const change = Math.round(((current - before) / before) * 100) + return `${change >= 0 ? "+" : ""}${change}% vs previous period` +} + +const kindSummary = (rows: ReadonlyArray): string => { + const pages = rows.filter((row) => row.kind === "navigation").length + const screens = rows.filter((row) => row.kind === "screen").length + const custom = rows.length - pages - screens + return [ + `${custom} custom`, + ...(pages > 0 ? [`${pages} ${pluralize(pages, "page view")}`] : []), + ...(screens > 0 ? [`${screens} ${pluralize(screens, "screen")}`] : []), + ].join(" · ") +} + +const stats = (h: H, model: Model, rows: ReadonlyArray): Html => { + const total = sum(rows) + // The read stops at EVENTS_LIMIT names: past that, totals and the trend would undercount. + const isCapped = rows.length >= EVENTS_LIMIT + const top = rows + .filter(isCustomEvent) + .reduce( + (best, row) => (best === undefined || row.persons > best.persons ? row : best), + undefined, + ) + return statRail(h, { columns: 3 }, [ + statRailItem(h, { + eyebrow: "Events", + value: formatNumber(total), + delta: isCapped ? undefined : deltaOf(model, total), + subline: isCapped ? `across the top ${EVENTS_LIMIT} names` : undefined, + }), + statRailItem(h, { + eyebrow: "Event names", + value: isCapped ? `${EVENTS_LIMIT}+` : formatNumber(rows.length), + subline: kindSummary(rows), + }), + statRailItem(h, { + eyebrow: "Top custom event", + value: top?.eventName ?? "–", + valueClassName: "truncate font-mono text-lg", + subline: + top === undefined + ? undefined + : `${formatNumber(top.persons)} ${pluralize(top.persons, "person", "people")}`, + }), + ]) +} + +const visibleRows = (model: Model, rows: ReadonlyArray): ReadonlyArray => { + const query = model.eventsQuery.trim().toLowerCase() + return rows + .filter((row) => + model.eventsKind === "all" + ? true + : model.eventsKind === "custom" + ? isCustomEvent(row) + : row.kind === model.eventsKind, + ) + .filter((row) => query === "" || row.eventName.toLowerCase().includes(query)) + .toSorted((a, b) => b.persons - a.persons || b.count - a.count) +} + +const rowActions = (h: H, row: EventRow): Html => + hstack(h, { gap: "1", justify: "end" }, [ + button( + h, + { + variant: "ghost", + size: "xs", + attributes: [ + h.AriaLabel(`Build a funnel from ${row.eventName}`), + h.OnClick( + Message.ClickedStartFunnel({ step: { kind: "event", eventName: row.eventName } }), + ), + ], + }, + [icon(h, "FilterIcon"), "Funnel"], + ), + button( + h, + { + variant: "ghost", + size: "xs", + attributes: [ + h.AriaLabel(`Paths after ${row.eventName}`), + h.OnClick( + Message.ClickedStartPaths({ anchor: { kind: "event", eventName: row.eventName } }), + ), + ], + }, + [icon(h, "BranchForkIcon"), "Paths"], + ), + ]) + +const eventsTable = (h: H, model: Model, rows: ReadonlyArray): Html => { + const shown = visibleRows(model, rows) + const maxPersons = Math.max(1, ...rows.map((row) => row.persons)) + if (shown.length === 0) + return filteredEmpty(h, { noun: "events", onClear: Message.ClickedClearEventsFilters() }) + return table(h, { variant: "bare", size: "sm", ariaLabel: "Product events" }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Event"]), + tableHead(h, ["Kind"]), + tableHead(h, ["Events"], "text-right"), + tableHead(h, ["Sessions"], "text-right"), + tableHead(h, ["People"], "text-right"), + tableHead(h, [typography(h, { className: "sr-only" }, ["Actions"])]), + ]), + ]), + tableBody( + h, + shown.map((row) => { + const kind = kindOf(row) + return tableRow( + h, + [ + tableCell( + h, + [typography(h, { size: "xs", mono: true, truncate: true }, [row.eventName])], + "max-w-80", + ), + tableCell(h, [badge(h, { variant: kind.variant, size: "sm" }, [kind.label])]), + numericTableCell(h, [formatNumber(row.count)]), + numericTableCell(h, [formatNumber(row.sessions)]), + numericTableCell(h, [ + hstack(h, { gap: "2.5", justify: "end" }, [ + formatNumber(row.persons), + meter(h, { + value: row.persons, + max: maxPersons, + minVisible: 0.01, + className: "w-24", + }), + ]), + ]), + tableCell(h, [rowActions(h, row)], "w-px"), + ], + undefined, + [h.Key(`${row.kind}:${row.eventName}`)], + ) + }), + ), + ]) +} + +const sendYourOwn = (h: H): Html => + hstack(h, { gap: "2", wrap: true, className: "border-t px-4 py-2.5" }, [ + typography(h, { size: "xs", tone: "muted", className: "flex-1" }, [ + 'Send your own with track("name", props) in the browser, POST /v1/events from a server, or a span attribute.', + ]), + docsLink(h, "productEventsApi", "Event docs"), + ]) + +export const eventsTab = (h: H, model: Model): Html => + AsyncData.matchData(model.events.data, { + onEmpty: () => + vstack(h, { gap: "4" }, [ + statRailLoading(h, 3, 3), + panel(h, {}, [tableSkeleton(h, { columns: 6, rows: 8 })]), + ]), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load product events", + onRetry: Message.ClickedRetry({ slot: "events" }), + }), + onData: (rows) => + rows.length === 0 + ? emptyPanel(h, "No product events in this window", [ + "Page views arrive with web analytics. Custom events come from track() in the browser SDK, POST /v1/events from a backend, or an annotated span.", + hstack(h, { justify: "center", className: "mt-3" }, [ + docsLink(h, "productEventsApi"), + ]), + ]) + : vstack(h, { gap: "4" }, [ + stats(h, model, rows), + panel(h, {}, [ + panelHeader(h, { + title: "All events", + action: hstack(h, { gap: "2" }, [ + segmentedSelect(h, { + options: KIND_FILTERS, + value: model.eventsKind, + onChange: (kind) => Message.PickedEventsKind({ kind }), + size: "xs", + ariaLabel: "Event kind", + }), + compactSearchInput(h, { + value: model.eventsQuery, + placeholder: "Filter events…", + ariaLabel: "Filter events", + onInput: (value) => Message.ChangedEventsQuery({ value }), + onClear: Message.ChangedEventsQuery({ value: "" }), + className: "w-48", + }), + ]), + }), + eventsTable(h, model, rows), + sendYourOwn(h), + ]), + ]), + }) diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts new file mode 100644 index 0000000000..227b04d318 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -0,0 +1,286 @@ +import { FUNNEL_MAX_STEPS, type FunnelKeyBy, type FunnelVariant } from "@maple/query-model" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import type * as Chart from "@maple/views/charts/chart" +import * as FunnelChart from "@maple/views/charts/funnel-chart" +import * as FunnelDropoffChart from "@maple/views/charts/funnel-dropoff-chart" +import { fmtSpan } from "@maple/views/charts/funnel-dropoff-steps" +import { button } from "@maple/views/ui/button" +import { errorState } from "@maple/views/ui/error-state" +import { panel, panelChartBody, panelHeader } from "@maple/views/ui/panel" +import { hstack, vstack } from "@maple/views/ui/stack" +import { + numericTableCell, + table, + tableBody, + tableCell, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" +import { typography } from "@maple/views/ui/typography" +import { FUNNEL_KEY_BY_OPTIONS, FUNNEL_WINDOW_OPTIONS } from "../../components/funnels/definition" +import { stepBuilder } from "../kit/funnel-steps" +import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" +import { funnelOf, funnelParamsOf } from "./definition" +import { Message } from "./message" +import { type FunnelRow, isCustomEvent, type Model } from "./model" +import { + controlsBar, + countHeader, + countNoun, + emptyPanel, + eventSuggestions, + labelledSegment, + pageSuggestions, +} from "./view-parts" + +// The Funnels tab: the step editor, then how many reached each step and where the rest went. + +type H = HtmlBuilder + +const VARIANTS: ReadonlyArray<{ value: FunnelVariant; label: string }> = [ + { value: "bars", label: "Bars" }, + { value: "dropoff", label: "Drop-off" }, +] + +const WINDOWS = FUNNEL_WINDOW_OPTIONS.map((option) => ({ value: String(option.value), label: option.label })) + +const editor = (h: H, model: Model): Html => { + const funnel = funnelOf(model.search) + return panel(h, { className: "shrink-0" }, [ + panelHeader(h, { + title: "Steps", + scope: typography(h, { size: "2xs", tone: "muted" }, [ + "in order, each within the window of step 1", + ]), + }), + vstack(h, { className: "p-3" }, [ + stepBuilder(h, { + scope: "funnel", + slotPrefix: "product-analytics", + steps: model.steps, + eventNames: eventSuggestions(model), + pagePaths: pageSuggestions(model), + maxSteps: FUNNEL_MAX_STEPS, + combos: model.stepCombos, + selects: model.selects, + onCombo: (id, message) => Message.GotStepComboMessage({ id, message }), + onSelect: (id, message) => Message.GotSelectMessage({ id, message }), + onEdit: (edit) => Message.ChangedStep({ scope: "funnel", edit }), + }), + ]), + controlsBar(h, [ + labelledSegment(h, { + label: "Count by", + value: funnel.keyBy, + options: FUNNEL_KEY_BY_OPTIONS, + onChange: (keyBy) => Message.PickedKeyBy({ keyBy }), + }), + labelledSegment(h, { + label: "Within", + value: String(funnel.windowSeconds), + options: WINDOWS, + onChange: (seconds) => Message.PickedWindow({ seconds: Number(seconds) }), + }), + hstack(h, { className: "ml-auto" }, [ + labelledSegment(h, { + label: "View", + value: funnel.variant, + options: VARIANTS, + onChange: (variant) => Message.PickedVariant({ variant }), + }), + ]), + ]), + ]) +} + +/** Starting points from the events this org sends: the most reached custom events, two at a time. */ +const suggestions = (h: H, model: Model): Html => { + const custom = Option.getOrElse(AsyncData.getData(model.events.data), () => []) + .filter(isCustomEvent) + .toSorted((a, b) => b.persons - a.persons) + .slice(0, 4) + const pairs = custom + .slice(1) + .map((row, index) => [custom[index]?.eventName ?? "", row.eventName] as const) + return emptyPanel(h, "Pick the steps people take, in order", [ + "Each step is an event, a page view or, first step only, where a session came from.", + ...(pairs.length === 0 + ? [] + : [ + " Or start from one of these:", + hstack( + h, + { gap: "2", wrap: true, justify: "center", className: "mt-3" }, + pairs.map(([first, second]) => + button( + h, + { + variant: "outline", + size: "sm", + attributes: [ + h.Key(`${first}>${second}`), + h.OnClick( + Message.ClickedSuggestedFunnel({ + steps: [ + { kind: "event", eventName: first }, + { kind: "event", eventName: second }, + ], + }), + ), + ], + }, + [typography(h, { size: "xs", mono: true }, [`${first} → ${second}`])], + ), + ), + ), + ]), + ]) +} + +/** The step that loses the largest share of those who reached the one before it. */ +const biggestDrop = (rows: ReadonlyArray): Readonly<{ name: string; share: number }> | null => + rows.slice(1).reduce | null>((worst, row, index) => { + const before = rows[index]?.value ?? 0 + const share = before === 0 ? 0 : 1 - row.value / before + return worst === null || share > worst.share ? { name: row.name, share } : worst + }, null) + +const stats = (h: H, rows: ReadonlyArray): Html => { + const entered = rows[0]?.value ?? 0 + const last = rows.at(-1) + const drop = biggestDrop(rows) + return statRail(h, { columns: 4 }, [ + statRailItem(h, { eyebrow: "Entered", value: formatNumber(entered), subline: "reached step 1" }), + statRailItem(h, { + eyebrow: "Converted", + value: formatNumber(last?.value ?? 0), + subline: last === undefined ? undefined : `reached ${last.name}`, + }), + statRailItem(h, { + eyebrow: "Conversion", + value: formatPercent(entered === 0 ? 0 : (last?.value ?? 0) / entered), + }), + statRailItem(h, { + eyebrow: "Biggest drop", + value: drop === null ? "–" : formatPercent(drop.share), + subline: drop === null ? "one step" : `before ${drop.name}`, + }), + ]) +} + +const chart = (h: H, model: Model, rows: ReadonlyArray, isDropoff: boolean): Html => { + const common = { + slotId: "product-analytics-funnel", + model: model.chart, + toParentMessage: (message: Chart.Message) => Message.GotChartMessage({ message }), + } + const inputs = { data: rows, className: "h-80" } + return isDropoff + ? h.submodel({ ...common, view: FunnelDropoffChart.view, viewInputs: inputs }) + : h.submodel({ ...common, view: FunnelChart.view, viewInputs: inputs }) +} + +const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, hasDetails: boolean): Html => { + const entered = rows[0]?.value ?? 0 + return table(h, { variant: "bare", size: "sm", ariaLabel: "Funnel steps" }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, ["Step"]), + tableHead(h, [countHeader(keyBy)], "text-right"), + tableHead(h, ["From previous"], "text-right"), + tableHead(h, ["From start"], "text-right"), + ...(hasDetails + ? [tableHead(h, ["Median time"], "text-right"), tableHead(h, ["Leavers did next"])] + : []), + ]), + ]), + tableBody( + h, + rows.map((row, index) => { + const before = index === 0 ? 0 : (rows[index - 1]?.value ?? 0) + const leaver = row.leavers?.[0] + return tableRow( + h, + [ + tableCell(h, [ + hstack(h, { gap: "2" }, [ + typography( + h, + { size: "2xs", tone: "muted", numeric: true, className: "w-4" }, + [String(index + 1)], + ), + typography(h, { size: "xs", mono: true, truncate: true }, [row.name]), + ]), + ]), + numericTableCell(h, [formatNumber(row.value)]), + numericTableCell(h, [before === 0 ? "–" : formatPercent(row.value / before)]), + numericTableCell(h, [entered === 0 ? "–" : formatPercent(row.value / entered)]), + ...(hasDetails + ? [ + numericTableCell(h, [row.p50Ms === undefined ? "–" : fmtSpan(row.p50Ms)]), + tableCell(h, [ + leaver === undefined + ? "–" + : typography( + h, + { size: "xs", mono: true, tone: "muted", truncate: true }, + [leaver.name === "" ? "(nothing)" : leaver.name], + ), + ]), + ] + : []), + ], + undefined, + [h.Key(`${index}:${row.name}`)], + ) + }), + ), + ]) +} + +const results = (h: H, model: Model): Html => + AsyncData.matchData(model.funnel.data, { + onEmpty: () => statRailLoading(h, 4, 4), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't run the funnel", + onRetry: Message.ClickedRetry({ slot: "funnel" }), + }), + onData: (rows) => { + const { keyBy, variant } = funnelOf(model.search) + // A bars answer still on screen while the drop-off details load draws as bars. + const hasDetails = rows + .slice(1) + .some((row) => row.p50Ms !== undefined || row.leavers !== undefined) + const isDropoff = variant === "dropoff" && (rows.length < 2 || hasDetails) + const entered = rows[0]?.value ?? 0 + if (entered === 0) + return emptyPanel(h, "Nobody reached step 1 in this window", [ + "Check the first step's name, widen the time range, or clear the filters.", + ]) + return vstack(h, { gap: "4" }, [ + stats(h, rows), + panel(h, {}, [ + panelHeader(h, { + title: isDropoff ? "Drop-off" : "Conversion", + scope: typography(h, { size: "2xs", tone: "muted" }, [ + `${formatNumber(entered)} ${countNoun(keyBy, entered)} entered`, + ]), + }), + panelChartBody(h, [chart(h, model, rows, isDropoff)]), + ]), + panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy, hasDetails)]), + ]) + }, + }) + +export const funnelsTab = (h: H, model: Model): Html => + vstack(h, { gap: "4" }, [ + editor(h, model), + funnelParamsOf(model.search) === null ? suggestions(h, model) : results(h, model), + ]) diff --git a/apps/web/src/page/product-analytics/view-parts.ts b/apps/web/src/page/product-analytics/view-parts.ts new file mode 100644 index 0000000000..9cab560c45 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-parts.ts @@ -0,0 +1,82 @@ +import type { FunnelKeyBy } from "@maple/query-model" +import { pluralize } from "@maple/ui/lib/format" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { empty, emptyDescription, emptyHeader, emptyTitle } from "@maple/views/ui/empty" +import { panel } from "@maple/views/ui/panel" +import { segmentedSelect } from "@maple/views/ui/segmented-select" +import { hstack } from "@maple/views/ui/stack" +import { typography } from "@maple/views/ui/typography" +import type { StepSuggestion } from "../kit/funnel-steps" +import type { Message } from "./message" +import type { EventRow, Model } from "./model" + +// What the three tabs share: their control bar, their empty panel and the step inputs' suggestions. + +type H = HtmlBuilder + +/** A labelled segmented control in a tab's control bar. */ +export const labelledSegment = ( + h: H, + options: Readonly<{ + label: string + value: T + options: ReadonlyArray<{ value: T; label: string }> + onChange: (value: T) => Message + }>, + extra: ReadonlyArray = [], +): Html => + hstack(h, { gap: "2" }, [ + typography(h, { size: "xs", tone: "muted", className: "shrink-0" }, [options.label]), + segmentedSelect(h, { + options: options.options, + value: options.value, + onChange: options.onChange, + size: "xs", + ariaLabel: options.label, + }), + ...extra, + ]) + +/** The ruled row of controls under an editor. */ +export const controlsBar = (h: H, children: ReadonlyArray): Html => + hstack(h, { wrap: true, className: "gap-x-5 gap-y-2 border-t px-3 py-2.5" }, children) + +/** A panel holding one empty state: a title and what to do about it. */ +export const emptyPanel = (h: H, title: string, description: ReadonlyArray): Html => + panel(h, {}, [empty(h, [emptyHeader(h, [emptyTitle(h, [title]), emptyDescription(h, description)])])]) + +const eventRowsOf = (model: Model): ReadonlyArray => + Option.getOrElse(AsyncData.getData(model.events.data), () => []) + +/** The window's event names, most reached first, for the step inputs. */ +export const eventSuggestions = (model: Model): ReadonlyArray => + eventRowsOf(model) + .toSorted((a, b) => b.persons - a.persons) + .map((row) => ({ name: row.eventName, count: row.persons })) + +/** The window's entry pages, for page steps. */ +export const pageSuggestions = (model: Model): ReadonlyArray => + Option.getOrElse( + Option.map(AsyncData.getData(model.breakdowns.data), (breakdowns) => breakdowns.entryPaths), + () => [], + ) + +/** What a funnel or path counts, as people read it: "1 person", "12 sessions". */ +export const countNoun = (keyBy: FunnelKeyBy, count: number): string => { + switch (keyBy) { + case "person": + return pluralize(count, "person", "people") + case "visitor": + return pluralize(count, "visitor") + case "user": + return pluralize(count, "user") + case "session": + return pluralize(count, "session") + } +} + +/** A table column header for the same count. */ +export const countHeader = (keyBy: FunnelKeyBy): string => + ({ person: "People", visitor: "Visitors", user: "Users", session: "Sessions" })[keyBy] diff --git a/apps/web/src/page/product-analytics/view-paths.ts b/apps/web/src/page/product-analytics/view-paths.ts new file mode 100644 index 0000000000..a351dd2873 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -0,0 +1,307 @@ +import { + PATHS_MAX_BRANCHES, + PATHS_MAX_DEPTH, + PATHS_OTHER, + type PathsAnchor, + type PathsDirection, +} from "@maple/query-model" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" +import { Option } from "effect" +import { AsyncData } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import * as PathsChart from "@maple/views/charts/paths-chart" +import { button } from "@maple/views/ui/button" +import { emptyMessage } from "@maple/views/ui/empty" +import { errorState } from "@maple/views/ui/error-state" +import { icon } from "@maple/views/ui/icon" +import { panel, panelChartBody, panelHeader } from "@maple/views/ui/panel" +import { hstack, vstack } from "@maple/views/ui/stack" +import { + numericTableCell, + table, + tableBody, + tableCell, + tableHead, + tableHeader, + tableRow, +} from "@maple/views/ui/table" +import { tableSkeleton } from "@maple/views/ui/table-skeleton" +import { typography } from "@maple/views/ui/typography" +import { stepBuilder } from "../kit/funnel-steps" +import { anchorNameOf, pathsOf, pathsParamsOf } from "./definition" +import { Message } from "./message" +import type { Model, PathsRow } from "./model" +import { + controlsBar, + countNoun, + emptyPanel, + eventSuggestions, + labelledSegment, + pageSuggestions, +} from "./view-parts" + +// The Paths tab: what people did in the steps after (or before) one event or page. + +type H = HtmlBuilder + +const DIRECTIONS: ReadonlyArray<{ value: PathsDirection; label: string }> = [ + { value: "after", label: "After" }, + { value: "before", label: "Before" }, +] +const DEPTHS = Array.from({ length: PATHS_MAX_DEPTH }, (_, index) => ({ + value: String(index + 1), + label: String(index + 1), +})) +const BRANCHES = [3, 4, 6, PATHS_MAX_BRANCHES].map((value) => ({ + value: String(value), + label: String(value), +})) + +const editor = (h: H, model: Model): Html => { + const paths = pathsOf(model.search) + return panel(h, { className: "shrink-0" }, [ + vstack(h, { gap: "2", className: "p-3" }, [ + labelledSegment( + h, + { + label: "Direction", + value: paths.direction, + options: DIRECTIONS, + onChange: (direction) => Message.PickedDirection({ direction }), + }, + [ + typography(h, { size: "2xs", tone: "muted" }, [ + paths.direction === "after" + ? "what people did next, from the first time they reached this" + : "how people got here, back from the last time they reached this", + ]), + ], + ), + stepBuilder(h, { + scope: "paths", + slotPrefix: "product-analytics", + steps: [model.anchor], + eventNames: eventSuggestions(model), + pagePaths: pageSuggestions(model), + maxSteps: 1, + combos: model.stepCombos, + selects: model.selects, + onCombo: (id, message) => Message.GotStepComboMessage({ id, message }), + onSelect: (id, message) => Message.GotSelectMessage({ id, message }), + onEdit: (edit) => Message.ChangedStep({ scope: "paths", edit }), + }), + ]), + controlsBar(h, [ + labelledSegment(h, { + label: "Steps", + value: String(paths.depth), + options: DEPTHS, + onChange: (depth) => Message.PickedDepth({ depth: Number(depth) }), + }), + labelledSegment( + h, + { + label: "Branches", + value: String(paths.branches), + options: BRANCHES, + onChange: (branches) => Message.PickedBranches({ branches: Number(branches) }), + }, + [typography(h, { size: "2xs", tone: "muted" }, ["named per step, the rest fold into Other"])], + ), + ]), + ]) +} + +/** A path node's name as people read it. */ +const nodeLabel = (name: string): string => (name === PATHS_OTHER ? "Other" : name === "" ? "(ended)" : name) + +/** + * A node as the anchor of a new paths view. The query names page views by path and events by name, + * so a known event name is an event and anything else starting with `/` is a page. + */ +const anchorOf = (name: string, eventNames: ReadonlySet): PathsAnchor => + !eventNames.has(name) && name.startsWith("/") + ? { kind: "page", pagePath: name } + : { kind: "event", eventName: name } + +type Tally = Readonly<{ name: string; count: number }> + +const tally = (rows: ReadonlyArray, pick: (row: PathsRow) => string): ReadonlyArray => { + const counts = new Map() + rows.forEach((row) => counts.set(pick(row), (counts.get(pick(row)) ?? 0) + row.count)) + return [...counts].map(([name, count]) => ({ name, count })).toSorted((a, b) => b.count - a.count) +} + +/** The first hop's nodes: what people did right after (or before) the anchor. */ +const nextSteps = (rows: ReadonlyArray): ReadonlyArray => + tally( + rows.filter((row) => row.hop === 1 && row.toNode !== ""), + (row) => row.toNode, + ) + +/** Where a sequence stopped: the node a hop into `''` left from. */ +const endings = (rows: ReadonlyArray): ReadonlyArray => + tally( + rows.filter((row) => row.toNode === ""), + (row) => row.fromNode, + ) + +/** Everyone who reached the anchor: each of them has exactly one first hop. */ +const anchorTotal = (rows: ReadonlyArray): number => + rows.filter((row) => row.hop === 1).reduce((total, row) => total + row.count, 0) + +const tallyTable = ( + h: H, + model: Model, + options: Readonly<{ label: string; rows: ReadonlyArray; total: number; empty: string }>, +): Html => { + if (options.rows.length === 0) return emptyMessage(h, { className: "py-6" }, [options.empty]) + const eventNames = new Set( + Option.getOrElse(AsyncData.getData(model.events.data), () => []).map((row) => row.eventName), + ) + return table(h, { variant: "bare", size: "sm", ariaLabel: options.label }, [ + tableHeader(h, [ + tableRow(h, [ + tableHead(h, [options.label]), + tableHead(h, ["People"], "text-right"), + tableHead(h, ["Share"], "text-right"), + tableHead(h, [typography(h, { className: "sr-only" }, ["Actions"])]), + ]), + ]), + tableBody( + h, + options.rows.slice(0, 6).map((row) => + tableRow( + h, + [ + tableCell( + h, + [ + typography(h, { size: "xs", mono: true, truncate: true }, [ + nodeLabel(row.name), + ]), + ], + "max-w-60", + ), + numericTableCell(h, [formatNumber(row.count)]), + numericTableCell(h, [ + options.total === 0 ? "–" : formatPercent(row.count / options.total), + ]), + tableCell( + h, + row.name === PATHS_OTHER + ? [] + : [ + button( + h, + { + variant: "ghost", + size: "xs", + attributes: [ + h.AriaLabel(`Paths from ${row.name}`), + h.OnClick( + Message.ClickedStartPaths({ + anchor: anchorOf(row.name, eventNames), + }), + ), + ], + }, + [icon(h, "BranchForkIcon"), "Paths from here"], + ), + ], + "w-px", + ), + ], + undefined, + [h.Key(row.name)], + ), + ), + ), + ]) +} + +const loading = (h: H): Html => panel(h, {}, [tableSkeleton(h, { columns: 4, rows: 6 })]) + +const results = (h: H, model: Model): Html => { + const paths = pathsOf(model.search) + // A refetch after a direction or anchor change would draw the old hops under the new heading. + if (AsyncData.isRefreshing(model.paths.data)) return loading(h) + return AsyncData.matchData(model.paths.data, { + onEmpty: () => loading(h), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't load the paths", + onRetry: Message.ClickedRetry({ slot: "paths" }), + }), + onData: (rows) => { + const total = anchorTotal(rows) + if (rows.length === 0) + return emptyPanel(h, "Nobody reached this in this window", [ + "Check the name, widen the time range, or clear the filters.", + ]) + return vstack(h, { gap: "4" }, [ + panel(h, {}, [ + panelHeader(h, { + title: hstack(h, { gap: "1.5", as: "span" }, [ + paths.direction === "after" ? "What people did after" : "How people got to", + typography(h, { size: "sm", mono: true, as: "span" }, [ + anchorNameOf(paths.anchor), + ]), + ]), + scope: typography(h, { size: "2xs", tone: "muted" }, [ + `${formatNumber(total)} ${countNoun("person", total)} · repeats collapsed`, + ]), + }), + panelChartBody(h, [ + h.submodel({ + slotId: "product-analytics-paths", + model: model.chart, + view: PathsChart.view, + toParentMessage: (message) => Message.GotChartMessage({ message }), + viewInputs: { data: rows, className: "h-96", direction: paths.direction }, + }), + ]), + ]), + h.div( + [h.Class("grid gap-4 lg:grid-cols-2")], + [ + panel(h, {}, [ + panelHeader(h, { + title: + paths.direction === "after" + ? "Most common next step" + : "Most common step before", + }), + tallyTable(h, model, { + label: "Event or page", + rows: nextSteps(rows), + total, + empty: "Nothing followed", + }), + ]), + panel(h, {}, [ + panelHeader(h, { title: "Where sequences ended" }), + tallyTable(h, model, { + label: "Last event or page", + rows: endings(rows), + total, + empty: "Every sequence ran the full number of steps", + }), + ]), + ], + ), + ]) + }, + }) +} + +export const pathsTab = (h: H, model: Model): Html => + vstack(h, { gap: "4" }, [ + editor(h, model), + pathsParamsOf(model.search) === null + ? emptyPanel(h, "Pick an event or a page", [ + "Paths shows the most common sequences after it (or before it), a few steps deep.", + ]) + : results(h, model), + ]) diff --git a/apps/web/src/page/product-analytics/view.ts b/apps/web/src/page/product-analytics/view.ts new file mode 100644 index 0000000000..bf8b98d34c --- /dev/null +++ b/apps/web/src/page/product-analytics/view.ts @@ -0,0 +1,121 @@ +import { Option } from "effect" +import { AsyncData, Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { linkButton } from "@maple/views/ui/button" +import { icon } from "@maple/views/ui/icon" +import { hstack } from "@maple/views/ui/stack" +import { AppRoute, hrefOf } from "../../route" +import type { PageViewInputs } from "../contract" +import { pageTime } from "../infra-integrations/kit/page-time" +import { analyticsFilterRail } from "../kit/analytics-filter-rail" +import { dashboardPage } from "../kit/dashboard-page" +import { linkTabStrip } from "../kit/underline-tabs" +import { populationOf, type Tab, TABS, tabOf } from "./definition" +import { Message } from "./message" +import type { Model } from "./model" +import { DEFAULT_PRESET, withDrafts } from "./update" +import { eventsTab } from "./view-events" +import { funnelsTab } from "./view-funnels" +import { pathsTab } from "./view-paths" + +// `/product-analytics`: Events, Funnels and Paths over `product_events`, beside Web analytics. + +type H = HtmlBuilder + +const TAB_LABEL = { events: "Events", funnels: "Funnels", paths: "Paths" } satisfies Record + +const tabs = (h: H, model: Model): Html => { + const active = tabOf(model.search) + const eventCount = Option.getOrUndefined(AsyncData.getData(model.events.data))?.length + return linkTabStrip( + h, + "Product analytics views", + TABS.map((tab) => ({ + key: tab, + href: hrefOf( + AppRoute.ProductAnalytics({ + search: { ...withDrafts(model), tab: tab === "events" ? undefined : tab }, + }), + ), + label: TAB_LABEL[tab], + count: tab === "events" ? eventCount : undefined, + isCurrent: tab === active, + isRouteActive: tab === active, + })), + ) +} + +const replaysLink = (h: H, model: Model): Html => { + const { search } = model + return linkButton( + h, + { + variant: "outline", + size: "sm", + href: hrefOf( + AppRoute.Replays({ + search: { + startTime: search.startTime, + endTime: search.endTime, + timePreset: search.timePreset, + }, + }), + ), + attributes: [h.AriaLabel("View session replays")], + }, + [icon(h, "PlayRotateClockwiseIcon"), h.span([h.Class("hidden sm:inline")], ["Replays"])], + ) +} + +const rail = (h: H, model: Model): Html => + analyticsFilterRail(h, { + id: "product-analytics-filters", + breakdowns: model.breakdowns.data, + filters: populationOf(model.search), + showsFailureElsewhere: false, + sections: model.filterSections, + failedFavicons: model.failedFavicons, + onSections: (message) => Message.GotFilterSectionsMessage({ message }), + onToggle: (key, value) => Message.ToggledFilter({ key, value }), + onChange: (key, value) => Message.ChangedFilter({ key, value }), + onClear: Message.ClickedClearFilters(), + onFaviconFailed: (host) => Message.FailedFavicon({ host }), + }) + +const body = (h: H, model: Model): Html => { + switch (tabOf(model.search)) { + case "events": + return eventsTab(h, model) + case "funnels": + return funnelsTab(h, model) + case "paths": + return pathsTab(h, model) + } +} + +export const view = Submodel.defineView((model, _inputs, h) => + dashboardPage( + h, + { + title: "Product analytics", + description: "What people do in your product, and where they drop off.", + gap: "lg", + filters: rail(h, model), + actions: hstack(h, { gap: "2", wrap: true }, [ + replaysLink(h, model), + pageTime(h, { + search: model.search, + defaultPreset: DEFAULT_PRESET, + window: model.window, + picker: model.timePicker, + toPickerMessage: (message) => Message.GotTimePickerMessage({ message }), + onReset: Message.ClickedResetTimeRange(), + onReload: Message.ClickedReload(), + isReloading: model.isReloading, + }), + ]), + tabs: tabs(h, model), + }, + [body(h, model)], + ), +) diff --git a/apps/web/src/page/registry.ts b/apps/web/src/page/registry.ts index 70413079d8..7016ab9560 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -73,6 +73,7 @@ import * as LogsList from "./logs/list" import * as LogPage from "./logs/log" import * as MetricsBrowse from "./metrics/browse" import * as Analytics from "./analytics" +import * as ProductAnalytics from "./product-analytics" import * as MetricDetail from "./metrics/detail" import * as Overview from "./overview" import * as Recommendation from "./recommendations" @@ -181,6 +182,7 @@ export const pages = [ ReplayDetail.page, Share.page, Analytics.page, + ProductAnalytics.page, ] /** Public routes a page renders bare, outside the shell (`/share/$token`). */ diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index 9d69e7dff5..fc883bd740 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -75,6 +75,7 @@ export const AppRoute = defineRouteUnion({ AgentSessions: { search: Search.AgentSessionsSearch }, Alerts: { search: Search.AlertsSearch }, Analytics: { search: Search.AnalyticsSearch }, + ProductAnalytics: { search: Search.ProductAnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, Dashboards: { search: Search.DashboardsSearch }, diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index f5992c960b..b441ca6b72 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -257,6 +257,11 @@ const routers = { ), Alerts: pipe(path("alerts"), search(Search.AlertsSearch), Route.mapTo(AppRoute.Alerts)), Analytics: pipe(path("analytics"), search(Search.AnalyticsSearch), Route.mapTo(AppRoute.Analytics)), + ProductAnalytics: pipe( + path("product-analytics"), + search(Search.ProductAnalyticsSearch), + Route.mapTo(AppRoute.ProductAnalytics), + ), Anomalies: pipe(path("anomalies"), search(Search.AnomaliesSearch), Route.mapTo(AppRoute.Anomalies)), CodeReview: pipe(path("code-review"), search(Search.CodeReviewSearch), Route.mapTo(AppRoute.CodeReview)), Dashboards: pipe(path("dashboards"), search(Search.DashboardsSearch), Route.mapTo(AppRoute.Dashboards)), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index 2dd25fa947..a261601e32 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -17,7 +17,15 @@ import { VcsRepositoryId, } from "@maple/domain/http" import { PushSourcePlatform } from "@maple/domain/primitives" -import { QUERY_BUILDER_METRIC_TYPES } from "@maple/query-model" +import { + FunnelKeyBy, + FunnelPopulationFilters, + FunnelStep, + FunnelVariant, + PathsAnchor, + PathsDirection, + QUERY_BUILDER_METRIC_TYPES, +} from "@maple/query-model" // apps/web/src/lib/search-params.ts const StringArray = Schema.Array(Schema.String) @@ -503,6 +511,21 @@ export const AnalyticsSearch = Schema.Struct({ tab: Schema.optional(Schema.String), }) +/** `/product-analytics`: the population filters, the tab, and the funnel and paths definitions. */ +export const ProductAnalyticsSearch = Schema.Struct({ + ...FunnelPopulationFilters.fields, + ...TimeRangeSearchFields, + tab: Schema.optional(Schema.String), + steps: Schema.optional(Schema.Array(FunnelStep)), + keyBy: Schema.optional(FunnelKeyBy), + within: NumberParam, + view: Schema.optional(FunnelVariant), + anchor: Schema.optional(PathsAnchor), + direction: Schema.optional(PathsDirection), + depth: NumberParam, + branches: NumberParam, +}) + const AnomalySeverityLiteral = Schema.Literals(["warning", "critical"]) const AnomalySignalLiteral = Schema.Literals([ "error_rate", diff --git a/apps/web/src/route/session-time-range.ts b/apps/web/src/route/session-time-range.ts index 6adfe34f28..9b0785d6f0 100644 --- a/apps/web/src/route/session-time-range.ts +++ b/apps/web/src/route/session-time-range.ts @@ -32,6 +32,7 @@ const CEILINGS: ReadonlyMap = new Map([ "Logs", "Replays", "Analytics", + "ProductAnalytics", "ErrorIssue", "AgentSessionTool", "AgentSessionTools", diff --git a/apps/web/src/shell/nav.ts b/apps/web/src/shell/nav.ts index f89bec0f60..b649fc0895 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -161,6 +161,7 @@ export const navGroups = (flags: NavFlags): ReadonlyArray => [ items: [ exploreItem, { title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" }, + { title: "Product analytics", href: "/product-analytics", icon: "ChartBarTrendUpIcon" }, { title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" }, ], }, diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 506c42874a..893a255c1a 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -174,6 +174,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). ## apps/web/src/page/kit +- `apps/web/src/page/kit/analytics-filter-rail.ts` `isHostLike`, `favicon`, `analyticsFilterRail`: `AnalyticsFilterSidebar`: the session-dimension filter rail of /analytics and /product-analytics, its options from `getWebAnalyticsBreakdowns`. - `apps/web/src/page/kit/autocomplete-lookups.ts` `lookupField`, `autocompleteLookup`: The autocomplete lookups a query builder fetches once it is first focused, each reduced to the names it suggests. - `apps/web/src/page/kit/clipboard.ts` `writeClipboard`, `copyToast`: `useCopy`'s write: the clipboard API, then the `execCommand` fallback; `true` when it landed. - `apps/web/src/page/kit/commit-card.ts` `isResolvableSha`, `commitDetailKey`, `commitDetailsKey`, `readCommitDetail`, `readCommitDetails`, `loadCommitDetail`, `commitDetailBody`: The commit SHA hover card: the body `CommitDetailBody` renders once `commitQueryAtom(sha)` settles, and the lookup itself. From 486fd15ba6cccbcea6edc1ad71981890a5d0eaa7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:10:18 +0100 Subject: [PATCH 02/14] fix(web): keep a blank Page anchor when Product analytics navigates Picking Page for the paths anchor made an anchor with no path yet, which the URL leaves out; the navigation then rebuilt the drafts from the URL and the anchor fell back to an event. A navigation now keeps the drafts when it carries the definition they already produce. --- .../product-analytics.scene.test.ts | 11 +++++++++++ apps/web/src/page/product-analytics/update.ts | 16 ++++++++++------ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index 65cd6b7124..326c5a3017 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -170,6 +170,17 @@ describe("product analytics", () => { ).toEqual(SIGNUP) }) + test("a Page anchor with no path yet stays a Page anchor", () => { + const picked = pageUpdate( + resolved({ tab: "paths" }).model, + Message.ChangedStep({ scope: "paths", edit: StepEdit.SetKind({ index: 0, kind: "page" }) }), + ) + expect(picked.model.anchor).toEqual({ kind: "page", pagePath: "" }) + expect(routeChanged(picked.model, { search: { tab: "paths" } }, context).model.anchor.kind).toBe( + "page", + ) + }) + test("the rail's traffic and event keys do not apply here", () => { const model = resolved().model expect( diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 14ee5a9aa1..28ecfe9f62 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -71,13 +71,17 @@ const resolveCommand = (model: Model, context: PageContext, mode: RequestMode): const searchHref = (search: Search) => hrefOf(AppRoute.ProductAnalytics({ search })) /** - * Goes to `search`, whose steps and anchor become the drafts. A pending typing pause is cancelled: - * the URL now holds whatever the drafts were. + * Goes to `search`. A new definition (a row's Funnel) replaces the drafts; the drafts' own (a blank + * Page anchor the URL leaves out) keeps them. Either way a pending typing pause is cancelled. */ -const navigate = (model: Model, search: Search, isReplace = false): Return => ({ - model: { ...model, ...draftsOf(search), typingVersion: model.typingVersion + 1 }, - outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace }), -}) +const navigate = (model: Model, search: Search, isReplace = false): Return => { + const committed = committedOf(search) + const drafts = committed === committedOf(withDrafts(model)) ? { committed } : draftsOf(search) + return { + model: { ...model, ...drafts, typingVersion: model.typingVersion + 1 }, + outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace }), + } +} const withRequests = (model: Model, context: PageContext, mode: Keyed.RequestMode): Return => model.window === null ? { model } : requestSlots(model, context, model.window, mode) From 2b24176db8629b4dc1b6e9a7a16edccc749a5eab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:18:25 +0100 Subject: [PATCH 03/14] test(web): sample URL for the Product analytics page --- apps/web/src/view.lazy.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 704fbb76d3..46846044c6 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -110,6 +110,7 @@ const SAMPLE_PATHS = [ "/replays/session_1", "/share/token_1", "/analytics", + "/product-analytics", ] const entered = (path: string): Model => From 8a7f2aafa4db8b365452ad8fde0dba93c131f623 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:20:13 +0100 Subject: [PATCH 04/14] fix(web): name the Paths ends table by direction Walking back from the anchor, a sequence that runs out is where it started, not where it ended. --- .../product-analytics.scene.test.ts | 25 +++++++++++++++++++ .../src/page/product-analytics/view-paths.ts | 16 +++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index 326c5a3017..d103702bca 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -170,6 +170,31 @@ describe("product analytics", () => { ).toEqual(SIGNUP) }) + test("walking back from the anchor, a sequence that runs out is where it started", () => { + const model = settled( + resolved({ + tab: "paths", + direction: "before", + anchor: { kind: "event", eventName: "plan_started" }, + }).model, + "paths", + [ + { hop: 1, fromNode: "plan_started", toNode: "/pricing", count: 40 }, + { hop: 2, fromNode: "/pricing", toNode: "", count: 40 }, + ], + ) + scene( + { update: pageUpdate, view }, + given(model), + Mount.resolve( + MeasureChartContainer, + Chart.Message.ResizedChartContainer({ width: 800, height: 320 }), + ), + expectView(text("Where sequences started")).toExist(), + expectView(text("Where sequences ended")).not.toExist(), + ) + }) + test("a Page anchor with no path yet stays a Page anchor", () => { const picked = pageUpdate( resolved({ tab: "paths" }).model, diff --git a/apps/web/src/page/product-analytics/view-paths.ts b/apps/web/src/page/product-analytics/view-paths.ts index a351dd2873..c447dadfac 100644 --- a/apps/web/src/page/product-analytics/view-paths.ts +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -277,13 +277,23 @@ const results = (h: H, model: Model): Html => { label: "Event or page", rows: nextSteps(rows), total, - empty: "Nothing followed", + empty: + paths.direction === "after" ? "Nothing followed" : "Nothing came before", }), ]), panel(h, {}, [ - panelHeader(h, { title: "Where sequences ended" }), + // Walking back from the anchor, a sequence that runs out is where it began. + panelHeader(h, { + title: + paths.direction === "after" + ? "Where sequences ended" + : "Where sequences started", + }), tallyTable(h, model, { - label: "Last event or page", + label: + paths.direction === "after" + ? "Last event or page" + : "First event or page", rows: endings(rows), total, empty: "Every sequence ran the full number of steps", From bd197846a26d946a9355907d9407e878a129a1ef Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:43:02 +0100 Subject: [PATCH 05/14] feat(backend): demo seed writes browser sessions and product events Previews and local stacks seeded through /internal/demo/seed had traces, logs and metrics but no session_replays or product_events, so Web analytics showed its setup state and Product analytics had nothing to show. The seed now adds 40 visitors an hour walking a signup funnel. --- .../backend/src/services/org/DemoService.ts | 18 ++- .../src/services/org/demo/product.test.ts | 29 ++++ .../backend/src/services/org/demo/product.ts | 151 ++++++++++++++++++ 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/services/org/demo/product.test.ts create mode 100644 packages/backend/src/services/org/demo/product.ts diff --git a/packages/backend/src/services/org/DemoService.ts b/packages/backend/src/services/org/DemoService.ts index b268478f24..5b01e5a708 100644 --- a/packages/backend/src/services/org/DemoService.ts +++ b/packages/backend/src/services/org/DemoService.ts @@ -4,9 +4,11 @@ import { Env } from "@maple/backend/platform/Env" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" import type { TenantContext } from "@maple/backend/services/auth/AuthService" import { generateDemoRows } from "./demo/fixtures" +import { generateDemoProductRows } from "./demo/product" const DEMO_HOURS_DEFAULT = 6 const DEMO_RATE_PER_HOUR = 250 +const DEMO_VISITORS_PER_HOUR = 40 // Rows per warehouse append. Keeps each NDJSON POST body modest (~1.5k rows // total for the 6h default) without fanning out into many tiny requests. const INGEST_CHUNK = 500 @@ -50,7 +52,13 @@ export class DemoService extends Context.Service()( }) const ingestAll = ( - datasource: "traces" | "logs" | "metrics_gauge" | "metrics_sum", + datasource: + | "traces" + | "logs" + | "metrics_gauge" + | "metrics_sum" + | "session_replays" + | "product_events", rows: ReadonlyArray, ) => Effect.forEach( @@ -76,6 +84,14 @@ export class DemoService extends Context.Service()( // Runtime, Metric Overview) have data to show. yield* ingestAll("metrics_gauge", metricGaugeRows) yield* ingestAll("metrics_sum", metricSumRows) + // Browser sessions and their product events, so Web and Product analytics have data. + const { sessionRows, eventRows } = generateDemoProductRows({ + orgId: tenant.orgId, + hours: safeHours, + visitorsPerHour: DEMO_VISITORS_PER_HOUR, + }) + yield* ingestAll("session_replays", sessionRows) + yield* ingestAll("product_events", eventRows) return new DemoSeedResponse({ seeded: true, diff --git a/packages/backend/src/services/org/demo/product.test.ts b/packages/backend/src/services/org/demo/product.test.ts new file mode 100644 index 0000000000..7d7db4c674 --- /dev/null +++ b/packages/backend/src/services/org/demo/product.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest" +import { generateDemoProductRows } from "./product" + +const NOW = Date.parse("2026-11-03T12:00:00Z") +const rows = generateDemoProductRows({ orgId: "org_demo", hours: 6, visitorsPerHour: 40, nowMs: NOW }) + +const reached = (eventName: string) => + new Set(rows.eventRows.filter((row) => row.event_name === eventName).map((row) => row.visitor_id)).size + +describe("generateDemoProductRows", () => { + it("writes one session per visitor, and every event belongs to one of them", () => { + const sessions = new Set(rows.sessionRows.map((row) => row.session_id)) + expect(rows.sessionRows).toHaveLength(240) + expect(rows.eventRows.every((row) => sessions.has(row.session_id))).toBe(true) + expect(rows.eventRows.every((row) => row.org_id === "org_demo")).toBe(true) + }) + + it("narrows down the signup funnel step by step", () => { + const counts = ["signup_started", "signup_completed", "onboarding_connected"].map(reached) + expect(counts[0]).toBeGreaterThan(0) + expect(counts[1]).toBeLessThanOrEqual(counts[0] ?? 0) + expect(counts[2]).toBeLessThanOrEqual(counts[1] ?? 0) + }) + + it("keeps every event inside the window", () => { + const latest = rows.eventRows.map((row) => Date.parse(`${String(row.timestamp).replace(" ", "T")}Z`)) + expect(latest.every((ms) => ms <= NOW && ms >= NOW - 6 * 3600_000)).toBe(true) + }) +}) diff --git a/packages/backend/src/services/org/demo/product.ts b/packages/backend/src/services/org/demo/product.ts new file mode 100644 index 0000000000..80f31b2887 --- /dev/null +++ b/packages/backend/src/services/org/demo/product.ts @@ -0,0 +1,151 @@ +import { randomBytes } from "node:crypto" +import { fmtMetricTs } from "@maple/backend/services/warehouse/metric-rows" + +// Demo browser sessions and product events: `session_replays` rows (what web analytics and the +// funnel population filters read) and the `product_events` page views and track() calls inside +// them, walking a signup funnel so Product analytics has events, funnels and paths to show. +// Rows use the datasources' ingestion JSON (snake_case, see packages/domain/src/tinybird/datasources.ts). + +const HOST = "app.demo-shop.dev" + +const REFERRERS = [ + { referrerHost: "google.com", utmSource: "" }, + { referrerHost: "github.com", utmSource: "" }, + { referrerHost: "news.ycombinator.com", utmSource: "" }, + { referrerHost: "", utmSource: "newsletter" }, + { referrerHost: "", utmSource: "" }, +] as const + +const CLIENTS = [ + { browser: "Chrome", os: "Mac OS", device: "desktop" }, + { browser: "Chrome", os: "Windows", device: "desktop" }, + { browser: "Safari", os: "iOS", device: "mobile" }, + { browser: "Firefox", os: "Linux", device: "desktop" }, +] as const + +const COUNTRIES = ["US", "US", "DE", "GB", "FR", "IN"] as const + +/** Each step reached with this chance once the previous one was. Leavers drift to the side events. */ +const FUNNEL: ReadonlyArray<{ event: string; page: string; chance: number }> = [ + { event: "signup_started", page: "/signup", chance: 0.35 }, + { event: "signup_completed", page: "/onboarding", chance: 0.65 }, + { event: "onboarding_connected", page: "/onboarding", chance: 0.55 }, + { event: "dashboard_created", page: "/dashboards", chance: 0.6 }, + { event: "plan_started", page: "/billing", chance: 0.2 }, +] + +const SIDE_EVENTS = [ + { event: "docs_opened", page: "/docs" }, + { event: "pricing_viewed", page: "/pricing" }, + { event: "query_run", page: "/explore" }, +] as const + +const pick = ]>(items: A): A[number] => + items[Math.floor(Math.random() * items.length)] ?? items[0] +const id = (prefix: string) => `${prefix}_${randomBytes(8).toString("hex")}` + +export interface DemoProductRows { + readonly sessionRows: ReadonlyArray> + readonly eventRows: ReadonlyArray> +} + +export const generateDemoProductRows = (input: { + readonly orgId: string + readonly hours: number + readonly visitorsPerHour: number + readonly nowMs?: number +}): DemoProductRows => { + const nowMs = input.nowMs ?? Date.now() + const windowMs = input.hours * 3600_000 + const visitors = Math.max(1, Math.round(input.hours * input.visitorsPerHour)) + const sessionRows: Array> = [] + const eventRows: Array> = [] + + Array.from({ length: visitors }).forEach(() => { + const visitorId = id("v") + const sessionId = id("s") + const referral = pick(REFERRERS) + const client = pick(CLIENTS) + const country = pick(COUNTRIES) + const startMs = nowMs - Math.random() * windowMs * 0.95 + let atMs = startMs + let seq = 0 + let userId = "" + const pages: Array = [] + + const push = (kind: "navigation" | "custom", eventName: string, pagePath: string) => { + atMs += 5_000 + Math.random() * 90_000 + seq += 1 + if (kind === "navigation") pages.push(pagePath) + eventRows.push({ + org_id: input.orgId, + timestamp: fmtMetricTs(Math.min(atMs, nowMs)), + source: "browser", + session_id: sessionId, + seq, + visitor_id: visitorId, + user_id: userId, + kind, + event_name: eventName, + host: HOST, + page_path: pagePath, + url: `https://${HOST}${pagePath}`, + service_name: "demo-frontend", + }) + } + const visit = (pagePath: string) => push("navigation", "$pageview", pagePath) + + visit("/") + if (Math.random() < 0.5) visit("/pricing") + FUNNEL.every((step) => { + if (Math.random() >= step.chance) { + if (Math.random() < 0.6) { + const side = pick(SIDE_EVENTS) + visit(side.page) + push("custom", side.event, side.page) + } + return false + } + visit(step.page) + if (step.event === "signup_completed") userId = id("u") + push("custom", step.event, step.page) + return true + }) + + const endMs = Math.min(atMs + 10_000, nowMs) + sessionRows.push({ + org_id: input.orgId, + session_id: sessionId, + start_time: fmtMetricTs(startMs), + end_time: fmtMetricTs(endMs), + duration_ms: Math.round(endMs - startMs), + status: "ended", + user_id: userId, + url_initial: `https://${HOST}/`, + user_agent: "", + browser_name: client.browser, + os_name: client.os, + device_type: client.device, + country, + service_name: "demo-frontend", + page_views: pages.length, + click_count: pages.length * 3, + error_count: 0, + trace_ids: [], + resource_attributes: {}, + version: 1, + visitor_id: visitorId, + visitor_is_new: 1, + referrer: referral.referrerHost === "" ? "" : `https://${referral.referrerHost}/`, + referrer_host: referral.referrerHost, + utm_source: referral.utmSource, + host: HOST, + entry_path: pages[0] ?? "/", + exit_path: pages.at(-1) ?? "/", + language: "en-US", + last_activity_at: fmtMetricTs(endMs), + }) + }) + + return { sessionRows, eventRows } +} From 4ce8f22557e2efe374a0360b69f761cb8dd384d1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:43:02 +0100 Subject: [PATCH 06/14] fix(web): name the Events row's Paths action by what it opens The row keeps the Paths tab's direction, so it opens paths from the event, after or before. --- apps/web/src/page/product-analytics/view-events.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts index ffb9a81e19..d7532c8d86 100644 --- a/apps/web/src/page/product-analytics/view-events.ts +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -140,7 +140,7 @@ const rowActions = (h: H, row: EventRow): Html => variant: "ghost", size: "xs", attributes: [ - h.AriaLabel(`Paths after ${row.eventName}`), + h.AriaLabel(`Paths from ${row.eventName}`), h.OnClick( Message.ClickedStartPaths({ anchor: { kind: "event", eventName: row.eventName } }), ), From d7fda3b666ce7b72100040bf76fa24eeae52282c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:46:58 +0100 Subject: [PATCH 07/14] fix(query-engine): the ingest org guard reads org_id rows too session_replays and product_events map OrgId from the ingestion key org_id, so the guard saw no org tag on their rows and refused the demo seed's sessions. It now checks org_id like the other two tags: every tag a row carries must be the writing org. --- packages/query-engine/src/execution/executor.test.ts | 10 +++++++++- packages/query-engine/src/execution/executor.ts | 3 +++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/query-engine/src/execution/executor.test.ts b/packages/query-engine/src/execution/executor.test.ts index 1d133fe09e..7194782ed0 100644 --- a/packages/query-engine/src/execution/executor.test.ts +++ b/packages/query-engine/src/execution/executor.test.ts @@ -279,12 +279,20 @@ describe("makeWarehouseExecutor span instrumentation", () => { const foreignResource = yield* executor .ingest(tenant, "traces", [{ resource_attributes: { maple_org_id: "org_victim" } }]) .pipe(Effect.flip) + const foreignJsonKey = yield* executor + .ingest(tenant, "product_events", [{ org_id: "org_victim" }]) + .pipe(Effect.flip) yield* executor.ingest(tenant, "traces", [{ resource_attributes: { maple_org_id: "org_test" } }]) + yield* executor.ingest(tenant, "product_events", [{ org_id: "org_test" }]) assert.strictEqual(error._tag, "@maple/http/errors/WarehouseScopeError") assert.strictEqual(missing._tag, "@maple/http/errors/WarehouseScopeError") assert.strictEqual(foreignResource._tag, "@maple/http/errors/WarehouseScopeError") - assert.deepStrictEqual(inserted, [[{ resource_attributes: { maple_org_id: "org_test" } }]]) + assert.strictEqual(foreignJsonKey._tag, "@maple/http/errors/WarehouseScopeError") + assert.deepStrictEqual(inserted, [ + [{ resource_attributes: { maple_org_id: "org_test" } }], + [{ org_id: "org_test" }], + ]) }), ) diff --git a/packages/query-engine/src/execution/executor.ts b/packages/query-engine/src/execution/executor.ts index 84606d46b0..1ffa69806a 100644 --- a/packages/query-engine/src/execution/executor.ts +++ b/packages/query-engine/src/execution/executor.ts @@ -169,8 +169,11 @@ const isRecord = (value: unknown): value is Record => */ const rowBelongsTo = (row: unknown, orgId: string): boolean => { if (!isRecord(row)) return false + // `org_id` is the ingestion-JSON key of the datasources that map OrgId by jsonPath + // (session_replays, product_events); every tag a row carries must match. const tags = [ row.OrgId, + row.org_id, isRecord(row.resource_attributes) ? row.resource_attributes.maple_org_id : undefined, ].filter((tag) => tag !== undefined) return tags.length > 0 && tags.every((tag) => tag === orgId) From 10009c6f4e7310ad386d25c8f694492972a08f7a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 11:54:42 +0100 Subject: [PATCH 08/14] fix(web): let the Events header wrap on a phone The kind filter and a fixed-width search field overflowed a 390px panel; the row now wraps and the field takes the full width until sm. --- apps/web/src/page/product-analytics/view-events.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts index d7532c8d86..52efab00f2 100644 --- a/apps/web/src/page/product-analytics/view-events.ts +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -236,7 +236,7 @@ export const eventsTab = (h: H, model: Model): Html => panel(h, {}, [ panelHeader(h, { title: "All events", - action: hstack(h, { gap: "2" }, [ + action: hstack(h, { gap: "2", wrap: true }, [ segmentedSelect(h, { options: KIND_FILTERS, value: model.eventsKind, @@ -250,7 +250,7 @@ export const eventsTab = (h: H, model: Model): Html => ariaLabel: "Filter events", onInput: (value) => Message.ChangedEventsQuery({ value }), onClear: Message.ChangedEventsQuery({ value: "" }), - className: "w-48", + className: "w-full sm:w-48", }), ]), }), From 43e0c8340a8ebe0152254279250210e519694745 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:01:20 +0100 Subject: [PATCH 09/14] fix(web): the steps table follows the funnel view, not leftover rows Switching Drop-off to Bars kept the timing and leaver columns until the new answer arrived, because they followed the rows on screen. --- apps/web/src/page/product-analytics/view-funnels.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index 227b04d318..cee7dbd6cb 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -274,7 +274,7 @@ const results = (h: H, model: Model): Html => }), panelChartBody(h, [chart(h, model, rows, isDropoff)]), ]), - panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy, hasDetails)]), + panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy, isDropoff)]), ]) }, }) From e5ae73fb2dfd4c0036e53919f3c95f8df1be4d9d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:07:22 +0100 Subject: [PATCH 10/14] fix(web): a route change from outside cancels a pending typing pause Back to an entry with the same steps but another window kept the pause, which then wrote the half-typed step into that entry. The page now remembers the URL it asked for; any other route change cancels the pause, while its own write still leaves a newer pause running. --- apps/web/src/page/product-analytics/model.ts | 2 ++ .../product-analytics.scene.test.ts | 28 +++++++++++++++++++ apps/web/src/page/product-analytics/update.ts | 20 +++++++++---- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/web/src/page/product-analytics/model.ts b/apps/web/src/page/product-analytics/model.ts index cd69ebd55a..f1249b75ee 100644 --- a/apps/web/src/page/product-analytics/model.ts +++ b/apps/web/src/page/product-analytics/model.ts @@ -64,6 +64,8 @@ export const Model = Schema.Struct({ committed: Schema.String, /** Bumped by each typed edit; only the last one's pause commits. */ typingVersion: Schema.Number, + /** The URL this page last asked for: a route change to anything else came from outside. */ + navigatedTo: Schema.NullOr(Schema.String), stepCombos: Schema.Record(Schema.String, Combobox.Model), selects: Schema.Record(Schema.String, Select.Model), eventsQuery: Schema.String, diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index d103702bca..b5d3414bb4 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -195,6 +195,34 @@ describe("product analytics", () => { ) }) + test("Back to another window mid-typing cancels the pause; the page's own write does not", () => { + const start = resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "" }] }).model + const typed = typing(start, "sig") + const back = routeChanged( + typed.model, + { search: { tab: "funnels", steps: [{ kind: "event", eventName: "" }], timePreset: "24h" } }, + context, + ) + expect(back.model.steps).toEqual([{ kind: "event", eventName: "sig" }]) + expect( + pageUpdate(back.model, Message.PausedTyping({ version: typed.model.typingVersion })).outMessage, + ).toBeUndefined() + + const committed = pageUpdate( + typed.model, + Message.PausedTyping({ version: typed.model.typingVersion }), + ) + const more = typing(committed.model, "signup") + const landed = routeChanged( + more.model, + { search: { tab: "funnels", steps: [{ kind: "event", eventName: "sig" }] } }, + context, + ) + expect( + pageUpdate(landed.model, Message.PausedTyping({ version: more.model.typingVersion })).outMessage, + ).toBeDefined() + }) + test("a Page anchor with no path yet stays a Page anchor", () => { const picked = pageUpdate( resolved({ tab: "paths" }).model, diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 28ecfe9f62..83f2049b1a 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -78,7 +78,12 @@ const navigate = (model: Model, search: Search, isReplace = false): Return => { const committed = committedOf(search) const drafts = committed === committedOf(withDrafts(model)) ? { committed } : draftsOf(search) return { - model: { ...model, ...drafts, typingVersion: model.typingVersion + 1 }, + model: { + ...model, + ...drafts, + typingVersion: model.typingVersion + 1, + navigatedTo: searchHref(search), + }, outMessage: PageOutMessage.RequestedNavigation({ url: searchHref(search), isReplace }), } } @@ -158,6 +163,7 @@ export const init = (route: { readonly search: Search }, context: PageContext): paths: Keyed.idle(), ...draftsOf(route.search), typingVersion: 0, + navigatedTo: null, stepCombos: {}, selects: {}, eventsQuery: "", @@ -186,11 +192,15 @@ export const routeChanged = ( context: PageContext, ): Return => { if (searchHref(model.search) === searchHref(route.search)) return { model } - // A definition this page did not write (Back, a row's Funnel button) replaces the drafts. + // A definition this page did not write (Back, a row's Funnel button) replaces the drafts. Any + // route change the page did not ask for (Back to another window) cancels a pending typing pause. + const isOwn = searchHref(route.search) === model.navigatedTo const drafts = - committedOf(route.search) === model.committed - ? {} - : { ...draftsOf(route.search), typingVersion: model.typingVersion + 1 } + committedOf(route.search) !== model.committed + ? { ...draftsOf(route.search), typingVersion: model.typingVersion + 1 } + : isOwn + ? {} + : { typingVersion: model.typingVersion + 1 } const next: Model = { ...model, ...drafts, search: route.search } return routeWindowStep(model.search, route.search, { resolve: (): Return => ({ From 543fd858a47622d33b5472e4fbb117140e7e3677 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:13:45 +0100 Subject: [PATCH 11/14] fix(web): the paths tables list every row Both were cut to six rows while Share divided by the full total. Each is already bounded by Branches and Steps, so they now show them all. --- apps/web/src/page/product-analytics/view-paths.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/page/product-analytics/view-paths.ts b/apps/web/src/page/product-analytics/view-paths.ts index c447dadfac..074afd2fbd 100644 --- a/apps/web/src/page/product-analytics/view-paths.ts +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -6,7 +6,7 @@ import { type PathsDirection, } from "@maple/query-model" import { formatNumber, formatPercent } from "@maple/ui/lib/format" -import { Option } from "effect" +import { Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as PathsChart from "@maple/views/charts/paths-chart" @@ -139,7 +139,7 @@ const nextSteps = (rows: ReadonlyArray): ReadonlyArray => (row) => row.toNode, ) -/** Where a sequence stopped: the node a hop into `''` left from. */ +/** Where a sequence stopped: the node a hop into `''` left from. At most depth × (branches + 1) rows. */ const endings = (rows: ReadonlyArray): ReadonlyArray => tally( rows.filter((row) => row.toNode === ""), @@ -170,7 +170,7 @@ const tallyTable = ( ]), tableBody( h, - options.rows.slice(0, 6).map((row) => + Arr.map(options.rows, (row) => tableRow( h, [ From f510d992105d558bad18b60f2970068000486bdc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:14:48 +0100 Subject: [PATCH 12/14] fix(web): tell exactly 200 event names from more, and use the kit grid The events read asks for one name past the limit, so a complete 200-name answer no longer reads as capped. The paths tables sit in the kit grid. --- .../product-analytics.scene.test.ts | 21 ++++++ .../src/page/product-analytics/requests.ts | 3 +- .../src/page/product-analytics/view-events.ts | 17 +++-- .../src/page/product-analytics/view-paths.ts | 70 +++++++++---------- apps/web/src/page/product-analytics/view.ts | 5 +- 5 files changed, 69 insertions(+), 47 deletions(-) diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index b5d3414bb4..2e920aaffa 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -255,6 +255,27 @@ describe("product analytics", () => { ) }) + test("exactly 200 names is a full answer; one more says the totals are capped", () => { + const names = (count: number): ReadonlyArray => + Array.from({ length: count }, (_, index) => ({ + eventName: `event_${index}`, + kind: "custom", + count: 1, + sessions: 1, + persons: 1, + })) + scene( + { update: pageUpdate, view }, + given(settled(resolved().model, "events", names(200))), + expectView(text("200+")).not.toExist(), + ) + scene( + { update: pageUpdate, view }, + given(settled(resolved().model, "events", names(201))), + expectView(text("across the top 200 names")).toExist(), + ) + }) + test("an org without events is told where they come from", () => { scene( { update: pageUpdate, view }, diff --git a/apps/web/src/page/product-analytics/requests.ts b/apps/web/src/page/product-analytics/requests.ts index d14524074c..e8f002fe8e 100644 --- a/apps/web/src/page/product-analytics/requests.ts +++ b/apps/web/src/page/product-analytics/requests.ts @@ -25,7 +25,8 @@ const plan = (model: Model, window: TimeWindow): ReadonlyArray => { const { startTime, endTime } = window const breakdownsInput = { startTime, endTime, limitPerDimension: BREAKDOWN_LIMIT, ...population } const events = (slot: SlotName, range: TimeWindow): Request => { - const input = { ...range, limit: EVENTS_LIMIT, ...population } + // One past the limit, so a full answer can tell "exactly 200" from "more than 200". + const input = { ...range, limit: EVENTS_LIMIT + 1, ...population } return { slot, fn: "getProductEventNames", diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts index 52efab00f2..c2d8c2c2b8 100644 --- a/apps/web/src/page/product-analytics/view-events.ts +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -70,10 +70,8 @@ const kindSummary = (rows: ReadonlyArray): string => { ].join(" · ") } -const stats = (h: H, model: Model, rows: ReadonlyArray): Html => { +const stats = (h: H, model: Model, rows: ReadonlyArray, isCapped: boolean): Html => { const total = sum(rows) - // The read stops at EVENTS_LIMIT names: past that, totals and the trend would undercount. - const isCapped = rows.length >= EVENTS_LIMIT const top = rows .filter(isCustomEvent) .reduce( @@ -223,8 +221,12 @@ export const eventsTab = (h: H, model: Model): Html => title: "Couldn't load product events", onRetry: Message.ClickedRetry({ slot: "events" }), }), - onData: (rows) => - rows.length === 0 + onData: (answer) => { + // The read asks for one name past EVENTS_LIMIT: if it came back, totals and the trend + // would undercount, so the page says so instead. + const isCapped = answer.length > EVENTS_LIMIT + const rows = answer.slice(0, EVENTS_LIMIT) + return rows.length === 0 ? emptyPanel(h, "No product events in this window", [ "Page views arrive with web analytics. Custom events come from track() in the browser SDK, POST /v1/events from a backend, or an annotated span.", hstack(h, { justify: "center", className: "mt-3" }, [ @@ -232,7 +234,7 @@ export const eventsTab = (h: H, model: Model): Html => ]), ]) : vstack(h, { gap: "4" }, [ - stats(h, model, rows), + stats(h, model, rows, isCapped), panel(h, {}, [ panelHeader(h, { title: "All events", @@ -257,5 +259,6 @@ export const eventsTab = (h: H, model: Model): Html => eventsTable(h, model, rows), sendYourOwn(h), ]), - ]), + ]) + }, }) diff --git a/apps/web/src/page/product-analytics/view-paths.ts b/apps/web/src/page/product-analytics/view-paths.ts index 074afd2fbd..574b1482f0 100644 --- a/apps/web/src/page/product-analytics/view-paths.ts +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -15,6 +15,7 @@ import { emptyMessage } from "@maple/views/ui/empty" import { errorState } from "@maple/views/ui/error-state" import { icon } from "@maple/views/ui/icon" import { panel, panelChartBody, panelHeader } from "@maple/views/ui/panel" +import { grid } from "@maple/views/ui/grid" import { hstack, vstack } from "@maple/views/ui/stack" import { numericTableCell, @@ -263,44 +264,37 @@ const results = (h: H, model: Model): Html => { }), ]), ]), - h.div( - [h.Class("grid gap-4 lg:grid-cols-2")], - [ - panel(h, {}, [ - panelHeader(h, { - title: - paths.direction === "after" - ? "Most common next step" - : "Most common step before", - }), - tallyTable(h, model, { - label: "Event or page", - rows: nextSteps(rows), - total, - empty: - paths.direction === "after" ? "Nothing followed" : "Nothing came before", - }), - ]), - panel(h, {}, [ - // Walking back from the anchor, a sequence that runs out is where it began. - panelHeader(h, { - title: - paths.direction === "after" - ? "Where sequences ended" - : "Where sequences started", - }), - tallyTable(h, model, { - label: - paths.direction === "after" - ? "Last event or page" - : "First event or page", - rows: endings(rows), - total, - empty: "Every sequence ran the full number of steps", - }), - ]), - ], - ), + grid(h, { lg: 2, gap: "4" }, [ + panel(h, {}, [ + panelHeader(h, { + title: + paths.direction === "after" + ? "Most common next step" + : "Most common step before", + }), + tallyTable(h, model, { + label: "Event or page", + rows: nextSteps(rows), + total, + empty: paths.direction === "after" ? "Nothing followed" : "Nothing came before", + }), + ]), + panel(h, {}, [ + // Walking back from the anchor, a sequence that runs out is where it began. + panelHeader(h, { + title: + paths.direction === "after" + ? "Where sequences ended" + : "Where sequences started", + }), + tallyTable(h, model, { + label: paths.direction === "after" ? "Last event or page" : "First event or page", + rows: endings(rows), + total, + empty: "Every sequence ran the full number of steps", + }), + ]), + ]), ]) }, }) diff --git a/apps/web/src/page/product-analytics/view.ts b/apps/web/src/page/product-analytics/view.ts index bf8b98d34c..11c00ec608 100644 --- a/apps/web/src/page/product-analytics/view.ts +++ b/apps/web/src/page/product-analytics/view.ts @@ -13,6 +13,7 @@ import { linkTabStrip } from "../kit/underline-tabs" import { populationOf, type Tab, TABS, tabOf } from "./definition" import { Message } from "./message" import type { Model } from "./model" +import { EVENTS_LIMIT } from "./requests" import { DEFAULT_PRESET, withDrafts } from "./update" import { eventsTab } from "./view-events" import { funnelsTab } from "./view-funnels" @@ -27,6 +28,8 @@ const TAB_LABEL = { events: "Events", funnels: "Funnels", paths: "Paths" } satis const tabs = (h: H, model: Model): Html => { const active = tabOf(model.search) const eventCount = Option.getOrUndefined(AsyncData.getData(model.events.data))?.length + const shownCount = + eventCount === undefined ? undefined : eventCount > EVENTS_LIMIT ? `${EVENTS_LIMIT}+` : eventCount return linkTabStrip( h, "Product analytics views", @@ -38,7 +41,7 @@ const tabs = (h: H, model: Model): Html => { }), ), label: TAB_LABEL[tab], - count: tab === "events" ? eventCount : undefined, + count: tab === "events" ? shownCount : undefined, isCurrent: tab === active, isRouteActive: tab === active, })), From 9a46fb5d9a34bda08f73bff8212b2818294412cc Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 3 Nov 2026 12:23:59 +0100 Subject: [PATCH 13/14] fix(web): funnel refetch shows loading, Back restarts the typing pause, names trimmed - A Count by or view change no longer draws the previous counts under the new labels: a refetching funnel shows its loading state, as paths does. - Back to another window mid-typing restarts the typing pause, so the typed step lands in the entry now showing instead of never running. - Step and anchor names are trimmed before they reach the query. - The Top custom event tile says when it only saw the top 200 names. --- .../src/page/product-analytics/definition.ts | 16 ++++- .../product-analytics.scene.test.ts | 16 ++++- apps/web/src/page/product-analytics/update.ts | 22 +++--- .../src/page/product-analytics/view-events.ts | 4 +- .../page/product-analytics/view-funnels.ts | 70 ++++++++++--------- 5 files changed, 82 insertions(+), 46 deletions(-) diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts index 7b4694b324..d00196f082 100644 --- a/apps/web/src/page/product-analytics/definition.ts +++ b/apps/web/src/page/product-analytics/definition.ts @@ -58,7 +58,7 @@ export const funnelOf = (search: Search): FunnelDefinition => ({ /** The funnel's widget params, or `null` until a step has something to match on. */ export const funnelParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { const funnel = funnelOf(search) - const steps = completedSteps(funnel.steps) + const steps = completedSteps(funnel.steps).map(trimmed) if (steps.length === 0) return null return { ...populationOf(search), @@ -96,13 +96,25 @@ export const pathsParamsOf = (search: Search): ProductEventsPathsWidgetParams | if (anchorNameOf(paths.anchor) === "") return null return { ...populationOf(search), - anchor: paths.anchor, + anchor: trimmed(paths.anchor), direction: paths.direction, depth: paths.depth, branches: paths.branches, } } +/** A step as the query matches it: names typed with stray spaces still find their events. */ +const trimmed = (step: S): S => { + switch (step.kind) { + case "event": + return { ...step, eventName: step.eventName.trim() } + case "page": + return { ...step, pagePath: step.pagePath.trim() } + case "session": + return { ...step, value: step.value.trim() } + } +} + /** A draft step as the URL keeps it: no typed attribute filter (this page offers none). */ export const toFunnelStep = (draft: StepDraft): FunnelStep => { if (draft.kind !== "event") return draft diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index 2e920aaffa..7f8ca0ad0c 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -88,6 +88,14 @@ describe("product analytics", () => { }) }) + test("names typed with stray spaces still match their events", () => { + const run = resolved({ + tab: "funnels", + steps: [{ kind: "event", eventName: "signup " }], + }).commands?.find(({ name }) => name === "FetchProductAnalyticsFunnel") + expect(run?.args).toMatchObject({ input: { steps: [{ kind: "event", eventName: "signup" }] } }) + }) + test("paths wait for an anchor", () => { expect(names(resolved({ tab: "paths" }).commands)).not.toContain("FetchProductAnalyticsPaths") const run = resolved({ @@ -195,7 +203,7 @@ describe("product analytics", () => { ) }) - test("Back to another window mid-typing cancels the pause; the page's own write does not", () => { + test("Back to another window mid-typing restarts the pause; the page's own write keeps it", () => { const start = resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "" }] }).model const typed = typing(start, "sig") const back = routeChanged( @@ -207,6 +215,12 @@ describe("product analytics", () => { expect( pageUpdate(back.model, Message.PausedTyping({ version: typed.model.typingVersion })).outMessage, ).toBeUndefined() + expect(names(back.commands)).toContain("WaitForProductAnalyticsTypingPause") + const landed24h = pageUpdate(back.model, Message.PausedTyping({ version: back.model.typingVersion })) + expect(searchOf(landed24h.outMessage)).toMatchObject({ + timePreset: "24h", + steps: [{ kind: "event", eventName: "sig" }], + }) const committed = pageUpdate( typed.model, diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 83f2049b1a..ecf76f1125 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -193,22 +193,28 @@ export const routeChanged = ( ): Return => { if (searchHref(model.search) === searchHref(route.search)) return { model } // A definition this page did not write (Back, a row's Funnel button) replaces the drafts. Any - // route change the page did not ask for (Back to another window) cancels a pending typing pause. + // other route change the page did not ask for (Back to another window) restarts a pending typing + // pause, so a half-typed step lands in the entry now showing, once typing stops. const isOwn = searchHref(route.search) === model.navigatedTo - const drafts = - committedOf(route.search) !== model.committed - ? { ...draftsOf(route.search), typingVersion: model.typingVersion + 1 } - : isOwn - ? {} - : { typingVersion: model.typingVersion + 1 } + const isNewDefinition = committedOf(route.search) !== model.committed + const typingVersion = model.typingVersion + 1 + const drafts = isNewDefinition + ? { ...draftsOf(route.search), typingVersion } + : isOwn + ? {} + : { typingVersion } const next: Model = { ...model, ...drafts, search: route.search } - return routeWindowStep(model.search, route.search, { + const isAhead = !isNewDefinition && !isOwn && committedOf(withDrafts(next)) !== committedOf(route.search) + const step = routeWindowStep(model.search, route.search, { resolve: (): Return => ({ model: next, commands: [resolveCommand(next, context, snapModeOf(model.refreshVersion))], }), requestAll: (mode) => withRequests(next, context, mode), }) + return isAhead + ? { ...step, commands: [...(step.commands ?? []), WaitForTypingPause({ version: typingVersion })] } + : step } export const contextChanged = (model: Model, context: PageContext): Return => diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts index c2d8c2c2b8..b97b74c1f2 100644 --- a/apps/web/src/page/product-analytics/view-events.ts +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -97,7 +97,9 @@ const stats = (h: H, model: Model, rows: ReadonlyArray, isCapped: bool subline: top === undefined ? undefined - : `${formatNumber(top.persons)} ${pluralize(top.persons, "person", "people")}`, + : `${formatNumber(top.persons)} ${pluralize(top.persons, "person", "people")}${ + isCapped ? ` · of the top ${EVENTS_LIMIT} names` : "" + }`, }), ]) } diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index cee7dbd6cb..11f3cf959b 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -243,41 +243,43 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, ha } const results = (h: H, model: Model): Html => - AsyncData.matchData(model.funnel.data, { - onEmpty: () => statRailLoading(h, 4, 4), - onFailure: (failure) => - errorState(h, { - failure, - title: "Couldn't run the funnel", - onRetry: Message.ClickedRetry({ slot: "funnel" }), - }), - onData: (rows) => { - const { keyBy, variant } = funnelOf(model.search) - // A bars answer still on screen while the drop-off details load draws as bars. - const hasDetails = rows - .slice(1) - .some((row) => row.p50Ms !== undefined || row.leavers !== undefined) - const isDropoff = variant === "dropoff" && (rows.length < 2 || hasDetails) - const entered = rows[0]?.value ?? 0 - if (entered === 0) - return emptyPanel(h, "Nobody reached step 1 in this window", [ - "Check the first step's name, widen the time range, or clear the filters.", - ]) - return vstack(h, { gap: "4" }, [ - stats(h, rows), - panel(h, {}, [ - panelHeader(h, { - title: isDropoff ? "Drop-off" : "Conversion", - scope: typography(h, { size: "2xs", tone: "muted" }, [ - `${formatNumber(entered)} ${countNoun(keyBy, entered)} entered`, - ]), + // A refetch after a Count by or view change would draw the old counts under the new labels. + AsyncData.isRefreshing(model.funnel.data) + ? statRailLoading(h, 4, 4) + : AsyncData.matchData(model.funnel.data, { + onEmpty: () => statRailLoading(h, 4, 4), + onFailure: (failure) => + errorState(h, { + failure, + title: "Couldn't run the funnel", + onRetry: Message.ClickedRetry({ slot: "funnel" }), }), - panelChartBody(h, [chart(h, model, rows, isDropoff)]), - ]), - panel(h, {}, [panelHeader(h, { title: "Steps" }), stepsTable(h, rows, keyBy, isDropoff)]), - ]) - }, - }) + onData: (rows) => { + const { keyBy, variant } = funnelOf(model.search) + const isDropoff = variant === "dropoff" + const entered = rows[0]?.value ?? 0 + if (entered === 0) + return emptyPanel(h, "Nobody reached step 1 in this window", [ + "Check the first step's name, widen the time range, or clear the filters.", + ]) + return vstack(h, { gap: "4" }, [ + stats(h, rows), + panel(h, {}, [ + panelHeader(h, { + title: isDropoff ? "Drop-off" : "Conversion", + scope: typography(h, { size: "2xs", tone: "muted" }, [ + `${formatNumber(entered)} ${countNoun(keyBy, entered)} entered`, + ]), + }), + panelChartBody(h, [chart(h, model, rows, isDropoff)]), + ]), + panel(h, {}, [ + panelHeader(h, { title: "Steps" }), + stepsTable(h, rows, keyBy, isDropoff), + ]), + ]) + }, + }) export const funnelsTab = (h: H, model: Model): Html => vstack(h, { gap: "4" }, [ From 477ddce8a09f319f2b91679ffb011285eb4be19d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 14 Nov 2026 01:29:20 +0100 Subject: [PATCH 14/14] chore(lint): product analytics page on the effect collection helpers --- apps/web/src/lab/entries/product-analytics.ts | 10 +-- .../web/src/page/product-analytics/command.ts | 12 ++-- .../src/page/product-analytics/definition.ts | 23 ++++--- .../product-analytics.scene.test.ts | 31 +++++---- .../src/page/product-analytics/requests.ts | 33 +++++---- apps/web/src/page/product-analytics/update.ts | 8 +-- .../src/page/product-analytics/view-events.ts | 68 +++++++++++-------- .../page/product-analytics/view-funnels.ts | 44 +++++++----- .../src/page/product-analytics/view-parts.ts | 12 ++-- .../src/page/product-analytics/view-paths.ts | 28 +++++--- apps/web/src/page/product-analytics/view.ts | 4 +- .../src/services/org/demo/product.test.ts | 35 +++++++--- .../backend/src/services/org/demo/product.ts | 33 +++++---- 13 files changed, 204 insertions(+), 137 deletions(-) diff --git a/apps/web/src/lab/entries/product-analytics.ts b/apps/web/src/lab/entries/product-analytics.ts index f7570da950..ac8236970c 100644 --- a/apps/web/src/lab/entries/product-analytics.ts +++ b/apps/web/src/lab/entries/product-analytics.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { Effect, Schema } from "effect" +import { Array as Arr, Effect, Schema, String as Str } from "effect" import { AsyncData } from "foldkit" import { toLoadFailure } from "@maple/views/data/load-failure" import type { PageContext } from "../../page/contract" @@ -46,7 +46,7 @@ const EVENTS: ReadonlyArray = [ { eventName: "plan_started", kind: "custom", count: 61, sessions: 61, persons: 61 }, ] -const PREVIOUS: ReadonlyArray = EVENTS.map((row) => ({ +const PREVIOUS: ReadonlyArray = Arr.map(EVENTS, (row) => ({ ...row, count: Math.round(row.count * 0.89), })) @@ -138,7 +138,7 @@ const BREAKDOWNS: Breakdowns = { ], } -const SIGNUP_TO_PAID: Search["steps"] = FUNNEL.map((row) => ({ kind: "event", eventName: row.name })) +const SIGNUP_TO_PAID: Search["steps"] = Arr.map(FUNNEL, (row) => ({ kind: "event", eventName: row.name })) const searchOf = (scenario: Scenario): Search => { switch (scenario) { @@ -188,11 +188,11 @@ const init = (flags: typeof Flags.Type) => { /** Reads and the window never leave the lab; everything local (popovers, typing pauses) runs. */ const isLocalCommand = (command: { readonly name: string }): boolean => - !command.name.startsWith("Fetch") && command.name !== "ResolveProductAnalyticsWindow" + !Str.startsWith("Fetch")(command.name) && command.name !== "ResolveProductAnalyticsWindow" const update = (model: Model, message: Message) => { const next = pageUpdate(model, message, context) - return { model: next.model, commands: (next.commands ?? []).filter(isLocalCommand) } + return { model: next.model, commands: Arr.filter(next.commands ?? [], isLocalCommand) } } const Flags = Schema.Struct({ scenario: Scenario }) diff --git a/apps/web/src/page/product-analytics/command.ts b/apps/web/src/page/product-analytics/command.ts index a04ab9fbd7..9dbebfc473 100644 --- a/apps/web/src/page/product-analytics/command.ts +++ b/apps/web/src/page/product-analytics/command.ts @@ -7,7 +7,7 @@ import { import { getWebAnalyticsBreakdowns, WebAnalyticsFilterFields } from "@maple/web-data/warehouse/web-analytics" import { Clock, Duration, Effect, Schema } from "effect" import { Command } from "foldkit" -import { toLoadFailure } from "@maple/views/data/load-failure" +import { settleTo } from "@maple/views/data/load-failure" import { resolveWindow } from "../../data/time-range" import { Message, RequestMode, SlotName } from "./message" @@ -34,20 +34,16 @@ export const ResolveWindow = Command.define("ResolveProductAnalyticsWindow", { const Window = { startTime: Schema.String, endTime: Schema.String } -const define = ( +const define = ( name: string, fields: Fields, - run: (input: Schema.Struct["Type"]) => Effect.Effect, + run: (input: Schema.Struct["Type"]) => Effect.Effect, ) => Command.define(name, { args: { slot: SlotName, key: Schema.String, input: Schema.Struct(fields) }, messages: [Message.CompletedFetch], execute: ({ slot, key, input }) => - run(input).pipe( - Effect.mapError(toLoadFailure), - Effect.result, - Effect.map((result) => Message.CompletedFetch({ slot, key, result })), - ), + run(input).pipe(settleTo((result) => Message.CompletedFetch({ slot, key, result }))), }) export const FetchBreakdowns = define( diff --git a/apps/web/src/page/product-analytics/definition.ts b/apps/web/src/page/product-analytics/definition.ts index 549e3ee706..a6747ee47d 100644 --- a/apps/web/src/page/product-analytics/definition.ts +++ b/apps/web/src/page/product-analytics/definition.ts @@ -13,6 +13,7 @@ import { type ProductEventsFunnelWidgetParams, type ProductEventsPathsWidgetParams, } from "@maple/query-model" +import { Array as Arr, Option, Record as Rec, String as Str } from "effect" import { completedSteps, DEFAULT_FUNNEL_KEY_BY, @@ -28,13 +29,17 @@ export type Search = typeof ProductAnalyticsSearch.Type export const TABS = ["events", "funnels", "paths"] as const export type Tab = (typeof TABS)[number] -export const tabOf = (search: Search): Tab => TABS.find((tab) => tab === search.tab) ?? "events" +export const tabOf = (search: Search): Tab => + Option.getOrElse( + Arr.findFirst(TABS, (tab) => tab === search.tab), + (): Tab => "events", + ) /** The session filters every read on the page takes, unset ones left out. */ export const populationOf = (search: Search): FunnelPopulationFilters => - Object.fromEntries( - FUNNEL_POPULATION_FILTER_FIELDS.flatMap((field) => - search[field] === undefined ? [] : [[field, search[field]]], + Rec.fromEntries( + Arr.flatMap(FUNNEL_POPULATION_FILTER_FIELDS, (field) => + search[field] === undefined ? [] : [[field, search[field]] as const], ), ) @@ -58,7 +63,7 @@ export const funnelOf = (search: Search): FunnelDefinition => ({ /** The funnel's widget params, or `null` until a step has something to match on. */ export const funnelParamsOf = (search: Search): ProductEventsFunnelWidgetParams | null => { const funnel = funnelOf(search) - const steps = completedSteps(funnel.steps).map(trimmed) + const steps = Arr.map(completedSteps(funnel.steps), trimmed) if (steps.length === 0) return null return { ...populationOf(search), @@ -88,7 +93,7 @@ export const pathsOf = (search: Search): PathsDefinition => ({ /** The event or page an anchor names, trimmed; `""` while it names nothing. */ export const anchorNameOf = (anchor: PathsAnchor): string => - (anchor.kind === "event" ? anchor.eventName : anchor.pagePath).trim() + Str.trim(anchor.kind === "event" ? anchor.eventName : anchor.pagePath) /** The paths widget params, or `null` until the anchor names an event or a page. */ export const pathsParamsOf = (search: Search): ProductEventsPathsWidgetParams | null => { @@ -107,11 +112,11 @@ export const pathsParamsOf = (search: Search): ProductEventsPathsWidgetParams | const trimmed = (step: S): S => { switch (step.kind) { case "event": - return { ...step, eventName: step.eventName.trim() } + return { ...step, eventName: Str.trim(step.eventName) } case "page": - return { ...step, pagePath: step.pagePath.trim() } + return { ...step, pagePath: Str.trim(step.pagePath) } case "session": - return { ...step, value: step.value.trim() } + return { ...step, value: Str.trim(step.value) } } } diff --git a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts index 7f8ca0ad0c..eeec7ef078 100644 --- a/apps/web/src/page/product-analytics/product-analytics.scene.test.ts +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -1,5 +1,5 @@ import { OrgId } from "@maple/domain" -import { Option, Result, Schema } from "effect" +import { Array as Arr, Option, Result, Schema } from "effect" import { fromString } from "foldkit/url" import { expect as expectView, given, Mount, scene, text, withViewInputs } from "foldkit/scene" import * as Chart from "@maple/views/charts/chart" @@ -21,7 +21,11 @@ const window = { startTime: "2026-10-27 00:00:00", endTime: "2026-11-03 00:00:00 const pageUpdate = (model: Model, message: Message) => update(model, message, context) const view = withViewInputs(pageView, { context })() const names = (commands: ReadonlyArray<{ readonly name: string }> | undefined) => - (commands ?? []).map(({ name }) => name) + Arr.map(commands ?? [], ({ name }) => name) +const sentAs = ( + commands: ReadonlyArray | undefined, + name: string, +): C | undefined => Option.getOrUndefined(Arr.findFirst(commands ?? [], (command) => command.name === name)) const resolved = (search: Search = {}) => pageUpdate(init({ search }, context).model, Message.CompletedResolveWindow({ window, mode: "Load" })) @@ -74,14 +78,15 @@ describe("product analytics", () => { test("the Events tab reads the rail's options and this window's and the previous window's events", () => { const sent = names(resolved().commands) expect(sent).toContain("FetchProductAnalyticsBreakdowns") - expect(sent.filter((name) => name === "FetchProductEventNames")).toHaveLength(2) + expect(Arr.filter(sent, (name) => name === "FetchProductEventNames")).toHaveLength(2) expect(sent).not.toContain("FetchProductAnalyticsFunnel") }) test("a funnel runs only once a step names something, with the drop-off details by default", () => { expect(names(resolved({ tab: "funnels" }).commands)).not.toContain("FetchProductAnalyticsFunnel") - const run = resolved({ tab: "funnels", steps: SIGNUP, country: "DE" }).commands?.find( - ({ name }) => name === "FetchProductAnalyticsFunnel", + const run = sentAs( + resolved({ tab: "funnels", steps: SIGNUP, country: "DE" }).commands, + "FetchProductAnalyticsFunnel", ) expect(run?.args).toMatchObject({ input: { steps: SIGNUP, keyBy: "person", windowSeconds: 86_400, details: true, country: "DE" }, @@ -89,19 +94,19 @@ describe("product analytics", () => { }) test("names typed with stray spaces still match their events", () => { - const run = resolved({ - tab: "funnels", - steps: [{ kind: "event", eventName: "signup " }], - }).commands?.find(({ name }) => name === "FetchProductAnalyticsFunnel") + const run = sentAs( + resolved({ tab: "funnels", steps: [{ kind: "event", eventName: "signup " }] }).commands, + "FetchProductAnalyticsFunnel", + ) expect(run?.args).toMatchObject({ input: { steps: [{ kind: "event", eventName: "signup" }] } }) }) test("paths wait for an anchor", () => { expect(names(resolved({ tab: "paths" }).commands)).not.toContain("FetchProductAnalyticsPaths") - const run = resolved({ - tab: "paths", - anchor: { kind: "event", eventName: "signup_completed" }, - }).commands?.find(({ name }) => name === "FetchProductAnalyticsPaths") + const run = sentAs( + resolved({ tab: "paths", anchor: { kind: "event", eventName: "signup_completed" } }).commands, + "FetchProductAnalyticsPaths", + ) expect(run?.args).toMatchObject({ input: { direction: "after", depth: 3, branches: 4 } }) }) diff --git a/apps/web/src/page/product-analytics/requests.ts b/apps/web/src/page/product-analytics/requests.ts index 8125d5fb56..1f693e3b18 100644 --- a/apps/web/src/page/product-analytics/requests.ts +++ b/apps/web/src/page/product-analytics/requests.ts @@ -1,4 +1,4 @@ -import { Option } from "effect" +import { Array as Arr, Option } from "effect" import { Command } from "foldkit" import * as Keyed from "@maple/views/data/keyed" import { requestKey } from "@maple/views/data/request-key" @@ -94,19 +94,18 @@ export const requestSlots = ( mode: Keyed.RequestMode, only?: ReadonlyArray, ): { readonly model: Model; readonly commands: ReadonlyArray } => - plan(model, window) - .filter((request) => only === undefined || only.includes(request.slot)) - .reduce<{ model: Model; commands: ReadonlyArray }>( - (state, request) => { - const key = requestKey(context.orgId, request.fn, { data: request.input }) - const current: Keyed.Keyed = state.model[request.slot] - return Option.match(Keyed.request(current, key, mode), { - onNone: () => state, - onSome: (slot) => ({ - model: { ...state.model, [request.slot]: slot }, - commands: [...state.commands, request.command(key)], - }), - }) - }, - { model, commands: [] }, - ) + Arr.reduce }>( + Arr.filter(plan(model, window), (request) => only === undefined || only.includes(request.slot)), + { model, commands: [] }, + (state, request) => { + const key = requestKey(context.orgId, request.fn, { data: request.input }) + const current: Keyed.Keyed = state.model[request.slot] + return Option.match(Keyed.request(current, key, mode), { + onNone: () => state, + onSome: (slot) => ({ + model: { ...state.model, [request.slot]: slot }, + commands: [...state.commands, request.command(key)], + }), + }) + }, + ) diff --git a/apps/web/src/page/product-analytics/update.ts b/apps/web/src/page/product-analytics/update.ts index 83acb18f99..f21bf28787 100644 --- a/apps/web/src/page/product-analytics/update.ts +++ b/apps/web/src/page/product-analytics/update.ts @@ -1,5 +1,5 @@ import { FUNNEL_MAX_STEPS, FUNNEL_POPULATION_FILTER_FIELDS } from "@maple/query-model" -import { Option, Result, Schema, Struct } from "effect" +import { Array as Arr, Option, Result, Schema, Struct } from "effect" import { Command } from "foldkit" import * as Chart from "@maple/views/charts/chart" import * as Keyed from "@maple/views/data/keyed" @@ -45,7 +45,7 @@ const decodeFailure: LoadFailure = toLoadFailure("Couldn't read the response") /** Folds an answer into every slot that asked under its key, decoded with that slot's schema. */ const settleSlots = (model: Model, key: string, result: Result.Result): Model => - SlotName.literals.reduce((next, name) => { + Arr.reduce(SlotName.literals, model, (next, name) => { if (next[name].key !== key) return next const decoded = Result.match(result, { onFailure: (failure) => Result.fail(failure), @@ -57,7 +57,7 @@ const settleSlots = (model: Model, key: string, result: Result.Result = next[name] return { ...next, [name]: Keyed.accept(slot, key, decoded) } - }, model) + }) const resolveCommand = (model: Model, context: PageContext, mode: RequestMode): Cmd => ResolveWindow({ @@ -107,7 +107,7 @@ const draftsOf = (search: Search): Pick /** The search with the drafts written in, so a tab or filter change carries what is being typed. */ export const withDrafts = (model: Model): Search => { - const steps = model.steps.map(toFunnelStep) + const steps = Arr.map(model.steps, toFunnelStep) const anchor = toAnchor(model.anchor) return { ...model.search, diff --git a/apps/web/src/page/product-analytics/view-events.ts b/apps/web/src/page/product-analytics/view-events.ts index b97b74c1f2..9a844480d6 100644 --- a/apps/web/src/page/product-analytics/view-events.ts +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -1,5 +1,5 @@ import { formatNumber, pluralize } from "@maple/ui/lib/format" -import { Option } from "effect" +import { Array as Arr, Option, Order, String as Str } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { button } from "@maple/views/ui/button" @@ -28,7 +28,7 @@ import { statRail, statRailItem, statRailLoading } from "../kit/stat-rail" import { Message } from "./message" import { type EventKindFilter, type EventRow, isCustomEvent, type Model } from "./model" import { EVENTS_LIMIT } from "./requests" -import { emptyPanel } from "./view-parts" +import { emptyPanel, mostPeopleFirst } from "./view-parts" // The Events tab: every event name in the window, and the way into a funnel or paths from one. @@ -48,7 +48,7 @@ const KIND_FILTERS: ReadonlyArray<{ value: EventKindFilter; label: string }> = [ { value: "screen", label: "Screens" }, ] -const sum = (rows: ReadonlyArray): number => rows.reduce((total, row) => total + row.count, 0) +const sum = (rows: ReadonlyArray): number => Arr.reduce(rows, 0, (total, row) => total + row.count) /** `+12% vs previous period`, or nothing when the previous window is empty or still loading. */ const deltaOf = (model: Model, current: number): string | undefined => { @@ -60,24 +60,26 @@ const deltaOf = (model: Model, current: number): string | undefined => { } const kindSummary = (rows: ReadonlyArray): string => { - const pages = rows.filter((row) => row.kind === "navigation").length - const screens = rows.filter((row) => row.kind === "screen").length + const pages = Arr.filter(rows, (row) => row.kind === "navigation").length + const screens = Arr.filter(rows, (row) => row.kind === "screen").length const custom = rows.length - pages - screens - return [ - `${custom} custom`, - ...(pages > 0 ? [`${pages} ${pluralize(pages, "page view")}`] : []), - ...(screens > 0 ? [`${screens} ${pluralize(screens, "screen")}`] : []), - ].join(" · ") + return Arr.join( + [ + `${custom} custom`, + ...(pages > 0 ? [`${pages} ${pluralize(pages, "page view")}`] : []), + ...(screens > 0 ? [`${screens} ${pluralize(screens, "screen")}`] : []), + ], + " · ", + ) } const stats = (h: H, model: Model, rows: ReadonlyArray, isCapped: boolean): Html => { const total = sum(rows) - const top = rows - .filter(isCustomEvent) - .reduce( - (best, row) => (best === undefined || row.persons > best.persons ? row : best), - undefined, - ) + const top = Arr.reduce( + Arr.filter(rows, isCustomEvent), + undefined, + (best, row) => (best === undefined || row.persons > best.persons ? row : best), + ) return statRail(h, { columns: 3 }, [ statRailItem(h, { eyebrow: "Events", @@ -104,18 +106,26 @@ const stats = (h: H, model: Model, rows: ReadonlyArray, isCapped: bool ]) } +/** Most people first, then most events. */ +const mostReachedFirst = Order.combine( + mostPeopleFirst, + Order.flip(Order.mapInput(Order.Number, (row: EventRow) => row.count)), +) + const visibleRows = (model: Model, rows: ReadonlyArray): ReadonlyArray => { - const query = model.eventsQuery.trim().toLowerCase() - return rows - .filter((row) => - model.eventsKind === "all" - ? true - : model.eventsKind === "custom" - ? isCustomEvent(row) - : row.kind === model.eventsKind, - ) - .filter((row) => query === "" || row.eventName.toLowerCase().includes(query)) - .toSorted((a, b) => b.persons - a.persons || b.count - a.count) + const query = Str.toLowerCase(Str.trim(model.eventsQuery)) + const ofKind = Arr.filter(rows, (row) => + model.eventsKind === "all" + ? true + : model.eventsKind === "custom" + ? isCustomEvent(row) + : row.kind === model.eventsKind, + ) + const matching = Arr.filter( + ofKind, + (row) => query === "" || Str.includes(query)(Str.toLowerCase(row.eventName)), + ) + return Arr.sort(matching, mostReachedFirst) } const rowActions = (h: H, row: EventRow): Html => @@ -152,7 +162,7 @@ const rowActions = (h: H, row: EventRow): Html => const eventsTable = (h: H, model: Model, rows: ReadonlyArray): Html => { const shown = visibleRows(model, rows) - const maxPersons = Math.max(1, ...rows.map((row) => row.persons)) + const maxPersons = Math.max(1, ...Arr.map(rows, (row) => row.persons)) if (shown.length === 0) return filteredEmpty(h, { noun: "events", onClear: Message.ClickedClearEventsFilters() }) return table(h, { variant: "bare", size: "sm", ariaLabel: "Product events" }, [ @@ -168,7 +178,7 @@ const eventsTable = (h: H, model: Model, rows: ReadonlyArray): Html => ]), tableBody( h, - shown.map((row) => { + Arr.map(shown, (row) => { const kind = kindOf(row) return tableRow( h, diff --git a/apps/web/src/page/product-analytics/view-funnels.ts b/apps/web/src/page/product-analytics/view-funnels.ts index 7f2ae19142..f66685ea99 100644 --- a/apps/web/src/page/product-analytics/view-funnels.ts +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -1,6 +1,6 @@ import { FUNNEL_MAX_STEPS, type FunnelKeyBy, type FunnelVariant } from "@maple/query-model" import { formatNumber, formatPercent } from "@maple/ui/lib/format" -import { Option } from "effect" +import { Array as Arr, Option } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import type * as Chart from "@maple/views/charts/chart" @@ -34,6 +34,7 @@ import { emptyPanel, eventSuggestions, labelledSegment, + mostPeopleFirst, pageSuggestions, } from "./view-parts" @@ -46,7 +47,10 @@ const VARIANTS: ReadonlyArray<{ value: FunnelVariant; label: string }> = [ { value: "dropoff", label: "Drop-off" }, ] -const WINDOWS = FUNNEL_WINDOW_OPTIONS.map((option) => ({ value: String(option.value), label: option.label })) +const WINDOWS = Arr.map(FUNNEL_WINDOW_OPTIONS, (option) => ({ + value: String(option.value), + label: option.label, +})) const editor = (h: H, model: Model): Html => { const funnel = funnelOf(model.search) @@ -99,13 +103,17 @@ const editor = (h: H, model: Model): Html => { /** Starting points from the events this org sends: the most reached custom events, two at a time. */ const suggestions = (h: H, model: Model): Html => { - const custom = Option.getOrElse(AsyncData.getData(model.events.data), () => []) - .filter(isCustomEvent) - .toSorted((a, b) => b.persons - a.persons) - .slice(0, 4) - const pairs = custom - .slice(1) - .map((row, index) => [custom[index]?.eventName ?? "", row.eventName] as const) + const custom = Arr.sort( + Arr.filter( + Option.getOrElse(AsyncData.getData(model.events.data), () => []), + isCustomEvent, + ), + mostPeopleFirst, + ).slice(0, 4) + const pairs = Arr.map( + custom.slice(1), + (row, index) => [custom[index]?.eventName ?? "", row.eventName] as const, + ) return emptyPanel(h, "Pick the steps people take, in order", [ "Each step is an event, a page view or, first step only, where a session came from.", ...(pairs.length === 0 @@ -115,7 +123,7 @@ const suggestions = (h: H, model: Model): Html => { hstack( h, { gap: "2", wrap: true, justify: "center", className: "mt-3" }, - pairs.map(([first, second]) => + Arr.map(pairs, ([first, second]) => button( h, { @@ -143,11 +151,15 @@ const suggestions = (h: H, model: Model): Html => { /** The step that loses the largest share of those who reached the one before it. */ const biggestDrop = (rows: ReadonlyArray): Readonly<{ name: string; share: number }> | null => - rows.slice(1).reduce | null>((worst, row, index) => { - const before = rows[index]?.value ?? 0 - const share = before === 0 ? 0 : 1 - row.value / before - return worst === null || share > worst.share ? { name: row.name, share } : worst - }, null) + Arr.reduce | null>( + rows.slice(1), + null, + (worst, row, index) => { + const before = rows[index]?.value ?? 0 + const share = before === 0 ? 0 : 1 - row.value / before + return worst === null || share > worst.share ? { name: row.name, share } : worst + }, + ) const stats = (h: H, rows: ReadonlyArray): Html => { const entered = rows[0]?.value ?? 0 @@ -200,7 +212,7 @@ const stepsTable = (h: H, rows: ReadonlyArray, keyBy: FunnelKeyBy, ha ]), tableBody( h, - rows.map((row, index) => { + Arr.map(rows, (row, index) => { const before = index === 0 ? 0 : (rows[index - 1]?.value ?? 0) const leaver = row.leavers?.[0] return tableRow( diff --git a/apps/web/src/page/product-analytics/view-parts.ts b/apps/web/src/page/product-analytics/view-parts.ts index 9cab560c45..c8307a34bc 100644 --- a/apps/web/src/page/product-analytics/view-parts.ts +++ b/apps/web/src/page/product-analytics/view-parts.ts @@ -1,6 +1,6 @@ import type { FunnelKeyBy } from "@maple/query-model" import { pluralize } from "@maple/ui/lib/format" -import { Option } from "effect" +import { Array as Arr, Option, Order } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { empty, emptyDescription, emptyHeader, emptyTitle } from "@maple/views/ui/empty" @@ -50,11 +50,15 @@ export const emptyPanel = (h: H, title: string, description: ReadonlyArray => Option.getOrElse(AsyncData.getData(model.events.data), () => []) +/** Events by the people who sent them, most first. */ +export const mostPeopleFirst = Order.flip(Order.mapInput(Order.Number, (row: EventRow) => row.persons)) + /** The window's event names, most reached first, for the step inputs. */ export const eventSuggestions = (model: Model): ReadonlyArray => - eventRowsOf(model) - .toSorted((a, b) => b.persons - a.persons) - .map((row) => ({ name: row.eventName, count: row.persons })) + Arr.map(Arr.sort(eventRowsOf(model), mostPeopleFirst), (row) => ({ + name: row.eventName, + count: row.persons, + })) /** The window's entry pages, for page steps. */ export const pageSuggestions = (model: Model): ReadonlyArray => diff --git a/apps/web/src/page/product-analytics/view-paths.ts b/apps/web/src/page/product-analytics/view-paths.ts index 574b1482f0..29a6e35369 100644 --- a/apps/web/src/page/product-analytics/view-paths.ts +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -6,7 +6,7 @@ import { type PathsDirection, } from "@maple/query-model" import { formatNumber, formatPercent } from "@maple/ui/lib/format" -import { Array as Arr, Option } from "effect" +import { Array as Arr, Option, Order, String as Str } from "effect" import { AsyncData } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import * as PathsChart from "@maple/views/charts/paths-chart" @@ -53,7 +53,7 @@ const DEPTHS = Array.from({ length: PATHS_MAX_DEPTH }, (_, index) => ({ value: String(index + 1), label: String(index + 1), })) -const BRANCHES = [3, 4, 6, PATHS_MAX_BRANCHES].map((value) => ({ +const BRANCHES = Arr.map([3, 4, 6, PATHS_MAX_BRANCHES], (value) => ({ value: String(value), label: String(value), })) @@ -121,7 +121,7 @@ const nodeLabel = (name: string): string => (name === PATHS_OTHER ? "Other" : na * so a known event name is an event and anything else starting with `/` is a page. */ const anchorOf = (name: string, eventNames: ReadonlySet): PathsAnchor => - !eventNames.has(name) && name.startsWith("/") + !eventNames.has(name) && Str.startsWith("/")(name) ? { kind: "page", pagePath: name } : { kind: "event", eventName: name } @@ -129,27 +129,34 @@ type Tally = Readonly<{ name: string; count: number }> const tally = (rows: ReadonlyArray, pick: (row: PathsRow) => string): ReadonlyArray => { const counts = new Map() - rows.forEach((row) => counts.set(pick(row), (counts.get(pick(row)) ?? 0) + row.count)) - return [...counts].map(([name, count]) => ({ name, count })).toSorted((a, b) => b.count - a.count) + Arr.forEach(rows, (row) => counts.set(pick(row), (counts.get(pick(row)) ?? 0) + row.count)) + return Arr.sort( + Arr.map([...counts], ([name, count]) => ({ name, count })), + Order.flip(Order.mapInput(Order.Number, (entry: Tally) => entry.count)), + ) } /** The first hop's nodes: what people did right after (or before) the anchor. */ const nextSteps = (rows: ReadonlyArray): ReadonlyArray => tally( - rows.filter((row) => row.hop === 1 && row.toNode !== ""), + Arr.filter(rows, (row) => row.hop === 1 && row.toNode !== ""), (row) => row.toNode, ) /** Where a sequence stopped: the node a hop into `''` left from. At most depth × (branches + 1) rows. */ const endings = (rows: ReadonlyArray): ReadonlyArray => tally( - rows.filter((row) => row.toNode === ""), + Arr.filter(rows, (row) => row.toNode === ""), (row) => row.fromNode, ) /** Everyone who reached the anchor: each of them has exactly one first hop. */ const anchorTotal = (rows: ReadonlyArray): number => - rows.filter((row) => row.hop === 1).reduce((total, row) => total + row.count, 0) + Arr.reduce( + Arr.filter(rows, (row) => row.hop === 1), + 0, + (total, row) => total + row.count, + ) const tallyTable = ( h: H, @@ -158,7 +165,10 @@ const tallyTable = ( ): Html => { if (options.rows.length === 0) return emptyMessage(h, { className: "py-6" }, [options.empty]) const eventNames = new Set( - Option.getOrElse(AsyncData.getData(model.events.data), () => []).map((row) => row.eventName), + Arr.map( + Option.getOrElse(AsyncData.getData(model.events.data), () => []), + (row) => row.eventName, + ), ) return table(h, { variant: "bare", size: "sm", ariaLabel: options.label }, [ tableHeader(h, [ diff --git a/apps/web/src/page/product-analytics/view.ts b/apps/web/src/page/product-analytics/view.ts index 2e6bc58725..d44e050be6 100644 --- a/apps/web/src/page/product-analytics/view.ts +++ b/apps/web/src/page/product-analytics/view.ts @@ -1,4 +1,4 @@ -import { Option } from "effect" +import { Array as Arr, Option } from "effect" import { AsyncData, Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { linkButton } from "@maple/views/ui/button" @@ -33,7 +33,7 @@ const tabs = (h: H, model: Model): Html => { return linkTabStrip( h, "Product analytics views", - TABS.map((tab) => ({ + Arr.map(TABS, (tab) => ({ key: tab, href: hrefOf( AppRoute.ProductAnalytics({ diff --git a/packages/backend/src/services/org/demo/product.test.ts b/packages/backend/src/services/org/demo/product.test.ts index 7d7db4c674..d1b9d4cbf5 100644 --- a/packages/backend/src/services/org/demo/product.test.ts +++ b/packages/backend/src/services/org/demo/product.test.ts @@ -1,29 +1,48 @@ +import { Array as Arr, Effect, Random, String as Str } from "effect" import { describe, expect, it } from "vitest" import { generateDemoProductRows } from "./product" const NOW = Date.parse("2026-11-03T12:00:00Z") -const rows = generateDemoProductRows({ orgId: "org_demo", hours: 6, visitorsPerHour: 40, nowMs: NOW }) +const rows = Effect.runSync( + Effect.gen(function* () { + const random = yield* Random.Random + return generateDemoProductRows({ + orgId: "org_demo", + hours: 6, + visitorsPerHour: 40, + nowMs: NOW, + random: () => random.nextDoubleUnsafe(), + }) + }).pipe(Random.withSeed("demo-product")), +) const reached = (eventName: string) => - new Set(rows.eventRows.filter((row) => row.event_name === eventName).map((row) => row.visitor_id)).size + new Set( + Arr.map( + Arr.filter(rows.eventRows, (row) => row.event_name === eventName), + (row) => row.visitor_id, + ), + ).size describe("generateDemoProductRows", () => { it("writes one session per visitor, and every event belongs to one of them", () => { - const sessions = new Set(rows.sessionRows.map((row) => row.session_id)) + const sessions = new Set(Arr.map(rows.sessionRows, (row) => row.session_id)) expect(rows.sessionRows).toHaveLength(240) - expect(rows.eventRows.every((row) => sessions.has(row.session_id))).toBe(true) - expect(rows.eventRows.every((row) => row.org_id === "org_demo")).toBe(true) + expect(Arr.every(rows.eventRows, (row) => sessions.has(row.session_id))).toBe(true) + expect(Arr.every(rows.eventRows, (row) => row.org_id === "org_demo")).toBe(true) }) it("narrows down the signup funnel step by step", () => { - const counts = ["signup_started", "signup_completed", "onboarding_connected"].map(reached) + const counts = Arr.map(["signup_started", "signup_completed", "onboarding_connected"], reached) expect(counts[0]).toBeGreaterThan(0) expect(counts[1]).toBeLessThanOrEqual(counts[0] ?? 0) expect(counts[2]).toBeLessThanOrEqual(counts[1] ?? 0) }) it("keeps every event inside the window", () => { - const latest = rows.eventRows.map((row) => Date.parse(`${String(row.timestamp).replace(" ", "T")}Z`)) - expect(latest.every((ms) => ms <= NOW && ms >= NOW - 6 * 3600_000)).toBe(true) + const latest = Arr.map(rows.eventRows, (row) => + Date.parse(`${Str.replace(" ", "T")(String(row.timestamp))}Z`), + ) + expect(Arr.every(latest, (ms) => ms <= NOW && ms >= NOW - 6 * 3600_000)).toBe(true) }) }) diff --git a/packages/backend/src/services/org/demo/product.ts b/packages/backend/src/services/org/demo/product.ts index 80f31b2887..8500b10fa5 100644 --- a/packages/backend/src/services/org/demo/product.ts +++ b/packages/backend/src/services/org/demo/product.ts @@ -1,5 +1,6 @@ import { randomBytes } from "node:crypto" import { fmtMetricTs } from "@maple/backend/services/warehouse/metric-rows" +import { Array as Arr, Random } from "effect" // Demo browser sessions and product events: `session_replays` rows (what web analytics and the // funnel population filters read) and the `product_events` page views and track() calls inside @@ -40,8 +41,10 @@ const SIDE_EVENTS = [ { event: "query_run", page: "/explore" }, ] as const -const pick = ]>(items: A): A[number] => - items[Math.floor(Math.random() * items.length)] ?? items[0] +type Rand = () => number + +const pick = ]>(rand: Rand, items: A): A[number] => + items[Math.floor(rand() * items.length)] ?? items[0] const id = (prefix: string) => `${prefix}_${randomBytes(8).toString("hex")}` export interface DemoProductRows { @@ -54,27 +57,31 @@ export const generateDemoProductRows = (input: { readonly hours: number readonly visitorsPerHour: number readonly nowMs?: number + /** Draws from the caller's Effect `Random`. Omitted, it is that service's default generator. */ + readonly random?: Rand }): DemoProductRows => { + const fallback = Random.Random.defaultValue() + const rand: Rand = input.random ?? (() => fallback.nextDoubleUnsafe()) const nowMs = input.nowMs ?? Date.now() const windowMs = input.hours * 3600_000 const visitors = Math.max(1, Math.round(input.hours * input.visitorsPerHour)) const sessionRows: Array> = [] const eventRows: Array> = [] - Array.from({ length: visitors }).forEach(() => { + Arr.forEach(Arr.range(1, visitors), () => { const visitorId = id("v") const sessionId = id("s") - const referral = pick(REFERRERS) - const client = pick(CLIENTS) - const country = pick(COUNTRIES) - const startMs = nowMs - Math.random() * windowMs * 0.95 + const referral = pick(rand, REFERRERS) + const client = pick(rand, CLIENTS) + const country = pick(rand, COUNTRIES) + const startMs = nowMs - rand() * windowMs * 0.95 let atMs = startMs let seq = 0 let userId = "" const pages: Array = [] const push = (kind: "navigation" | "custom", eventName: string, pagePath: string) => { - atMs += 5_000 + Math.random() * 90_000 + atMs += 5_000 + rand() * 90_000 seq += 1 if (kind === "navigation") pages.push(pagePath) eventRows.push({ @@ -96,11 +103,11 @@ export const generateDemoProductRows = (input: { const visit = (pagePath: string) => push("navigation", "$pageview", pagePath) visit("/") - if (Math.random() < 0.5) visit("/pricing") - FUNNEL.every((step) => { - if (Math.random() >= step.chance) { - if (Math.random() < 0.6) { - const side = pick(SIDE_EVENTS) + if (rand() < 0.5) visit("/pricing") + Arr.every(FUNNEL, (step) => { + if (rand() >= step.chance) { + if (rand() < 0.6) { + const side = pick(rand, SIDE_EVENTS) visit(side.page) push("custom", side.event, side.page) }