2026-05-19 22:04:22 +01:00
|
|
|
/**
|
|
|
|
|
* 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'
|
2026-08-27 08:23:21 +01:00
|
|
|
| 'place_viewed'
|
2026-05-19 22:04:22 +01:00
|
|
|
| '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'
|
2026-07-05 22:02:26 +01:00
|
|
|
| 'compare_focus_school'
|
2026-05-19 22:04:22 +01:00
|
|
|
// 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.
|
|
|
|
|
*/
|
2026-08-27 08:23:21 +01:00
|
|
|
export type NavigationSource =
|
|
|
|
|
'search' | 'rankings' | 'compare' | 'detail' | 'place' | 'direct';
|
|
|
|
|
|
2026-08-27 09:22:37 +01:00
|
|
|
/*
|
|
|
|
|
* 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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-27 08:23:21 +01:00
|
|
|
export function getNavigationSource(): NavigationSource {
|
2026-08-27 09:22:37 +01:00
|
|
|
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.
|
2026-05-19 22:04:22 +01:00
|
|
|
if (typeof window === 'undefined' || !document.referrer) return 'direct';
|
|
|
|
|
try {
|
|
|
|
|
const ref = new URL(document.referrer);
|
|
|
|
|
if (ref.origin !== window.location.origin) return 'direct';
|
2026-08-27 09:22:37 +01:00
|
|
|
return classifyPath(ref.pathname);
|
2026-05-19 22:04:22 +01:00
|
|
|
} 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';
|
|
|
|
|
}
|