feat(analytics): measure the location layer, and stop calling it direct #132

Merged
tudor merged 1 commits from feat/place-analytics into main 2026-08-27 07:29:48 +00:00
Owner

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.

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 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.
tudor added 1 commit 2026-08-27 07:23:42 +00:00
feat(analytics): measure the location layer, and stop calling it direct
PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m3s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 11s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 11s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 40s
d1a8596208
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

🤖 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.
## 🤖 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 main 2026-08-27 07:29:48 +00:00
Sign in to join this conversation.
No Reviewers
No labels
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tudor/school_compare#132