Files

79 lines
2.5 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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'
| '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'
// 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 function getNavigationSource(): 'search' | 'rankings' | 'compare' | 'detail' | 'direct' {
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';
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';
}