From d1a8596208e34946d4be4a8c99d6fab513a0c62d Mon Sep 17 00:00:00 2001 From: Tudor Date: Thu, 27 Aug 2026 08:23:21 +0100 Subject: [PATCH] feat(analytics): measure the location layer, and stop calling it direct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The location pages were only half-tracked. Umami counts a pageview for each of the ~3,900 URLs automatically, but nothing else: components/ places contained no track() call, and place_viewed was not even a declared event name. The part that mattered was worse than a gap. getNavigationSource mapped a same-origin referrer to a funnel source and had no case for /schools/, so every school view arriving through the location layer fell through to 'direct' — the bucket you read as "typed the URL, no referrer". W2's whole purpose is funnelling search traffic onto school pages, so the one measurement that says whether it worked was reporting the wrong answer, and reporting it confidently. Verified live against staging: expected "place", received "direct". /schools/ is checked before /school/. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test in the wrong order silently merges them. place_viewed carries kind, slug, phase and school_count. kind is the reason it exists: whether to keep investing in these pages turns on which sort earns engagement, and a pageview cannot say, because all four families share the /schools/ prefix and only the registry knows which is which. It is a client component because PlaceView is a server component; one line in PlaceView covers all four families, since they all render through it. Both E2E journeys were verified failing against staging first — one because place_viewed does not exist there, the other on the exact "place" vs "direct" mismatch. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj --- e2e/tests/journeys.spec.ts | 60 +++++++++++++++++ .../components/TrackPlaceView.test.tsx | 45 +++++++++++++ nextjs-app/__tests__/lib/analytics.test.ts | 64 +++++++++++++++++++ nextjs-app/components/places/PlaceView.tsx | 6 ++ .../components/places/TrackPlaceView.tsx | 47 ++++++++++++++ nextjs-app/lib/analytics.ts | 11 +++- 6 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 nextjs-app/__tests__/components/TrackPlaceView.test.tsx create mode 100644 nextjs-app/__tests__/lib/analytics.test.ts create mode 100644 nextjs-app/components/places/TrackPlaceView.tsx diff --git a/e2e/tests/journeys.spec.ts b/e2e/tests/journeys.spec.ts index dc25940..1a55540 100644 --- a/e2e/tests/journeys.spec.ts +++ b/e2e/tests/journeys.spec.ts @@ -2132,6 +2132,66 @@ test('the rankings page still orders by score, not name', async ({ page }) => { expect(scores).toEqual([...scores].sort((a: number, b: number) => b - a)); }); +/* + * Analytics on the location layer. + * + * Umami counts a pageview for every one of these URLs already. What it cannot + * say is which *kind* of location page earns engagement, because all four + * families share the /schools/ prefix — and that is the question that decides + * whether to keep investing in them. + */ + +/** Capture Umami events, with the real script blocked so it cannot clobber + * the stub. Must be called before the first navigation. */ +async function captureEvents(page: Page) { + const events: Array<{ name: string; data: Record }> = []; + await page.route('**/analytics.schoolcompare.co.uk/**', (route) => route.abort()); + await page.exposeFunction('__capture', + (name: string, data: Record) => { events.push({ name, data }); }); + await page.addInitScript(() => { + (window as unknown as { umami: unknown }).umami = { + track: (name: string, data: unknown) => + (window as unknown as { __capture: (n: string, d: unknown) => void }) + .__capture(name, data), + }; + }); + return events; +} + +test('a location page reports which kind of place it is', async ({ page }) => { + const events = await captureEvents(page); + const place = await firstPlaceOfKind(page, 'authority'); + + await page.goto(`/schools/authority/${place.slug}`); + await expect.poll(() => events.find((e) => e.name === 'place_viewed'), + { timeout: 10_000 }).toBeTruthy(); + + const event = events.find((e) => e.name === 'place_viewed')!; + expect(event.data.kind).toBe('authority'); + expect(event.data.slug).toBe(place.slug); + expect(event.data.phase).toBe('all'); +}); + +test('a school reached from a location page is attributed to it, not to direct', async ({ page }) => { + /* + * The defect this was written for. getNavigationSource had no case for + * /schools/, so every school view that came through the location layer was + * filed as 'direct' — the bucket you read as "typed the URL". The one + * measurement that says whether ~3,900 SEO pages work was reporting the + * wrong answer, confidently. + */ + const events = await captureEvents(page); + const place = await firstPlaceOfKind(page, 'town'); + + await page.goto(`/schools/${place.slug}`); + await page.locator('a[href^="/school/"]').first().click(); + await page.waitForURL(/\/school\//); + + await expect.poll(() => events.find((e) => e.name === 'school_viewed'), + { timeout: 10_000 }).toBeTruthy(); + expect(events.find((e) => e.name === 'school_viewed')!.data.from).toBe('place'); +}); + /* * School autosuggest (spec 2026-08-26). */ diff --git a/nextjs-app/__tests__/components/TrackPlaceView.test.tsx b/nextjs-app/__tests__/components/TrackPlaceView.test.tsx new file mode 100644 index 0000000..e8b22a4 --- /dev/null +++ b/nextjs-app/__tests__/components/TrackPlaceView.test.tsx @@ -0,0 +1,45 @@ +import { render } from '@testing-library/react'; +import { TrackPlaceView } from '@/components/places/TrackPlaceView'; + +const trackMock = jest.fn(); +jest.mock('@/lib/analytics', () => ({ + track: (...args: unknown[]) => trackMock(...args), + getNavigationSource: () => 'search', +})); + +describe('TrackPlaceView', () => { + beforeEach(() => trackMock.mockClear()); + + it('reports which kind of location page was viewed', () => { + /* + * `kind` is the reason this event exists. Whether to keep investing in the + * location layer turns on which *sort* of page earns engagement — towns, + * authorities or postcode districts — and a bare pageview cannot say, + * because all four families share the /schools/ prefix. + */ + render(); + expect(trackMock).toHaveBeenCalledWith('place_viewed', { + kind: 'authority', slug: 'kent', phase: 'all', + school_count: 412, from: 'search', + }); + }); + + it('names the phase when the page is a phase variant', () => { + render(); + expect(trackMock).toHaveBeenCalledWith('place_viewed', + expect.objectContaining({ phase: 'primary' })); + }); + + it('fires once, not once per render', () => { + const { rerender } = render( + ); + rerender(); + expect(trackMock).toHaveBeenCalledTimes(1); + }); + + it('renders nothing', () => { + const { container } = render( + ); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/nextjs-app/__tests__/lib/analytics.test.ts b/nextjs-app/__tests__/lib/analytics.test.ts new file mode 100644 index 0000000..22af2d7 --- /dev/null +++ b/nextjs-app/__tests__/lib/analytics.test.ts @@ -0,0 +1,64 @@ +import { getNavigationSource } from '@/lib/analytics'; + +/** jsdom's document.referrer is read-only; redefining it is the way in. */ +function referrer(url: string) { + Object.defineProperty(document, 'referrer', { value: url, configurable: true }); +} + +const ORIGIN = 'http://localhost'; + +describe('getNavigationSource', () => { + afterEach(() => referrer('')); + + it('attributes a visit from a location page to the place layer', () => { + /* + * The one this was added for. + * + * W2 published ~3,900 location pages whose entire purpose is to funnel + * search traffic onto school pages. Before this case existed they fell + * through to 'direct' — so the location layer's contribution was not + * merely missing from the funnel, it was being counted in the bucket you + * read as "typed the URL". The measurement that decides whether W2 worked + * was confidently reporting the wrong answer. + */ + referrer(`${ORIGIN}/schools/barnet`); + expect(getNavigationSource()).toBe('place'); + }); + + it.each([ + ['/schools/authority/kent', 'authority'], + ['/schools/near/sw11', 'outcode'], + ['/schools/brentwood/primary', 'phase variant'], + ])('covers %s (%s)', (path) => { + referrer(`${ORIGIN}${path}`); + expect(getNavigationSource()).toBe('place'); + }); + + it('still calls a school page "detail", one character away', () => { + // /school/ and /schools/ differ by one letter and mean different things. + // A prefix test written in the wrong order silently merges them. + referrer(`${ORIGIN}/school/100010-brecknock-primary-school`); + expect(getNavigationSource()).toBe('detail'); + }); + + it.each([ + ['/', 'search'], + ['/rankings', 'rankings'], + ['/compare?urns=1,2', 'compare'], + ])('leaves %s attributed as %s', (path, expected) => { + referrer(`${ORIGIN}${path}`); + expect(getNavigationSource()).toBe(expected); + }); + + it('treats an external referrer as direct', () => { + // Umami records the real referrer on the pageview; this field is only + // about internal navigation. + referrer('https://www.google.com/search?q=schools+in+barnet'); + expect(getNavigationSource()).toBe('direct'); + }); + + it('treats no referrer as direct', () => { + referrer(''); + expect(getNavigationSource()).toBe('direct'); + }); +}); diff --git a/nextjs-app/components/places/PlaceView.tsx b/nextjs-app/components/places/PlaceView.tsx index 20fc831..3aa90ac 100644 --- a/nextjs-app/components/places/PlaceView.tsx +++ b/nextjs-app/components/places/PlaceView.tsx @@ -15,6 +15,7 @@ import { placeUrl, authoritySlug } from '@/lib/places'; import type { School } from '@/lib/types'; import { schoolUrl } from '@/lib/utils'; import { absoluteUrl } from '@/lib/site'; +import { TrackPlaceView } from './TrackPlaceView'; import styles from './PlaceView.module.css'; interface Props { @@ -165,6 +166,11 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props) return (
+ {/* One line, and all four place families are measured, because they all + render through this component. */} + +