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..ac8236970c --- /dev/null +++ b/apps/web/src/lab/entries/product-analytics.ts @@ -0,0 +1,220 @@ +import { OrgId } from "@maple/domain" +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" +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 = Arr.map(EVENTS, (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"] = Arr.map(FUNNEL, (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 => + !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: Arr.filter(next.commands ?? [], 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 1267692669..c5bffa1e25 100644 --- a/apps/web/src/lab/registry.ts +++ b/apps/web/src/lab/registry.ts @@ -483,6 +483,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 4536061966..2b8b59880e 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 cd7dcf2bcb..875b282dea 100644 --- a/apps/web/src/page/analytics/model.ts +++ b/apps/web/src/page/analytics/model.ts @@ -18,28 +18,11 @@ 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" import { SortDir } from "../kit/table-sort" export type Search = typeof AnalyticsSearch.Type -const FacetRow = Schema.Struct({ name: Schema.String, count: Schema.Number }) - -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, diff --git a/apps/web/src/page/analytics/update.ts b/apps/web/src/page/analytics/update.ts index 7ccc4ee3c3..ff8a1d591f 100644 --- a/apps/web/src/page/analytics/update.ts +++ b/apps/web/src/page/analytics/update.ts @@ -32,7 +32,6 @@ import { AiReferralPoint, AnalyticsEvent, AnalyticsPage, - Breakdowns, Live, type Model, PageviewsPoint, @@ -46,6 +45,7 @@ import { TraceSample, } from "./model" import { filtersOf, requestSlots, searchSortOf, 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 bf9ac93c5e..e9f9acaa4a 100644 --- a/apps/web/src/page/analytics/view-breakdown.ts +++ b/apps/web/src/page/analytics/view-breakdown.ts @@ -1,8 +1,10 @@ import { formatNumber, formatPercent, pluralize } 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 { stripClass } from "@maple/views/ui/rule" import { emptyMessage } from "@maple/views/ui/empty" +import { icon } from "@maple/views/ui/icon" import { panel } from "@maple/views/ui/panel" import { pressable } from "@maple/views/ui/pressable" import { searchInput } from "@maple/views/ui/search-input" @@ -39,7 +41,12 @@ export type BreakdownDimension = Readonly<{ note?: (h: H) => Html }> -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 }> @@ -346,7 +353,23 @@ export const breakdownPanel = ( }, [ 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 f2bba6bb7d..22b70019cc 100644 --- a/apps/web/src/page/analytics/view-filters.ts +++ b/apps/web/src/page/analytics/view-filters.ts @@ -1,15 +1,8 @@ -import { WEB_ANALYTICS_UNSET } from "@maple/domain/query-engine" -import { activeFilterChips, FILTER_SECTION_LABEL, hasActiveFilters, trafficIncludes } from "./filters" -import { countryLabel, languageLabel, referrerLabel, utmLabel } from "./labels" -import { Option, Array as Arr, Record as Rec } from "effect" +import { activeFilterChips, FILTER_SECTION_LABEL } from "./filters" +import { Option, Array as Arr } 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 { filterSection } from "@maple/views/ui/filters/filter-section" import { filterSidebarBody, filterSidebarEmpty, @@ -17,150 +10,16 @@ import { 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, searchSiteOf, tabOf } from "./requests" import { presenceOf } from "@maple/views/data/signal-presence" -import { favicon, isHostLike } from "./view-icons" - -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 }), - }) -} +import { analyticsFilterRail } from "../kit/analytics-filter-rail" +import { Message } from "./message" +import type { Model } from "./model" +import { filtersOf, searchSiteOf, tabOf } from "./requests" -const checkboxGroup = (h: H, title: string, children: ReadonlyArray): Html => - h.div([h.Class("pb-3")], [filterSidebarGroupLabel(h, title), ...children]) +// The page's filter rail: the kit's, with the Traffic group and the Event section. -const loadedRail = (h: H, model: Model, breakdowns: Breakdowns): Html => { - const filters = filtersOf(model.search) - const canClear = hasActiveFilters(filters) - const isEmpty = Arr.every(Rec.values(breakdowns), (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", - Arr.filter( - [ - 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", - Arr.map(events, (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 }), - ], - (node): node is Html => node !== null, - ), - ), - ]) -} +type H = HtmlBuilder /** The overview's breakdown cards render (not the AI tab, not the no-sessions setup state). */ export const showsBreakdownCards = (model: Model): boolean => @@ -172,11 +31,23 @@ 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: Arr.map( + Option.getOrElse(AsyncData.getData(model.events.data), () => []), + (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 }), }) /** @@ -206,12 +77,15 @@ export const searchEventRail = (h: H, model: Model): Html | undefined => { : { onClear: Message.ChangedFilter({ key: "eventName", value: undefined }) }, ), ...filterSidebarBody(h, "analytics-search-filters", [ - section( - h, - model, - "eventName", - Arr.map(events, (event) => ({ name: event.name, count: event.sessions })), - ), + filterSection(h, { + title: FILTER_SECTION_LABEL.eventName, + options: Arr.map(events, (event) => ({ name: event.name, count: event.sessions })), + selected: eventName === undefined ? [] : [eventName], + isSearchable: true, + sections: model.filterSections, + toSectionsMessage: (message) => Message.GotFilterSectionsMessage({ message }), + onToggle: (name) => Message.ToggledFilter({ key: "eventName", value: name }), + }), ]), ]) } diff --git a/apps/web/src/page/analytics/view-icons.ts b/apps/web/src/page/analytics/view-icons.ts index 1d8986cb29..64cd897294 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" @@ -10,25 +11,14 @@ type H = HtmlBuilder const ICON_BOX = "size-4 shrink-0" -const faviconUrl = (host: string): string => `https://favicon.maple.dev/?url=${encodeURIComponent(host)}` - -/** A dotted name a favicon could exist for. */ -export const isHostLike = (value: string): boolean => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(value) - /** 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 1b7d7ffe44..9b05a497ca 100644 --- a/apps/web/src/page/analytics/view-overview.ts +++ b/apps/web/src/page/analytics/view-overview.ts @@ -363,6 +363,18 @@ export const overviewCards = (model: Model): ReadonlyArray `https://favicon.maple.dev/?url=${encodeURIComponent(host)}` + +/** A dotted name a favicon could exist for. */ +export const isHostLike = (value: string): boolean => /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(value) + +/** 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 = Arr.every(Rec.values(breakdowns), (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 }), + ]), + ]) +} + +/** 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..9dbebfc473 --- /dev/null +++ b/apps/web/src/page/product-analytics/command.ts @@ -0,0 +1,84 @@ +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 { settleTo } 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(settleTo((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..a6747ee47d --- /dev/null +++ b/apps/web/src/page/product-analytics/definition.ts @@ -0,0 +1,136 @@ +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 { Array as Arr, Option, Record as Rec, String as Str } from "effect" +import { + completedSteps, + DEFAULT_FUNNEL_KEY_BY, + DEFAULT_FUNNEL_WINDOW_SECONDS, +} from "../dashboards/kit/funnel-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 => + 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 => + Rec.fromEntries( + Arr.flatMap(FUNNEL_POPULATION_FILTER_FIELDS, (field) => + search[field] === undefined ? [] : [[field, search[field]] as const], + ), + ) + +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 = Arr.map(completedSteps(funnel.steps), trimmed) + 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 => + 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 => { + const paths = pathsOf(search) + if (anchorNameOf(paths.anchor) === "") return null + return { + ...populationOf(search), + 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: Str.trim(step.eventName) } + case "page": + return { ...step, pagePath: Str.trim(step.pagePath) } + case "session": + return { ...step, value: Str.trim(step.value) } + } +} + +/** 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..f1249b75ee --- /dev/null +++ b/apps/web/src/page/product-analytics/model.ts @@ -0,0 +1,80 @@ +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, + /** 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, + 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..eeec7ef078 --- /dev/null +++ b/apps/web/src/page/product-analytics/product-analytics.scene.test.ts @@ -0,0 +1,319 @@ +import { OrgId } from "@maple/domain" +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" +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) => + 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" })) + +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(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 = 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" }, + }) + }) + + test("names typed with stray spaces still match their events", () => { + 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 = sentAs( + resolved({ tab: "paths", anchor: { kind: "event", eventName: "signup_completed" } }).commands, + "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("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("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( + 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() + 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, + 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, + 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( + 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("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 }, + 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..1f693e3b18 --- /dev/null +++ b/apps/web/src/page/product-analytics/requests.ts @@ -0,0 +1,111 @@ +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" +import { previousWindow } from "../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 => { + // 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", + 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 } => + 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 new file mode 100644 index 0000000000..f21bf28787 --- /dev/null +++ b/apps/web/src/page/product-analytics/update.ts @@ -0,0 +1,334 @@ +import { FUNNEL_MAX_STEPS, FUNNEL_POPULATION_FILTER_FIELDS } from "@maple/query-model" +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" +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 "../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 => + Arr.reduce(SlotName.literals, model, (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) } + }) + +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`. 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 => { + const committed = committedOf(search) + const drafts = committed === committedOf(withDrafts(model)) ? { committed } : draftsOf(search) + return { + model: { + ...model, + ...drafts, + typingVersion: model.typingVersion + 1, + navigatedTo: searchHref(search), + }, + 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 = Arr.map(model.steps, 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, + navigatedTo: null, + 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. Any + // 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 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 } + 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 => + 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..9a844480d6 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-events.ts @@ -0,0 +1,276 @@ +import { formatNumber, pluralize } from "@maple/ui/lib/format" +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" +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, mostPeopleFirst } 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 => 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 => { + 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 = 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 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 = 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", + 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")}${ + isCapped ? ` · of the top ${EVENTS_LIMIT} names` : "" + }`, + }), + ]) +} + +/** 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 = 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 => + 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 from ${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, ...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" }, [ + 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, + Arr.map(shown, (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: (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" }, [ + docsLink(h, "productEventsApi"), + ]), + ]) + : vstack(h, { gap: "4" }, [ + stats(h, model, rows, isCapped), + panel(h, {}, [ + panelHeader(h, { + title: "All events", + action: hstack(h, { gap: "2", wrap: true }, [ + 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-full sm: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..f66685ea99 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-funnels.ts @@ -0,0 +1,300 @@ +import { FUNNEL_MAX_STEPS, type FunnelKeyBy, type FunnelVariant } from "@maple/query-model" +import { formatNumber, formatPercent } from "@maple/ui/lib/format" +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" +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 "../dashboards/kit/funnel-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, + mostPeopleFirst, + 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 = 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) + 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 = 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 + ? [] + : [ + " Or start from one of these:", + hstack( + h, + { gap: "2", wrap: true, justify: "center", className: "mt-3" }, + Arr.map(pairs, ([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 => + 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 + 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, + Arr.map(rows, (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 => + // 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" }), + }), + 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" }, [ + 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..c8307a34bc --- /dev/null +++ b/apps/web/src/page/product-analytics/view-parts.ts @@ -0,0 +1,86 @@ +import type { FunnelKeyBy } from "@maple/query-model" +import { pluralize } from "@maple/ui/lib/format" +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" +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), () => []) + +/** 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 => + 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 => + 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..29a6e35369 --- /dev/null +++ b/apps/web/src/page/product-analytics/view-paths.ts @@ -0,0 +1,321 @@ +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 { 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" +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 { grid } from "@maple/views/ui/grid" +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 = Arr.map([3, 4, 6, PATHS_MAX_BRANCHES], (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) && Str.startsWith("/")(name) + ? { 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() + 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( + 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( + 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 => + Arr.reduce( + Arr.filter(rows, (row) => row.hop === 1), + 0, + (total, row) => total + row.count, + ) + +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( + Arr.map( + Option.getOrElse(AsyncData.getData(model.events.data), () => []), + (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, + Arr.map(options.rows, (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 }, + }), + ]), + ]), + 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", + }), + ]), + ]), + ]) + }, + }) +} + +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..d44e050be6 --- /dev/null +++ b/apps/web/src/page/product-analytics/view.ts @@ -0,0 +1,124 @@ +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" +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 { analyticsFilterRail } from "../kit/analytics-filter-rail" +import { dashboardPage } from "../kit/dashboard-page" +import { pageTime } from "../kit/page-time" +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" +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 + const shownCount = + eventCount === undefined ? undefined : eventCount > EVENTS_LIMIT ? `${EVENTS_LIMIT}+` : eventCount + return linkTabStrip( + h, + "Product analytics views", + Arr.map(TABS, (tab) => ({ + key: tab, + href: hrefOf( + AppRoute.ProductAnalytics({ + search: { ...withDrafts(model), tab: tab === "events" ? undefined : tab }, + }), + ), + label: TAB_LABEL[tab], + count: tab === "events" ? shownCount : 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 1695fe2545..bbef0bea7f 100644 --- a/apps/web/src/page/registry.ts +++ b/apps/web/src/page/registry.ts @@ -83,6 +83,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 Cost from "./cost" import * as MetricDetail from "./metrics/detail" import * as Overview from "./overview" @@ -208,6 +209,7 @@ export const pages = [ UserDetail.page, Share.page, Analytics.page, + ProductAnalytics.page, Cost.page, ] diff --git a/apps/web/src/route/app-route.ts b/apps/web/src/route/app-route.ts index d7d9387e99..c9c497fbbd 100644 --- a/apps/web/src/route/app-route.ts +++ b/apps/web/src/route/app-route.ts @@ -88,6 +88,7 @@ export const AppRoute = defineRouteUnion({ Alerts: { search: Search.AlertsSearch }, AlertsSection: { section: Search.AlertsSection, search: Search.AlertsHubSearch }, Analytics: { search: Search.AnalyticsSearch }, + ProductAnalytics: { search: Search.ProductAnalyticsSearch }, Anomalies: { search: Search.AnomaliesSearch }, CodeReview: { search: Search.CodeReviewSearch }, Cost: { search: Search.CostSearch }, diff --git a/apps/web/src/route/routers.ts b/apps/web/src/route/routers.ts index 106e0dfb5c..a451aae355 100644 --- a/apps/web/src/route/routers.ts +++ b/apps/web/src/route/routers.ts @@ -327,6 +327,11 @@ const routers = { Route.mapTo(AppRoute.AlertsSection), ), 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)), Cost: pipe(path("cost"), search(Search.CostSearch), Route.mapTo(AppRoute.Cost)), diff --git a/apps/web/src/route/search.ts b/apps/web/src/route/search.ts index f76421d32d..6e1821b448 100644 --- a/apps/web/src/route/search.ts +++ b/apps/web/src/route/search.ts @@ -18,7 +18,15 @@ import { VcsRepositoryId, } from "@maple/domain/http" import { PushSourcePlatform } from "@maple/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" import { TOOL_METRICS, TOOL_PERCENTILES } from "@maple/web-data/lib/tool-analytics" import { VARIABLE_PARAM_PREFIX } from "./dashboard-controls" import { HUB_SORTS, HUB_VIEWS, LEGACY_HUB_VIEWS, SEVERITY_FILTERS } from "../page/errors/hub/hub-params" @@ -552,6 +560,21 @@ export const AnalyticsSearch = Schema.Struct({ site: StringParam, }) +/** `/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, +}) + /** * `/anomalies`, retired: it only redirects to the Alerts Inbox's Anomalies view. Old links spelled * the filters several ways (`services=["a"]`, `services=a`, `service=a`), so every value is read diff --git a/apps/web/src/route/session-time-range.ts b/apps/web/src/route/session-time-range.ts index a1732a16e5..d01c8365d9 100644 --- a/apps/web/src/route/session-time-range.ts +++ b/apps/web/src/route/session-time-range.ts @@ -34,6 +34,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 cf85977aa7..c1ae0a6b42 100644 --- a/apps/web/src/shell/nav.ts +++ b/apps/web/src/shell/nav.ts @@ -276,6 +276,7 @@ const operatorNavGroups = (flags: NavFlags): ReadonlyArray => [ items: [ exploreItem, { title: "Web analytics", href: "/analytics", icon: "ChartBarHorizontalIcon" }, + { title: "Product analytics", href: "/product-analytics", icon: "ChartBarTrendUpIcon" }, { title: "Users", href: "/users", icon: "UsersIcon" }, { title: "Dashboards", href: "/dashboards", icon: "GridSquareCirclePlusIcon" }, ], diff --git a/apps/web/src/view.lazy.test.ts b/apps/web/src/view.lazy.test.ts index 62808294bf..442487a3ee 100644 --- a/apps/web/src/view.lazy.test.ts +++ b/apps/web/src/view.lazy.test.ts @@ -125,6 +125,7 @@ const SAMPLE_PATHS = [ "/replays/session_1", "/share/token_1", "/analytics", + "/product-analytics", ] const entered = (path: string): Model => diff --git a/packages/backend/src/services/org/DemoService.ts b/packages/backend/src/services/org/DemoService.ts index 7a7dbfa321..dd3db8c9c7 100644 --- a/packages/backend/src/services/org/DemoService.ts +++ b/packages/backend/src/services/org/DemoService.ts @@ -3,9 +3,11 @@ import { Array as Arr, Context, Effect, Layer, Random } from "effect" import { Env } from "@maple/backend/platform/Env" import { WarehouseQueryService } from "@maple/backend/services/warehouse/WarehouseQueryService" 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 @@ -51,7 +53,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( @@ -77,6 +85,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..d1b9d4cbf5 --- /dev/null +++ b/packages/backend/src/services/org/demo/product.test.ts @@ -0,0 +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 = 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( + 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(Arr.map(rows.sessionRows, (row) => row.session_id)) + expect(rows.sessionRows).toHaveLength(240) + 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 = 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 = 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 new file mode 100644 index 0000000000..8500b10fa5 --- /dev/null +++ b/packages/backend/src/services/org/demo/product.ts @@ -0,0 +1,158 @@ +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 +// 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 + +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 { + readonly sessionRows: ReadonlyArray> + readonly eventRows: ReadonlyArray> +} + +export const generateDemoProductRows = (input: { + readonly orgId: string + 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> = [] + + Arr.forEach(Arr.range(1, visitors), () => { + const visitorId = id("v") + const sessionId = id("s") + 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 + rand() * 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 (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) + } + 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 } +} diff --git a/packages/query-engine/src/execution/executor.test.ts b/packages/query-engine/src/execution/executor.test.ts index 64b1f96262..b31c50f20a 100644 --- a/packages/query-engine/src/execution/executor.test.ts +++ b/packages/query-engine/src/execution/executor.test.ts @@ -301,12 +301,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 d8927822d6..700a55ef2e 100644 --- a/packages/query-engine/src/execution/executor.ts +++ b/packages/query-engine/src/execution/executor.ts @@ -185,8 +185,14 @@ 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 = Arr.filter( - [row.OrgId, isRecord(row.resource_attributes) ? row.resource_attributes.maple_org_id : undefined], + [ + row.OrgId, + row.org_id, + isRecord(row.resource_attributes) ? row.resource_attributes.maple_org_id : undefined, + ], (tag) => tag !== undefined, ) return tags.length > 0 && Arr.every(tags, (tag) => tag === orgId) diff --git a/packages/views/src/ui/INDEX.md b/packages/views/src/ui/INDEX.md index 2c8c16962c..0b22144c76 100644 --- a/packages/views/src/ui/INDEX.md +++ b/packages/views/src/ui/INDEX.md @@ -193,6 +193,7 @@ existing copies onto it (see `apps/web/CLAUDE.md`). - `apps/web/src/page/kit/alert-links.ts` `ruleHref`, `incidentHref`, `alertsHref`, `createHref`: A rule's page. - `apps/web/src/page/kit/alert-test-rows.ts` `hubAt`, `ruleId`, `ruleRow`, `stateRow`, `incidentRow`, `destinationRow`: Synced alert rows for the alerts pages' scene tests, the shape Electric delivers. - `apps/web/src/page/kit/alert-time.ts` `msIso`, `formatSpan`, `everyMinutes`: How alert pages write times: ISO stamps, spans in their two largest units, check cadences. +- `apps/web/src/page/kit/analytics-filter-rail.ts` `isHostLike`, `favicon`, `analyticsFilterRail`: The session-dimension filter rail of /analytics and /product-analytics, its options from `getWebAnalyticsBreakdowns`. - `apps/web/src/page/kit/anomaly-evidence.ts` `anomalySummary`, `anomalySignalChart`, `anomalyFingerprintRow`: The anomaly detector's evidence: the observed value against its baseline, the signal chart with its baseline and threshold, and the error fingerprints grouped into it. - `apps/web/src/page/kit/anomaly-row.ts` `anomalyHref`, `anomalyRow`: One anomaly as a list row. - `apps/web/src/page/kit/audit-entry-link.ts` `auditEntryHref`: The Settings audit log ending at one entry, with that entry open.