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. */} + +