Files
school_compare/nextjs-app/lib/analytics.ts
T
TudorandClaude Fable 5 22769b6295
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
feat(compare): readable comparison chart on mobile
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>
2026-07-05 22:02:26 +01:00

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';
}