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 at all, and place_viewed was not even a declared event name.
The part that mattered was worse than a gap
getNavigationSource() maps a same-origin referrer to a funnel source, and had no case for /schools/:
if(p.startsWith('/rankings'))return'rankings';if(p.startsWith('/compare'))return'compare';if(p.startsWith('/school/'))return'detail';return'direct';// <- every location page landed here
So every school view arriving through the location layer was filed as direct — the bucket you read as "typed the URL, no referrer". W2's entire 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 before fixing:Expected: "place", Received: "direct".
/schools/ is now checked before/school/. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test written 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 — towns, authorities, London localities, postcode districts — and a pageview cannot say, because all four families share the /schools/ prefix and only the registry knows which is which. That is also the W2 stop condition we agreed: if coverage stalls, rethink the template rather than add pages.
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.
from is internal navigation only — an arrival from Google reads as direct there, and Umami's own pageview referrer is where external attribution lives. Noted in the code so nobody reads it as "no one came from search".
Verification
Both E2E journeys were run against staging first and confirmed failing — one because place_viewed does not exist there, the other on the exact place/direct mismatch. Then made to pass.
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 at all, and `place_viewed` was not even a declared event name.
## The part that mattered was worse than a gap
`getNavigationSource()` maps a same-origin referrer to a funnel source, and had no case for `/schools/`:
```ts
if (p.startsWith('/rankings')) return 'rankings';
if (p.startsWith('/compare')) return 'compare';
if (p.startsWith('/school/')) return 'detail';
return 'direct'; // <- every location page landed here
```
So every school view arriving through the location layer was filed as **`direct`** — the bucket you read as "typed the URL, no referrer". W2's entire 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 before fixing:** `Expected: "place"`, `Received: "direct"`.
`/schools/` is now checked *before* `/school/`. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test written 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 — towns, authorities, London localities, postcode districts — and a pageview cannot say, because all four families share the `/schools/` prefix and only the registry knows which is which. That is also the W2 stop condition we agreed: if coverage stalls, rethink the template rather than add pages.
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.
`from` is internal navigation only — an arrival from Google reads as `direct` there, and Umami's own pageview referrer is where external attribution lives. Noted in the code so nobody reads it as "no one came from search".
## Verification
Both E2E journeys were **run against staging first and confirmed failing** — one because `place_viewed` does not exist there, the other on the exact `place`/`direct` mismatch. Then made to pass.
Frontend 305 passed, `tsc` clean, `next build` green, 102 E2E collected.
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mWQnpye9F299NVRCCSRvj
This PR adds a place_viewed analytics event fired once per location-page view via a new client-only TrackPlaceView component, and extends getNavigationSource with a place bucket so school views reached via the location layer are no longer misattributed to direct. The change is small, well-scoped, and backed by solid unit and e2e test coverage, including a regression test for the exact prefix-ordering bug (/schools/ vs /school/) it fixes.
🟡 Minor
nextjs-app/components/places/TrackPlaceView.tsx: The effect's dependency array omits count even though count is read inside it, silenced by an eslint-disable. If a location page's school count could ever change without a remount (e.g. future client-side revalidation), the tracked school_count would be stale until kind/slug/phase also changed. Low risk today since these are effectively static per page load, but worth a comment noting the assumption if it isn't already documented elsewhere.
## 🤖 AI Code Review (Claude Code)
This PR adds a `place_viewed` analytics event fired once per location-page view via a new client-only `TrackPlaceView` component, and extends `getNavigationSource` with a `place` bucket so school views reached via the location layer are no longer misattributed to `direct`. The change is small, well-scoped, and backed by solid unit and e2e test coverage, including a regression test for the exact prefix-ordering bug (`/schools/` vs `/school/`) it fixes.
### 🟡 Minor
- **nextjs-app/components/places/TrackPlaceView.tsx**: The effect's dependency array omits `count` even though `count` is read inside it, silenced by an eslint-disable. If a location page's school count could ever change without a remount (e.g. future client-side revalidation), the tracked `school_count` would be stale until kind/slug/phase also changed. Low risk today since these are effectively static per page load, but worth a comment noting the assumption if it isn't already documented elsewhere.
tudor
merged commit ade9dbb3ba into main2026-08-27 07:29:48 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 notrack()call at all, andplace_viewedwas not even a declared event name.The part that mattered was worse than a gap
getNavigationSource()maps a same-origin referrer to a funnel source, and had no case for/schools/:So every school view arriving through the location layer was filed as
direct— the bucket you read as "typed the URL, no referrer". W2's entire 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 before fixing:
Expected: "place",Received: "direct"./schools/is now checked before/school/. They differ by one letter and mean different things — the location layer versus a single school — and a prefix test written in the wrong order silently merges them.place_viewedCarries
kind,slug,phaseandschool_count.kindis the reason it exists. Whether to keep investing in these pages turns on which sort earns engagement — towns, authorities, London localities, postcode districts — and a pageview cannot say, because all four families share the/schools/prefix and only the registry knows which is which. That is also the W2 stop condition we agreed: if coverage stalls, rethink the template rather than add pages.It is a client component because
PlaceViewis a server component. One line inPlaceViewcovers all four families, since they all render through it.fromis internal navigation only — an arrival from Google reads asdirectthere, and Umami's own pageview referrer is where external attribution lives. Noted in the code so nobody reads it as "no one came from search".Verification
Both E2E journeys were run against staging first and confirmed failing — one because
place_vieweddoes not exist there, the other on the exactplace/directmismatch. Then made to pass.Frontend 305 passed,
tscclean,next buildgreen, 102 E2E collected.🤖 AI Code Review (Claude Code)
This PR adds a
place_viewedanalytics event fired once per location-page view via a new client-onlyTrackPlaceViewcomponent, and extendsgetNavigationSourcewith aplacebucket so school views reached via the location layer are no longer misattributed todirect. The change is small, well-scoped, and backed by solid unit and e2e test coverage, including a regression test for the exact prefix-ordering bug (/schools/vs/school/) it fixes.🟡 Minor
counteven thoughcountis read inside it, silenced by an eslint-disable. If a location page's school count could ever change without a remount (e.g. future client-side revalidation), the trackedschool_countwould be stale until kind/slug/phase also changed. Low risk today since these are effectively static per page load, but worth a comment noting the assumption if it isn't already documented elsewhere.