diff --git a/nextjs-app/__tests__/components/RouteTrail.test.tsx b/nextjs-app/__tests__/components/RouteTrail.test.tsx new file mode 100644 index 0000000..cc95c9e --- /dev/null +++ b/nextjs-app/__tests__/components/RouteTrail.test.tsx @@ -0,0 +1,38 @@ +/** + * The trail has to be written by something, and it has to be written on every + * route — not only the ones that happen to track an event. + */ +import { render } from '@testing-library/react'; + +const recordVisitedPath = jest.fn(); +let pathname = '/schools/brentwood'; + +jest.mock('next/navigation', () => ({ usePathname: () => pathname })); +jest.mock('@/lib/analytics', () => ({ + recordVisitedPath: (p: string) => recordVisitedPath(p), +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { RouteTrail } = require('@/components/RouteTrail'); + +describe('RouteTrail', () => { + beforeEach(() => recordVisitedPath.mockClear()); + + it('records the page it is mounted on', () => { + render(); + expect(recordVisitedPath).toHaveBeenCalledWith('/schools/brentwood'); + }); + + it('records each new route as the user moves through the app', () => { + const { rerender } = render(); + pathname = '/school/115429-brentwood-school'; + rerender(); + expect(recordVisitedPath).toHaveBeenLastCalledWith( + '/school/115429-brentwood-school'); + }); + + it('renders nothing, so it can sit anywhere in the layout', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/nextjs-app/__tests__/lib/analytics.test.ts b/nextjs-app/__tests__/lib/analytics.test.ts index 22af2d7..96dd0fb 100644 --- a/nextjs-app/__tests__/lib/analytics.test.ts +++ b/nextjs-app/__tests__/lib/analytics.test.ts @@ -62,3 +62,97 @@ describe('getNavigationSource', () => { expect(getNavigationSource()).toBe('direct'); }); }); + +/* + * The defect the existing suite could not see. + * + * Every test above sets document.referrer, which the browser writes only when + * a *document* loads. Every internal navigation in this app is an App Router + * soft navigation — history.pushState, no new document — so document.referrer + * keeps naming whatever opened the tab for the whole session. Verified on + * staging: /schools/brentwood → click a school → URL changes to /school/… + * and document.referrer is still "". + * + * So `from` reported 'direct' for essentially every in-app journey, and the + * suite passed because it only ever exercised the full-page-load path. + */ +function freshAnalytics() { + let mod!: typeof import('@/lib/analytics'); + jest.isolateModules(() => { + mod = require('@/lib/analytics'); + }); + return mod; +} + +function at(path: string) { + window.history.pushState({}, '', path); +} + +describe('getNavigationSource across a soft navigation', () => { + afterEach(() => { + referrer(''); + at('/'); + }); + + it('attributes a school view to the place page the user actually came from', () => { + const { recordVisitedPath, getNavigationSource: source } = freshAnalytics(); + at('/schools/brentwood'); + recordVisitedPath('/schools/brentwood'); + + at('/school/115429-brentwood-school'); + recordVisitedPath('/school/115429-brentwood-school'); + + expect(source()).toBe('place'); + }); + + it('does not depend on whether the new path was recorded first', () => { + // The trail is written by a layout-level effect and read by a page-level + // one. React orders those by tree position, which is not a contract worth + // resting a measurement on, so the answer must be the same either way. + const { recordVisitedPath, getNavigationSource: source } = freshAnalytics(); + recordVisitedPath('/rankings'); + at('/school/115429-brentwood-school'); + + expect(source()).toBe('rankings'); + }); + + it('names the previous page, not the current one, when both are schools', () => { + const { recordVisitedPath, getNavigationSource: source } = freshAnalytics(); + at('/school/100010-brecknock-primary-school'); + recordVisitedPath('/school/100010-brecknock-primary-school'); + + at('/school/115429-brentwood-school'); + recordVisitedPath('/school/115429-brentwood-school'); + + expect(source()).toBe('detail'); + }); + + it('looks past a return visit to the page the user came back from', () => { + const { recordVisitedPath, getNavigationSource: source } = freshAnalytics(); + for (const p of ['/schools/brentwood', '/school/115429-brentwood-school', + '/schools/brentwood']) { + at(p); + recordVisitedPath(p); + } + expect(source()).toBe('detail'); + }); + + it('falls back to the referrer on a real document load, where it is true', () => { + // A fresh module is a fresh document: nothing has been recorded, and + // document.referrer is meaningful again. + const { getNavigationSource: source } = freshAnalytics(); + at('/school/115429-brentwood-school'); + referrer(`${ORIGIN}/schools/barnet`); + + expect(source()).toBe('place'); + }); + + it('still reads an arrival from outside as direct', () => { + const { recordVisitedPath, getNavigationSource: source } = freshAnalytics(); + at('/schools/brentwood'); + recordVisitedPath('/schools/brentwood'); + referrer('https://www.google.com/search?q=schools+in+brentwood'); + + expect(source()).toBe('direct'); + }); +}); diff --git a/nextjs-app/app/layout.tsx b/nextjs-app/app/layout.tsx index 2f888b9..96a2be4 100644 --- a/nextjs-app/app/layout.tsx +++ b/nextjs-app/app/layout.tsx @@ -4,6 +4,7 @@ import Script from 'next/script'; import { Navigation } from '@/components/Navigation'; import { Footer } from '@/components/Footer'; import { ComparisonToast } from '@/components/ComparisonToast'; +import { RouteTrail } from '@/components/RouteTrail'; import { ComparisonProvider } from '@/context/ComparisonProvider'; import { SITE_URL } from '@/lib/site'; import './globals.css'; @@ -114,6 +115,10 @@ export default function RootLayout({ /> + {/* Records every route so funnel attribution has a previous page to + name. document.referrer cannot: a soft navigation creates no + document, so the browser never updates it. */} + Skip to main content diff --git a/nextjs-app/components/RouteTrail.tsx b/nextjs-app/components/RouteTrail.tsx new file mode 100644 index 0000000..4531693 --- /dev/null +++ b/nextjs-app/components/RouteTrail.tsx @@ -0,0 +1,27 @@ +/** + * Writes the in-app navigation trail that funnel attribution reads. + * + * Renders nothing. It exists because document.referrer cannot answer "which + * page did they come from" in an App Router app: a soft navigation creates no + * document, so the browser never updates it. See the trail comment in + * lib/analytics.ts. + * + * Mounted once in the root layout, so every route is recorded — including the + * ones that fire no event of their own, which are still somebody else's + * previous page. + */ +'use client'; + +import { useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import { recordVisitedPath } from '@/lib/analytics'; + +export function RouteTrail() { + const pathname = usePathname(); + + useEffect(() => { + recordVisitedPath(pathname); + }, [pathname]); + + return null; +} diff --git a/nextjs-app/lib/analytics.ts b/nextjs-app/lib/analytics.ts index bd05ba1..591ed7d 100644 --- a/nextjs-app/lib/analytics.ts +++ b/nextjs-app/lib/analytics.ts @@ -60,22 +60,77 @@ export function track(name: EventName, data?: Payload): void { export type NavigationSource = 'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct'; +/* + * The in-app trail. + * + * document.referrer is written by the browser only when a *document* loads. + * Every internal navigation here is an App Router soft navigation — + * history.pushState, no new document — so document.referrer goes on naming + * whatever opened the tab (usually nothing, or a search engine) for the whole + * session. Reading it to answer "which page did they come from" therefore + * returned 'direct' for essentially every in-app journey, including the one + * the location layer exists to produce. + * + * Verified on staging: /schools/brentwood, click a school, the URL becomes + * /school/… and document.referrer is still "". + * + * A module-level trail is the counterpart with exactly the right lifetime. It + * survives soft navigation, and it dies on a real document load — which is + * precisely when document.referrer becomes meaningful again, so the two cover + * each other with no overlap. + */ +const TRAIL_LIMIT = 4; +const trail: string[] = []; + +/** Record a path the user is now on. Called by RouteTrail on every route. */ +export function recordVisitedPath(path: string): void { + if (trail[trail.length - 1] === path) return; + trail.push(path); + if (trail.length > TRAIL_LIMIT) trail.shift(); +} + +/** + * The most recent path that is not the one being viewed. + * + * Skipping the current path rather than taking trail[length - 2] is what + * makes the answer independent of ordering: the trail is written by a + * layout-level effect and read by a page-level one, and React orders those by + * tree position — not a contract worth resting a measurement on. It also + * gives the right answer when the user goes back to a page they came from. + */ +function previousInAppPath(): string | null { + if (typeof window === 'undefined') return null; + const current = window.location.pathname; + for (let i = trail.length - 1; i >= 0; i -= 1) { + if (trail[i] !== current) return trail[i]; + } + return null; +} + +function classifyPath(p: string): NavigationSource { + if (p === '/' || p === '') return 'search'; + if (p.startsWith('/rankings')) return 'rankings'; + if (p.startsWith('/compare')) return 'compare'; + // `/schools/` before `/school/`: they differ by one letter and mean + // different things — the location layer versus a single school. Checked + // first so the narrower-looking prefix cannot shadow it if either string + // is ever edited. + if (p.startsWith('/schools/')) return 'place'; + if (p.startsWith('/school/')) return 'detail'; + return 'direct'; +} + export function getNavigationSource(): NavigationSource { + const internal = previousInAppPath(); + if (internal) return classifyPath(internal); + + // No trail means this is the first page of the document, so the referrer is + // the only witness — and an honest one. if (typeof window === 'undefined' || !document.referrer) return 'direct'; try { const ref = new URL(document.referrer); if (ref.origin !== window.location.origin) return 'direct'; - const p = ref.pathname; - if (p === '/' || p === '') return 'search'; - if (p.startsWith('/rankings')) return 'rankings'; - if (p.startsWith('/compare')) return 'compare'; - // `/schools/` before `/school/`: they differ by one letter and mean - // different things — the location layer versus a single school. Checked - // first so the narrower-looking prefix cannot shadow it if either string - // is ever edited. - if (p.startsWith('/schools/')) return 'place'; - if (p.startsWith('/school/')) return 'detail'; - return 'direct'; + return classifyPath(ref.pathname); } catch { return 'direct'; }