feat(analytics): typed Umami event taxonomy across the funnel
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Build and Push Docker Images / Build Backend (FastAPI) (push) Successful in 18s
Build and Push Docker Images / Build Frontend (Next.js) (push) Successful in 57s
Build and Push Docker Images / Build Pipeline (Meltano + dbt + Airflow) (push) Successful in 12s
Build and Push Docker Images / Trigger Portainer Update (push) Successful in 1s
Add lib/analytics.ts with a single typed track() wrapper. SSR-safe,
never throws, no-ops when Umami isn't loaded. Event names form a
fixed union so refactors stay safe.
14 events wired:
Discovery (3)
search_submitted FilterBar submit + near_me path
near_me_used all geolocation outcomes
empty_results search returns 0 schools
Engagement (5)
school_viewed SchoolDetail + Secondary on mount, with
urn / phase / local_authority / from
section_nav_used section-nav links on both detail views
chart_metric_changed mobile chart chip switch
metric_compared_in_rankings rankings metric dropdown
external_link_clicked Ofsted / school website / DfE (declarative
data-umami-event attributes)
Conversion (5)
compare_school_added search/rankings/detail/compare sources
compare_school_removed detail toggle and compare page
compare_viewed once per session when there's a selection
(school_count, phase_mix)
compare_metric_changed compare page metric dropdown
compare_shared native sheet vs clipboard distinguished
Operational (1)
api_error caught in handleResponse, includes
endpoint / status / route
Suggested Goals to configure in the Umami dashboard for the funnel
report: search_submitted → school_viewed → compare_school_added →
compare_viewed → compare_shared.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
@@ -65,6 +65,16 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
||||
// If parsing JSON fails, use the default error
|
||||
}
|
||||
|
||||
// Client-side: report to analytics so we can spot silent failures.
|
||||
// No-ops on SSR (track guards against missing window).
|
||||
if (typeof window !== 'undefined') {
|
||||
const { track } = await import('./analytics');
|
||||
try {
|
||||
const endpoint = new URL(response.url).pathname;
|
||||
track('api_error', { endpoint, status: response.status, route: window.location.pathname });
|
||||
} catch { /* never */ }
|
||||
}
|
||||
|
||||
throw new APIFetchError(
|
||||
`API request failed: ${errorDetail}`,
|
||||
response.status,
|
||||
|
||||
Reference in New Issue
Block a user