Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1a8596208 | ||
|
|
a3c09d9b67 | ||
|
|
a7f4c86464 | ||
|
|
0804566736 | ||
|
|
0b15497c09 |
No files matched your search
@@ -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));
|
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<string, unknown> }> = [];
|
||||||
|
await page.route('**/analytics.schoolcompare.co.uk/**', (route) => route.abort());
|
||||||
|
await page.exposeFunction('__capture',
|
||||||
|
(name: string, data: Record<string, unknown>) => { 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).
|
* School autosuggest (spec 2026-08-26).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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(<TrackPlaceView kind="authority" slug="kent" count={412} />);
|
||||||
|
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(<TrackPlaceView kind="town" slug="brentwood" count={29} phase="primary" />);
|
||||||
|
expect(trackMock).toHaveBeenCalledWith('place_viewed',
|
||||||
|
expect.objectContaining({ phase: 'primary' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires once, not once per render', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||||
|
rerender(<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||||
|
expect(trackMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TrackPlaceView kind="town" slug="brentwood" count={29} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hero search and the results filter bar are the same component in two
|
||||||
|
* costumes. `.filterBar` is the card — background, border, shadow, padding —
|
||||||
|
* and `.heroMode` strips all of it so the search sits directly on the hero
|
||||||
|
* panel.
|
||||||
|
*
|
||||||
|
* Both selectors have specificity (0,1,0), so **source order decides**, and
|
||||||
|
* `.heroMode` only wins because it is declared immediately after. Any later
|
||||||
|
* bare `.filterBar` rule — which in practice means one inside a media query —
|
||||||
|
* silently wins instead, and the hero grows a card's padding back.
|
||||||
|
*
|
||||||
|
* That is exactly what happened: `@media (max-width: 768px) { .filterBar {
|
||||||
|
* padding: 0.875rem } }` re-added 14px in hero mode, indenting the search box,
|
||||||
|
* the hint and the location link 14px past the headline above them and costing
|
||||||
|
* the search field 28px of width on a 390px screen. The two rules directly
|
||||||
|
* below it in the same block were correctly written as
|
||||||
|
* `.filterBar:not(.heroMode)`; this one was missed, and nothing caught it
|
||||||
|
* because the result is a plausible-looking layout rather than a broken one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CSS = path.join(__dirname, '..', '..', 'components', 'FilterBar.module.css');
|
||||||
|
|
||||||
|
/** Properties `.heroMode` resets. A later bare `.filterBar` rule setting any
|
||||||
|
* of these puts the card back on the hero. */
|
||||||
|
const RESET_BY_HERO_MODE = [
|
||||||
|
'background', 'border', 'border-radius', 'box-shadow', 'padding',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comments are stripped before anything is parsed.
|
||||||
|
*
|
||||||
|
* A `{` or `}` inside a comment would otherwise desynchronise the brace walk
|
||||||
|
* below and the rule regex alike, and the selector text captured for each rule
|
||||||
|
* would carry the preceding comment along with it.
|
||||||
|
*/
|
||||||
|
function withoutComments(css: string): string {
|
||||||
|
return css.replace(/\/\*[\s\S]*?\*\//g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The individual selectors in a rule's prelude.
|
||||||
|
*
|
||||||
|
* Split on commas, because a selector list is a list: `.filterBar, .other { }`
|
||||||
|
* applies to `.filterBar` just as surely as `.filterBar { }` does, and an
|
||||||
|
* earlier version of this guard compared the whole prelude against the literal
|
||||||
|
* string '.filterBar' — so writing the regression as a comma list, or across
|
||||||
|
* two lines, would have walked straight past it.
|
||||||
|
*/
|
||||||
|
function selectorsOf(prelude: string): string[] {
|
||||||
|
return prelude.split(',').map((sel) => sel.trim().replace(/\s+/g, ' '))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaQueryBodies(css: string): string[] {
|
||||||
|
const bodies: string[] = [];
|
||||||
|
const re = /@media[^{]*\{/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(css)) !== null) {
|
||||||
|
// Walk braces from the opening one to find this at-rule's whole body.
|
||||||
|
let depth = 1;
|
||||||
|
let i = m.index + m[0].length;
|
||||||
|
const start = i;
|
||||||
|
while (i < css.length && depth > 0) {
|
||||||
|
if (css[i] === '{') depth++;
|
||||||
|
else if (css[i] === '}') depth--;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
bodies.push(css.slice(start, i - 1));
|
||||||
|
}
|
||||||
|
return bodies;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FilterBar hero-mode scoping', () => {
|
||||||
|
const css = withoutComments(fs.readFileSync(CSS, 'utf8'));
|
||||||
|
|
||||||
|
it('confirms heroMode still resets the card, which is what makes this matter', () => {
|
||||||
|
const hero = css.match(/\.heroMode\s*\{([^}]*)\}/);
|
||||||
|
expect(hero).not.toBeNull();
|
||||||
|
expect(hero![1]).toMatch(/padding:\s*0/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never re-applies card styling to the hero from inside a media query', () => {
|
||||||
|
const offenders: string[] = [];
|
||||||
|
|
||||||
|
for (const body of mediaQueryBodies(css)) {
|
||||||
|
for (const rule of body.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
|
||||||
|
// Only a *bare* .filterBar is dangerous, and it is dangerous wherever
|
||||||
|
// it appears in a selector list. Scoped variants
|
||||||
|
// (`.filterBar:not(.heroMode)`) and descendants are fine.
|
||||||
|
const selectors = selectorsOf(rule[1]);
|
||||||
|
if (!selectors.includes('.filterBar')) continue;
|
||||||
|
|
||||||
|
for (const prop of RESET_BY_HERO_MODE) {
|
||||||
|
if (new RegExp(`(^|[;\\s])${prop}\\s*:`).test(rule[2])) {
|
||||||
|
offenders.push(`${rule[1].trim()} sets ${prop}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix by scoping the rule as `.filterBar:not(.heroMode)`, the way the
|
||||||
|
// neighbouring rules in the same block already are.
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -413,7 +413,17 @@
|
|||||||
/* ── Narrow ───────────────────────────────────────────────────────── */
|
/* ── Narrow ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.filterBar {
|
/*
|
||||||
|
* Scoped, like the two rules below it.
|
||||||
|
*
|
||||||
|
* The results filter bar is a card — background, border, shadow — and needs
|
||||||
|
* inner padding. The hero's search is not a card: .heroMode zeroes the
|
||||||
|
* padding, border and background so the search sits directly on the panel.
|
||||||
|
* Unscoped, this rule put 14px back, which indented the search box, the hint
|
||||||
|
* and the location link 14px past the headline they sit under, and cost the
|
||||||
|
* search field 28px of width on a 390px screen.
|
||||||
|
*/
|
||||||
|
.filterBar:not(.heroMode) {
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,6 +467,14 @@
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Optical alignment: the button's own 6px of padding is what makes its
|
||||||
|
label start further right than the hint above it, even once both boxes
|
||||||
|
share a left edge. Pulling the padding back off lines the text up while
|
||||||
|
keeping the tap target. */
|
||||||
|
.heroMode .nearMeBtn {
|
||||||
|
margin-left: -0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
.geoError {
|
.geoError {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { placeUrl, authoritySlug } from '@/lib/places';
|
|||||||
import type { School } from '@/lib/types';
|
import type { School } from '@/lib/types';
|
||||||
import { schoolUrl } from '@/lib/utils';
|
import { schoolUrl } from '@/lib/utils';
|
||||||
import { absoluteUrl } from '@/lib/site';
|
import { absoluteUrl } from '@/lib/site';
|
||||||
|
import { TrackPlaceView } from './TrackPlaceView';
|
||||||
import styles from './PlaceView.module.css';
|
import styles from './PlaceView.module.css';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -165,6 +166,11 @@ export function PlaceView({ detail, phase, englandAverage, neighbours }: Props)
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
|
{/* One line, and all four place families are measured, because they all
|
||||||
|
render through this component. */}
|
||||||
|
<TrackPlaceView kind={place.kind} slug={place.slug}
|
||||||
|
count={place.count} phase={phase} />
|
||||||
|
|
||||||
<script
|
<script
|
||||||
type="application/ld+json"
|
type="application/ld+json"
|
||||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fires `place_viewed` once per location page.
|
||||||
|
*
|
||||||
|
* A separate client component because PlaceView is a server component and
|
||||||
|
* cannot call into the browser. It renders nothing — its whole job is the
|
||||||
|
* effect, which keeps the page itself server-rendered.
|
||||||
|
*
|
||||||
|
* Umami already counts a pageview for every one of these URLs, so this is not
|
||||||
|
* about traffic. It is about `kind`: whether to keep investing in the location
|
||||||
|
* layer turns on which *sort* of page earns engagement — towns, authorities,
|
||||||
|
* London localities or postcode districts — and a pageview cannot say, because
|
||||||
|
* all four families share the /schools/ prefix and only the registry knows
|
||||||
|
* which is which.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
kind: string;
|
||||||
|
slug: string;
|
||||||
|
count: number;
|
||||||
|
phase?: 'primary' | 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrackPlaceView({ kind, slug, count, phase }: Props) {
|
||||||
|
useEffect(() => {
|
||||||
|
track('place_viewed', {
|
||||||
|
kind,
|
||||||
|
slug,
|
||||||
|
// "all" rather than omitting it, so the unphased page is a value in the
|
||||||
|
// same field rather than a gap that has to be interpreted.
|
||||||
|
phase: phase ?? 'all',
|
||||||
|
school_count: count,
|
||||||
|
// Internal navigation only. An arrival from Google reads as 'direct'
|
||||||
|
// here; Umami's own pageview referrer is where external attribution
|
||||||
|
// lives, and these pages exist to be arrived at externally.
|
||||||
|
from: getNavigationSource(),
|
||||||
|
});
|
||||||
|
// Once per place, not once per render.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [kind, slug, phase]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ export type EventName =
|
|||||||
| 'empty_results'
|
| 'empty_results'
|
||||||
// Engagement
|
// Engagement
|
||||||
| 'school_viewed'
|
| 'school_viewed'
|
||||||
|
| 'place_viewed'
|
||||||
| 'section_nav_used'
|
| 'section_nav_used'
|
||||||
| 'chart_metric_changed'
|
| 'chart_metric_changed'
|
||||||
| 'metric_compared_in_rankings'
|
| 'metric_compared_in_rankings'
|
||||||
@@ -56,7 +57,10 @@ export function track(name: EventName, data?: Payload): void {
|
|||||||
* Categorise where the user navigated from, for funnel attribution
|
* Categorise where the user navigated from, for funnel attribution
|
||||||
* (mostly used on school_viewed). Only checks same-origin referrers.
|
* (mostly used on school_viewed). Only checks same-origin referrers.
|
||||||
*/
|
*/
|
||||||
export function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'detail' | 'direct' {
|
export type NavigationSource =
|
||||||
|
'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct';
|
||||||
|
|
||||||
|
export function getNavigationSource(): NavigationSource {
|
||||||
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
||||||
try {
|
try {
|
||||||
const ref = new URL(document.referrer);
|
const ref = new URL(document.referrer);
|
||||||
@@ -65,6 +69,11 @@ export function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'deta
|
|||||||
if (p === '/' || p === '') return 'search';
|
if (p === '/' || p === '') return 'search';
|
||||||
if (p.startsWith('/rankings')) return 'rankings';
|
if (p.startsWith('/rankings')) return 'rankings';
|
||||||
if (p.startsWith('/compare')) return 'compare';
|
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';
|
if (p.startsWith('/school/')) return 'detail';
|
||||||
return 'direct';
|
return 'direct';
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in new issue
Block a user