/** * 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' | 'results_view_changed'; type Primitive = string | number | boolean; type Payload = Record; /** * 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'; /* * 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'; } export function getNavigationSource(): NavigationSource { 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. if (typeof window === 'undefined' || !document.referrer) return 'direct'; try { const ref = new URL(document.referrer); if (ref.origin !== window.location.origin) return 'direct'; return classifyPath(ref.pathname); } 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'; }