Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
694bfd4
feat(web): Product analytics page: events, funnels and paths
Makisuo Nov 3, 2026
486fd15
fix(web): keep a blank Page anchor when Product analytics navigates
Makisuo Nov 3, 2026
9fae98e
Merge branch 'refactor/funnel-step-builder-kit' into feat/product-ana…
Makisuo Nov 3, 2026
2b24176
test(web): sample URL for the Product analytics page
Makisuo Nov 3, 2026
8a7f2aa
fix(web): name the Paths ends table by direction
Makisuo Nov 3, 2026
bd19784
feat(backend): demo seed writes browser sessions and product events
Makisuo Nov 3, 2026
4ce8f22
fix(web): name the Events row's Paths action by what it opens
Makisuo Nov 3, 2026
d7fda3b
fix(query-engine): the ingest org guard reads org_id rows too
Makisuo Nov 3, 2026
10009c6
fix(web): let the Events header wrap on a phone
Makisuo Nov 3, 2026
43e0c83
fix(web): the steps table follows the funnel view, not leftover rows
Makisuo Nov 3, 2026
e5ae73f
fix(web): a route change from outside cancels a pending typing pause
Makisuo Nov 3, 2026
1cbd74d
Merge branch 'refactor/funnel-step-builder-kit' into feat/product-ana…
Makisuo Nov 3, 2026
543fd85
fix(web): the paths tables list every row
Makisuo Nov 3, 2026
f510d99
fix(web): tell exactly 200 event names from more, and use the kit grid
Makisuo Nov 3, 2026
4f1ea39
Merge branch 'refactor/funnel-step-builder-kit' into feat/product-ana…
Makisuo Nov 3, 2026
9a46fb5
fix(web): funnel refetch shows loading, Back restarts the typing paus…
Makisuo Nov 3, 2026
a0ee7b8
Merge branch 'refactor/funnel-step-builder-kit' into feat/product-ana…
Makisuo Nov 14, 2026
477ddce
chore(lint): product analytics page on the effect collection helpers
Makisuo Nov 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions apps/web/src/lab/entries/product-analytics.ts
Original file line number Diff line number Diff line change
@@ -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=<scenario>`: 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<EventRow> = [
{ 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<EventRow> = Arr.map(EVENTS, (row) => ({
...row,
count: Math.round(row.count * 0.89),
}))

const FUNNEL: ReadonlyArray<FunnelRow> = [
{ 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<PathsRow> = [
{ 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 = <A>(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 }],
})
8 changes: 8 additions & 0 deletions apps/web/src/lab/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,14 @@ export const LAB_ENTRIES: ReadonlyArray<LabEntry> = [
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",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/page/analytics/analytics.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
19 changes: 1 addition & 18 deletions apps/web/src/page/analytics/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/page/analytics/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ import {
AiReferralPoint,
AnalyticsEvent,
AnalyticsPage,
Breakdowns,
Live,
type Model,
PageviewsPoint,
Expand All @@ -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<Model, Message>
type Cmd = Command.Command<Message, never, Resources>
Expand Down
27 changes: 25 additions & 2 deletions apps/web/src/page/analytics/view-breakdown.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -39,7 +41,12 @@ export type BreakdownDimension = Readonly<{
note?: (h: H) => Html
}>

export type BreakdownCard = Readonly<{ id: string; dimensions: ReadonlyArray<BreakdownDimension> }>
export type BreakdownCard = Readonly<{
id: string
dimensions: ReadonlyArray<BreakdownDimension>
/** 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 }>

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading