PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m12s
PR Checks / Backend Smoke (pull_request) Successful in 10s
PR Checks / Build Backend (no push) (pull_request) Successful in 33s
PR Checks / Build Frontend (no push) (pull_request) Successful in 1m19s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 1m16s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 26s
The List/Map switch was a small grey control beside the results heading, and the filters were plain dropdowns labelled "All Phases" and "Advanced". Both scrolled away with the first result. Search, filters and the List/Map switch now share one card pinned under the header. Distance, phase and school type are pill controls in the row; "Advanced" becomes "More filters" and counts only what it hides. The switch is filled brand teal and says which view is on (aria-pressed). On phones the search folds to a one-line summary once made, the filter pills scroll sideways, and a floating Map/List button sits above the tab bar in place of the toolbar switch. The selected pin's card now stacks under that button instead of covering the tab bar. Switching view from far down the list scrolls back to the top of the results, and each switch is tracked as results_view_changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
145 lines
5.1 KiB
TypeScript
145 lines
5.1 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'
|
|
| 'results_view_changed';
|
|
|
|
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';
|
|
|
|
/*
|
|
* 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';
|
|
}
|