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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user