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

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:
Tudor Sitaru
2026-05-19 22:04:22 +01:00
parent 4cfae93a0d
commit 7e182e88b2
11 changed files with 234 additions and 10 deletions
+34 -4
View File
@@ -16,6 +16,7 @@ import { useComparisonContext } from '@/context/ComparisonContext';
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
import type { SchoolsResponse, Filters, School } from '@/lib/types';
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
import { track } from '@/lib/analytics';
import styles from './HomeView.module.css';
interface HomeViewProps {
@@ -151,6 +152,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore) return;
track('results_load_more', { next_page: currentPage + 1 });
setIsLoadingMore(true);
try {
const params: Record<string, any> = {};
@@ -170,6 +172,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
const handleNearMe = useCallback(() => {
if (!navigator.geolocation) {
track('near_me_used', { outcome: 'unsupported' });
setGeoState('error');
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
return;
@@ -187,12 +190,16 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
if (data.result && data.result.length > 0) {
const postcode = data.result[0].postcode as string;
setGeoState('idle');
track('near_me_used', { outcome: 'granted' });
track('search_submitted', { query: postcode, via: 'near_me', has_postcode: true, filters_active: '', filters_count: 0 });
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
} else {
track('near_me_used', { outcome: 'no_postcode' });
setGeoState('error');
setGeoError('No postcode found near your location. Try entering one above.');
}
} catch {
track('near_me_used', { outcome: 'lookup_error' });
setGeoState('error');
setGeoError('Could not look up your location. Please try again.');
}
@@ -200,8 +207,10 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
(err) => {
setGeoState('error');
if (err.code === err.PERMISSION_DENIED) {
track('near_me_used', { outcome: 'denied' });
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
} else {
track('near_me_used', { outcome: 'error' });
setGeoError('Could not get your location. Please try again or enter a postcode.');
}
},
@@ -219,6 +228,27 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
return 0;
});
// Empty-results sentinel: track when a search returns nothing.
useEffect(() => {
if (!isSearchActive) return;
if (initialSchools.total !== 0) return;
track('empty_results', {
query_length: (searchParams.get('search') || '').length,
has_postcode: !!searchParams.get('postcode'),
});
}, [initialSchools.total, isSearchActive, searchParams]);
// Wrap addSchool with `from: 'search'` attribution so funnel reports can
// split which surface drives compare adds.
const addSchoolFromSearch = useCallback((school: School) => {
addSchool(school);
track('compare_school_added', {
urn: school.urn,
from: 'search',
selection_count_after: selectedSchools.length + 1,
});
}, [addSchool, selectedSchools.length]);
return (
<div className={styles.homeView}>
{/* Combined Hero + Search and Filters */}
@@ -620,7 +650,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
>
<CompactSchoolItem
school={school}
onAddToCompare={addSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
nationalAvgRwm={nationalAvgRwm}
/>
@@ -635,7 +665,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
<CompactSchoolItem
school={selectedMapSchool}
onAddToCompare={addSchool}
onAddToCompare={addSchoolFromSearch}
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
nationalAvgRwm={nationalAvgRwm}
/>
@@ -653,7 +683,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
key={school.urn}
school={school}
isLocationSearch={isLocationSearch}
onAddToCompare={addSchool}
onAddToCompare={addSchoolFromSearch}
onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
@@ -663,7 +693,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
key={school.urn}
school={school}
isLocationSearch={isLocationSearch}
onAddToCompare={addSchool}
onAddToCompare={addSchoolFromSearch}
onRemoveFromCompare={removeSchool}
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
nationalAvgRwm={nationalAvgRwm}