PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 9m36s
PR Checks / Backend Smoke (pull_request) Successful in 5s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 44s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 2m49s
The compare chart squashed clustered schools into a thin band (y pinned 0-100) under an in-chart title + per-school legend that ate ~40% of a 300px card, leaving converging lines indistinguishable on phones. - Auto-fit the y-axis to the data on all viewports (computeYBounds in lib/utils: padded + min-span for percentages, symmetric around 0 for progress, fitted for scores; negative pct-named trend metrics are not zero-clamped). - Distinct point style per school (circle/triangle/rect/rectRot/star) as secondary encoding for convergence and colour-blindness. - Mobile: drop in-chart title/legend/axis titles; add a chip row (colour dot + name) that doubles as tap-to-focus — highlights one school's line and dims the rest. Chart card 300px -> 340px, nearly all plot. - Fix a latent colour mismatch: datasets were built from Object.entries whose integer-like URN keys enumerate in ascending numeric order, desyncing line colours from card colours; the chart now receives the ordered school list. - Union years across schools instead of taking the first school's. - Extract PerformanceChart's matchMedia pattern into hooks/useIsMobile. Unit tests for metricKind/computeYBounds; e2e journey covers the mobile chips and focus toggle. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
80 lines
2.5 KiB
TypeScript
80 lines
2.5 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'
|
|
| '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 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';
|
|
}
|