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
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
89 lines
2.9 KiB
TypeScript
89 lines
2.9 KiB
TypeScript
/**
|
|
* Analytics tracking for Umami.
|
|
*
|
|
* Single typed wrapper around `window.umami.track()`. All events flow
|
|
* through `track(name, data?)` — this gives us:
|
|
* - Refactor-safe event names (one place to maintain).
|
|
* - A schema for properties so we don't ship typos that fragment dashboards.
|
|
* - No-op on the server and never-throws semantics, so analytics outages
|
|
* can't take the app down.
|
|
*
|
|
* Umami is privacy-friendly (no cookies, no IPs, no PII), so it's safe to
|
|
* include school identifiers and full search query text.
|
|
*/
|
|
|
|
export type EventName =
|
|
// Discovery
|
|
| 'search_submitted'
|
|
| 'near_me_used'
|
|
| 'empty_results'
|
|
// Engagement
|
|
| 'school_viewed'
|
|
| 'place_viewed'
|
|
| 'section_nav_used'
|
|
| 'chart_metric_changed'
|
|
| 'metric_compared_in_rankings'
|
|
| 'external_link_clicked'
|
|
// Conversion
|
|
| 'compare_school_added'
|
|
| 'compare_school_removed'
|
|
| 'compare_viewed'
|
|
| 'compare_metric_changed'
|
|
| 'compare_shared'
|
|
| 'compare_focus_school'
|
|
// Operational
|
|
| 'api_error'
|
|
| 'results_load_more';
|
|
|
|
type Primitive = string | number | boolean;
|
|
type Payload = Record<string, Primitive>;
|
|
|
|
/**
|
|
* Fire an event. No-ops if Umami isn't loaded yet (the script is `defer`)
|
|
* or if we're rendering server-side. Never throws.
|
|
*/
|
|
export function track(name: EventName, data?: Payload): void {
|
|
if (typeof window === 'undefined') return;
|
|
const umami = (window as unknown as { umami?: { track?: (n: string, d?: Payload) => void } }).umami;
|
|
if (!umami?.track) return;
|
|
try {
|
|
umami.track(name, data);
|
|
} catch {
|
|
// Analytics must never crash the app.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Categorise where the user navigated from, for funnel attribution
|
|
* (mostly used on school_viewed). Only checks same-origin referrers.
|
|
*/
|
|
export type NavigationSource =
|
|
'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct';
|
|
|
|
export function getNavigationSource(): NavigationSource {
|
|
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';
|
|
} catch {
|
|
return 'direct';
|
|
}
|
|
}
|
|
|
|
/** Split mobile vs desktop on a per-event basis. */
|
|
export function getViewport(): 'mobile' | 'desktop' {
|
|
if (typeof window === 'undefined') return 'desktop';
|
|
return window.matchMedia('(max-width: 640px)').matches ? 'mobile' : 'desktop';
|
|
}
|