From d494e4f1f1970008fc361b750e58ab972a92c5e0 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 16:11:47 +0200 Subject: [PATCH] feat(analytics): show the shopper funnel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API returns the journey before an order exists and the storefront reports it, but nothing displayed any of it. Adds the shopper funnel to the Analytics page: sessions through product views, carts and checkouts to paid, plus top traffic paths and per-SKU view-to-cart rate. Two things the page has to be honest about. The pre-order steps come from what browsers reported and are a floor: blocked scripts and closed tabs lose events. The paid step is read from the orders table and is exact. They are drawn in different colours, the paid row carries an "exact" badge, and a note says real conversion is no worse than shown. Presented as one continuous measurement, a funnel that undercounts its first step and not its last overstates the drop. When collection is off the page says so, rather than rendering an empty funnel. "Nobody visited" and "we are not counting" look identical otherwise, and an operator staring at zeroes deserves to know which they are looking at. "Looked at, not taken" ranks products viewed by several shoppers and added by fewer than half. That is the figure sales data cannot produce, because sales data only ever contains what did sell. Products with a single viewer are excluded — one person is not evidence. Closes #9 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- src/app/core/api/analytics.service.ts | 5 + src/app/core/models/analytics.models.ts | 42 +++ .../features/analytics/analytics.page.html | 125 +++++++ .../features/analytics/analytics.page.spec.ts | 345 ++++++++++++------ src/app/features/analytics/analytics.page.ts | 65 +++- 5 files changed, 472 insertions(+), 110 deletions(-) diff --git a/src/app/core/api/analytics.service.ts b/src/app/core/api/analytics.service.ts index b90b9fb..60e701c 100644 --- a/src/app/core/api/analytics.service.ts +++ b/src/app/core/api/analytics.service.ts @@ -3,6 +3,7 @@ import { Observable } from 'rxjs'; import { AnalyticsFunnel, + AnalyticsStorefront, AnalyticsProducts, AnalyticsSummary, AnalyticsTimeseries, @@ -52,4 +53,8 @@ export class AnalyticsService { funnel(range: DateRange): Observable { return this.api.get('/v1/admin/analytics/funnel', { ...range }); } + + storefront(range: DateRange): Observable { + return this.api.get('/v1/admin/analytics/storefront', { ...range }); + } } diff --git a/src/app/core/models/analytics.models.ts b/src/app/core/models/analytics.models.ts index 618ed4d..ada1355 100644 --- a/src/app/core/models/analytics.models.ts +++ b/src/app/core/models/analytics.models.ts @@ -142,3 +142,45 @@ export interface DateRange { from: string; to: string; } + +/** + * The shopper journey, from arriving at the shop to a paid order. + * + * **The pre-order steps are a floor, not a count.** Blocked scripts, a tab + * closed before the batch flushed and disabled JavaScript all lose events. The + * `paid` step is read from the orders table and is exact. Present them + * differently — a funnel whose first step undercounts and whose last does not + * will overstate conversion, and a reader deserves to know which end is soft. + */ +export interface StorefrontStep { + step: string; + label: string; + sessions: number; + conversion_from_start: number | null; + drop_off_from_previous: number | null; +} + +export interface PathViews { + path: string; + views: number; + sessions: number; +} + +export interface ProductInterest { + sku: string; + name: string | null; + sessions_viewed: number; + sessions_added: number; + /** Sessions that added divided by sessions that viewed. Null when unviewed. */ + add_to_cart_rate: number | null; +} + +export interface AnalyticsStorefront { + period: PeriodInfo; + /** False when the deployment is not collecting. Distinct from "no visitors". */ + enabled: boolean; + page_views: number; + steps: StorefrontStep[]; + top_paths: PathViews[]; + product_interest: ProductInterest[]; +} diff --git a/src/app/features/analytics/analytics.page.html b/src/app/features/analytics/analytics.page.html index 67f2946..e65a730 100644 --- a/src/app/features/analytics/analytics.page.html +++ b/src/app/features/analytics/analytics.page.html @@ -143,6 +143,131 @@

Analytics

