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); }); }