+ + @if (storefront(); as shop) { + + @if (storefrontOff()) { +
+

Storefront analytics is switched off.

+

+ This deployment is not collecting shopper events, so there is nothing to show — which + is not the same as nobody visiting. Set + STOREFRONT_ANALYTICS_ENABLED + on the API and enable it in the storefront configuration to start. +

+
+ } @else { +
+
+ @if (storefrontChart(); as config) { + + } +

+ {{ shop.page_views }} page view(s) in this period. +

+
+ +
+ + + + + + + + + + + @for (step of shop.steps; track step.step) { + + + + + + + } + +
StepSessionsOf visitsLost
+ {{ step.label }} + @if (step.step === 'paid') { + exact + } + {{ step.sessions }} + {{ percent(step.conversion_from_start) }} + + @if (step.drop_off_from_previous) { + −{{ step.drop_off_from_previous }} + } @else { + — + } +
+ +

+ Blocked scripts and closed tabs lose events, so every step except + Paid undercounts. Paid is read from + the orders table. Real conversion is therefore no worse than shown. +

+
+
+ + @if (leakingProducts().length > 0) { +
+

Looked at, not taken

+

+ Viewed by several shoppers and added by fewer than half of them. Sales figures + cannot show this, because they only contain what did sell. +

+
    + @for (product of leakingProducts(); track product.sku) { +
  • + + {{ product.name ?? product.sku }} + {{ + product.sku + }} + + + {{ product.sessions_viewed }} viewed · {{ product.sessions_added }} added + {{ percent(product.add_to_cart_rate) }} + +
  • + } +
+
+ } + + @if (shop.top_paths.length > 0) { +
+

Most visited pages

+
    + @for (row of shop.top_paths; track row.path) { +
  • + {{ row.path }} + + {{ row.views }} view(s) · {{ row.sessions }} session(s) + +
  • + } +
+
+ } + } +
+ } + @if (funnel(); as data) { { }); }); +const EUR = { + currency: 'EUR', + gross_revenue: 50000, + refunded_revenue: 10000, + net_revenue: 40000, + orders: 4, + units: 9, + average_order_value: 12500, + previous: { + gross_revenue: 25000, + refunded_revenue: 0, + net_revenue: 25000, + orders: 2, + units: 4, + average_order_value: 12500, + }, + change: { + net_revenue_pct: 60, + gross_revenue_pct: 100, + orders_pct: 100, + units_pct: 125, + average_order_value_pct: 0, + }, +}; + +const USD = { ...EUR, currency: 'USD', gross_revenue: 1000, net_revenue: 900 }; + +function buildPage(overrides: Record = {}) { + TestBed.configureTestingModule({ + providers: [ + { + provide: AnalyticsService, + useValue: { + summary: () => of({ currencies: [EUR], period: {}, previous_period: {} }), + timeseries: () => + of({ + interval: 'day', + series: [ + { + currency: 'EUR', + points: [ + { + bucket: '2026-08-01', + gross_revenue: 20000, + refunded_revenue: 0, + net_revenue: 20000, + orders: 2, + units: 4, + }, + { + bucket: '2026-08-02', + gross_revenue: 0, + refunded_revenue: 0, + net_revenue: 0, + orders: 0, + units: 0, + }, + ], + }, + ], + }), + products: () => of({ sort: 'revenue', products: [], never_sold: [] }), + storefront: () => + of({ + enabled: true, + page_views: 40, + steps: [ + { + step: 'sessions', + label: 'Visited the shop', + sessions: 100, + conversion_from_start: 1, + drop_off_from_previous: null, + }, + { + step: 'viewed_product', + label: 'Viewed a product', + sessions: 60, + conversion_from_start: 0.6, + drop_off_from_previous: 40, + }, + { + step: 'added_to_cart', + label: 'Added to cart', + sessions: 20, + conversion_from_start: 0.2, + drop_off_from_previous: 40, + }, + { + step: 'started_checkout', + label: 'Started checkout', + sessions: 12, + conversion_from_start: 0.12, + drop_off_from_previous: 8, + }, + { + step: 'paid', + label: 'Paid', + sessions: 10, + conversion_from_start: 0.1, + drop_off_from_previous: 2, + }, + ], + top_paths: [{ path: '/shop', views: 30, sessions: 25 }], + product_interest: [ + { + sku: 'LEAK-1', + name: 'Ignored Red', + sessions_viewed: 20, + sessions_added: 1, + add_to_cart_rate: 0.05, + }, + { + sku: 'GOOD-1', + name: 'Popular White', + sessions_viewed: 20, + sessions_added: 18, + add_to_cart_rate: 0.9, + }, + { + sku: 'RARE-1', + name: 'Barely Seen', + sessions_viewed: 1, + sessions_added: 0, + add_to_cart_rate: 0, + }, + ], + }), + funnel: () => + of({ + steps: [ + { + step: 'created', + label: 'Order created', + orders: 10, + conversion_from_start: 1, + drop_off_from_previous: null, + }, + { + step: 'checkout_started', + label: 'Checkout started', + orders: 6, + conversion_from_start: 0.6, + drop_off_from_previous: 4, + }, + { + step: 'paid', + label: 'Payment confirmed', + orders: 5, + conversion_from_start: 0.5, + drop_off_from_previous: 1, + }, + ], + never_checked_out: 4, + payment_failed: 1, + payment_unresolved: 0, + cancelled: 2, + }), + ...overrides, + }, + }, + ], + }); + const fixture = TestBed.createComponent(AnalyticsPage); + fixture.detectChanges(); + return fixture.componentInstance; +} + /** * Renders the page against stubbed responses. * @@ -54,116 +222,14 @@ describe('isoDate', () => { * screen. */ describe('AnalyticsPage', () => { - const EUR = { - currency: 'EUR', - gross_revenue: 50000, - refunded_revenue: 10000, - net_revenue: 40000, - orders: 4, - units: 9, - average_order_value: 12500, - previous: { - gross_revenue: 25000, - refunded_revenue: 0, - net_revenue: 25000, - orders: 2, - units: 4, - average_order_value: 12500, - }, - change: { - net_revenue_pct: 60, - gross_revenue_pct: 100, - orders_pct: 100, - units_pct: 125, - average_order_value_pct: 0, - }, - }; - - const USD = { ...EUR, currency: 'USD', gross_revenue: 1000, net_revenue: 900 }; - - function build(overrides: Record = {}) { - TestBed.configureTestingModule({ - providers: [ - { - provide: AnalyticsService, - useValue: { - summary: () => of({ currencies: [EUR], period: {}, previous_period: {} }), - timeseries: () => - of({ - interval: 'day', - series: [ - { - currency: 'EUR', - points: [ - { - bucket: '2026-08-01', - gross_revenue: 20000, - refunded_revenue: 0, - net_revenue: 20000, - orders: 2, - units: 4, - }, - { - bucket: '2026-08-02', - gross_revenue: 0, - refunded_revenue: 0, - net_revenue: 0, - orders: 0, - units: 0, - }, - ], - }, - ], - }), - products: () => of({ sort: 'revenue', products: [], never_sold: [] }), - funnel: () => - of({ - steps: [ - { - step: 'created', - label: 'Order created', - orders: 10, - conversion_from_start: 1, - drop_off_from_previous: null, - }, - { - step: 'checkout_started', - label: 'Checkout started', - orders: 6, - conversion_from_start: 0.6, - drop_off_from_previous: 4, - }, - { - step: 'paid', - label: 'Payment confirmed', - orders: 5, - conversion_from_start: 0.5, - drop_off_from_previous: 1, - }, - ], - never_checked_out: 4, - payment_failed: 1, - payment_unresolved: 0, - cancelled: 2, - }), - ...overrides, - }, - }, - ], - }); - const fixture = TestBed.createComponent(AnalyticsPage); - fixture.detectChanges(); - return fixture.componentInstance; - } - it('renders without throwing and finishes loading', () => { - const page = build(); + const page = buildPage(); expect(page.loading()).toBe(false); expect(page.partial()).toBe(false); }); it('builds chart configurations from the series', () => { - const page = build(); + const page = buildPage(); const revenue = page.revenueChart(); expect(revenue?.type).toBe('line'); @@ -172,7 +238,7 @@ describe('AnalyticsPage', () => { }); it('picks the currency that earned most for the charts', () => { - const page = build({ + const page = buildPage({ summary: () => of({ currencies: [USD, EUR], period: {}, previous_period: {} }), }); @@ -181,17 +247,17 @@ describe('AnalyticsPage', () => { }); it('names the step where most orders are lost', () => { - const page = build(); + const page = buildPage(); expect(page.worstDropOff()?.step).toBe('checkout_started'); }); it('flags a partial view when one panel fails', () => { - const page = build({ funnel: () => of(null) }); + const page = buildPage({ funnel: () => of(null) }); expect(page.partial()).toBe(true); }); it('shows an em dash rather than an arrow when there is no baseline', () => { - const page = build(); + const page = buildPage(); // Change from zero is undefined; an arrow would assert a direction the // data cannot support. expect(page.trend(null)).toBe('—'); @@ -201,8 +267,69 @@ describe('AnalyticsPage', () => { }); it('renders a null conversion as an em dash, not 0%', () => { - const page = build(); + const page = buildPage(); expect(page.percent(null)).toBe('—'); expect(page.percent(0.6)).toBe('60.0%'); }); }); + +/** + * The shopper funnel (S2). Its job is to be honest about where the numbers come + * from, so these pin the parts that could quietly mislead. + */ +describe('AnalyticsPage shopper funnel', () => { + it('draws the funnel and marks the paid step differently from the rest', () => { + const page = buildPage(); + const chart = page.storefrontChart(); + + expect(chart?.data.labels).toHaveLength(5); + expect(chart?.data.datasets[0].data).toEqual([100, 60, 20, 12, 10]); + + // The paid step is exact; every step before it is a floor. Drawing them + // identically would imply one continuous measurement. + const colours = chart?.data.datasets[0].backgroundColor as string[]; + expect(colours[4]).not.toBe(colours[0]); + }); + + it('surfaces products that are looked at but not taken', () => { + const page = buildPage(); + const leaking = page.leakingProducts(); + + expect(leaking.map((p) => p.sku)).toContain('LEAK-1'); + // Converts well, so it is not a problem to surface. + expect(leaking.map((p) => p.sku)).not.toContain('GOOD-1'); + // Only one viewer: too little evidence to call it a leak. + expect(leaking.map((p) => p.sku)).not.toContain('RARE-1'); + }); + + it('ranks the worst converter first', () => { + const page = buildPage(); + expect(page.leakingProducts()[0].sku).toBe('LEAK-1'); + }); + + it('does not claim collection is off when it is on', () => { + expect(buildPage().storefrontOff()).toBe(false); + }); + + it('distinguishes "not collecting" from "nobody visited"', () => { + // An empty funnel and a disabled one look identical unless the page says so. + const page = buildPage({ + storefront: () => + of({ + enabled: false, + page_views: 0, + steps: [], + top_paths: [], + product_interest: [], + }), + }); + + expect(page.storefrontOff()).toBe(true); + expect(page.storefrontChart()).toBeNull(); + }); + + it('flags a partial view when the storefront panel fails', () => { + const page = buildPage({ storefront: () => of(null) }); + expect(page.partial()).toBe(true); + }); +}); diff --git a/src/app/features/analytics/analytics.page.ts b/src/app/features/analytics/analytics.page.ts index a22f329..9585a0b 100644 --- a/src/app/features/analytics/analytics.page.ts +++ b/src/app/features/analytics/analytics.page.ts @@ -10,6 +10,7 @@ import { rangeEndingToday } from '../../core/date-range'; import { AnalyticsFunnel, AnalyticsProducts, + AnalyticsStorefront, AnalyticsSummary, AnalyticsTimeseries, CurrencyTotals, @@ -70,6 +71,7 @@ export class AnalyticsPage { readonly series = signal(null); readonly products = signal(null); readonly funnel = signal(null); + readonly storefront = signal(null); /** Currency the charts are drawn in — the one with the most revenue. */ readonly primaryCurrency = computed(() => { @@ -181,6 +183,59 @@ export class AnalyticsPage { }; }); + /** + * The shopper funnel, drawn as a bar chart. + * + * The last step is tinted differently: it comes from the orders table and is + * exact, while every step before it depends on what browsers reported and is + * a floor. Drawing them identically would imply one continuous measurement. + */ + readonly storefrontChart = computed | null>(() => { + const steps = this.storefront()?.steps; + if (!steps?.length) { + return null; + } + return { + type: 'bar', + data: { + labels: steps.map((step) => step.label), + datasets: [ + { + label: 'Sessions', + data: steps.map((step) => step.sessions), + backgroundColor: steps.map((step) => (step.step === 'paid' ? BRAND : MUTED)), + borderRadius: 3, + }, + ], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { x: { beginAtZero: true, ticks: { precision: 0 } } }, + }, + }; + }); + + /** True when the deployment is collecting nothing at all. */ + readonly storefrontOff = computed(() => this.storefront()?.enabled === false); + + /** + * Products drawing interest without converting it. + * + * Sorted by the gap between being viewed and being added — a listing that + * attracts people and then loses them is invisible in sales figures, which + * only ever contain what did sell. + */ + readonly leakingProducts = computed(() => { + const rows = this.storefront()?.product_interest ?? []; + return [...rows] + .filter((row) => row.sessions_viewed >= 2 && (row.add_to_cart_rate ?? 1) < 0.5) + .sort((a, b) => (a.add_to_cart_rate ?? 1) - (b.add_to_cart_rate ?? 1)) + .slice(0, 5); + }); + readonly worstDropOff = computed(() => { const steps = this.funnel()?.steps ?? []; const withDrop = steps.filter((step) => (step.drop_off_from_previous ?? 0) > 0); @@ -219,14 +274,22 @@ export class AnalyticsPage { series: this.analytics.timeseries(range, this.interval()).pipe(catchError(() => of(null))), products: this.analytics.products(range, this.sort(), 15).pipe(catchError(() => of(null))), funnel: this.analytics.funnel(range).pipe(catchError(() => of(null))), + storefront: this.analytics.storefront(range).pipe(catchError(() => of(null))), }).subscribe((result) => { - if (!result.summary || !result.series || !result.products || !result.funnel) { + if ( + !result.summary || + !result.series || + !result.products || + !result.funnel || + !result.storefront + ) { this.partial.set(true); } this.summary.set(result.summary); this.series.set(result.series); this.products.set(result.products); this.funnel.set(result.funnel); + this.storefront.set(result.storefront); this.loading.set(false); }); }