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
+19
View File
@@ -15,6 +15,7 @@ import { LoadingSkeleton } from './LoadingSkeleton';
import type { ComparisonData, MetricDefinition, School } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
import { fetchComparison } from '@/lib/api';
import { track } from '@/lib/analytics';
import styles from './ComparisonView.module.css';
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
@@ -148,12 +149,28 @@ export function ComparisonView({
setSelectedMetric(defaultMetric);
};
// compare_viewed: fire once after the page has its first selection.
// We watch `selectedSchools.length` going from 0 → ≥1 so the event is
// sent only when there's actual content to view, not for empty arrivals.
const compareViewedRef = useRef(false);
useEffect(() => {
if (compareViewedRef.current) return;
if (selectedSchools.length === 0) return;
compareViewedRef.current = true;
const primaryCount = selectedSchools.filter(s => s.phase?.toLowerCase().includes('primary')).length;
const secondaryCount = selectedSchools.length - primaryCount;
const phaseMix = primaryCount === 0 ? 'all_secondary' : secondaryCount === 0 ? 'all_primary' : 'mixed';
track('compare_viewed', { school_count: selectedSchools.length, phase_mix: phaseMix });
}, [selectedSchools]);
const handleMetricChange = (metric: string) => {
track('compare_metric_changed', { metric, phase: comparePhase });
setSelectedMetric(metric);
};
const handleRemoveSchool = (urn: number) => {
removeSchool(urn);
track('compare_school_removed', { urn, from: 'compare' });
};
const handleShare = async () => {
@@ -172,6 +189,7 @@ export function ComparisonView({
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
try {
await navigator.share(shareData);
track('compare_shared', { method: 'native', school_count: count });
return;
} catch (err) {
// User cancelled — bail silently. Any other error falls through to clipboard.
@@ -180,6 +198,7 @@ export function ComparisonView({
}
try {
await navigator.clipboard.writeText(url);
track('compare_shared', { method: 'clipboard', school_count: count });
setShareConfirm(true);
setTimeout(() => setShareConfirm(false), 2000);
} catch { /* fallback: do nothing */ }
+26 -3
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { isValidPostcode } from "@/lib/utils";
import { track } from "@/lib/analytics";
import type { Filters, ResultFilters } from "@/lib/types";
import styles from "./FilterBar.module.css";
@@ -93,14 +94,36 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
return;
}
if (isValidPostcode(omniValue)) {
const isPostcode = isValidPostcode(omniValue);
const cleaned = omniValue.trim();
// Build a comma-separated active-filter list so a single search event
// captures the whole intent (vs firing N events as filters are picked).
const filters_active = [
currentPhase && `phase=${currentPhase}`,
currentLA && `la=${currentLA}`,
currentType && `type=${currentType}`,
currentGender && `gender=${currentGender}`,
currentAdmissionsPolicy && `admissions=${currentAdmissionsPolicy}`,
currentHasSixthForm && `sixth_form=${currentHasSixthForm}`,
].filter(Boolean).join(',');
track('search_submitted', {
query: isPostcode ? cleaned.toUpperCase() : cleaned.toLowerCase(),
via: 'input',
has_postcode: isPostcode,
filters_active,
filters_count: filters_active ? filters_active.split(',').length : 0,
});
if (isPostcode) {
updateURL({
postcode: omniValue.trim().toUpperCase(),
postcode: cleaned.toUpperCase(),
radius: currentRadius || "1",
search: "",
});
} else {
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
updateURL({ search: cleaned, postcode: "", radius: "" });
}
};
+4
View File
@@ -38,6 +38,8 @@ export function Footer() {
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-umami-event="external_link_clicked"
data-umami-event-target="dfe"
>
School Performance Tables
</a>
@@ -48,6 +50,8 @@ export function Footer() {
target="_blank"
rel="noopener noreferrer"
className={styles.link}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Ofsted reports
</a>
+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}
+7 -1
View File
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
import '@/lib/chartSetup';
import type { SchoolResult } from '@/lib/types';
import { formatAcademicYear } from '@/lib/utils';
import { track } from '@/lib/analytics';
import styles from './PerformanceChart.module.css';
interface NationalByYear {
@@ -389,7 +390,12 @@ export function PerformanceChart({
role="tab"
aria-selected={active}
disabled={!enabled}
onClick={() => setActiveChip(chip.id)}
onClick={() => {
if (chip.id !== activeChip) {
track('chart_metric_changed', { chip: chip.id, phase: isSecondary ? 'secondary' : 'primary', viewport: 'mobile' });
}
setActiveChip(chip.id);
}}
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
title={!enabled ? 'No data for this school' : undefined}
>
+3
View File
@@ -9,6 +9,7 @@ import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { useComparison } from '@/hooks/useComparison';
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
import { track } from '@/lib/analytics';
import { EmptyState } from './EmptyState';
import styles from './RankingsView.module.css';
@@ -79,6 +80,7 @@ export function RankingsView({
};
const handleMetricChange = (metric: string) => {
track('metric_compared_in_rankings', { metric, phase: selectedPhase });
updateFilters({ metric });
};
@@ -98,6 +100,7 @@ export function RankingsView({
latitude: null,
longitude: null,
} as any);
track('compare_school_added', { urn: ranking.urn, from: 'rankings' });
};
// Get metric definition
+24 -1
View File
@@ -23,6 +23,7 @@ import {
} from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import SatsChart from './SatsChart';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
@@ -121,11 +122,24 @@ export function SchoolDetailView({
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
// Page-view event with funnel attribution. Fires once per mount.
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: phase || 'unknown',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
const deprivationDesc = (decile: number) => {
if (decile <= 3) return `This school is in one of England's most deprived areas (decile ${decile}/10). Many pupils may face additional challenges at home.`;
if (decile <= 7) return `This school is in an area with average levels of deprivation (decile ${decile}/10).`;
@@ -261,7 +275,13 @@ export function SchoolDetailView({
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
@@ -395,6 +415,7 @@ export function SchoolDetailView({
key={id}
href={`#${id}`}
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
@@ -417,6 +438,8 @@ export function SchoolDetailView({
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Full report
</a>
@@ -10,6 +10,7 @@ import { Modal } from "./Modal";
import { useComparison } from "@/hooks/useComparison";
import { debounce } from "@/lib/utils";
import { fetchSchools } from "@/lib/api";
import { track } from "@/lib/analytics";
import type { School } from "@/lib/types";
import styles from "./SchoolSearchModal.module.css";
@@ -60,6 +61,11 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
const handleAddSchool = (school: School) => {
addSchool(school);
track('compare_school_added', {
urn: school.urn,
from: 'compare',
selection_count_after: selectedSchools.length + 1,
});
// Don't close modal, allow adding multiple schools
};
@@ -20,6 +20,7 @@ import type {
} from '@/lib/types';
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
import { DeltaChip } from './DeltaChip';
import { track, getNavigationSource } from '@/lib/analytics';
import styles from './SecondarySchoolDetailView.module.css';
const OFSTED_LABELS: Record<number, string> = {
@@ -111,11 +112,23 @@ export function SecondarySchoolDetailView({
const handleComparisonToggle = () => {
if (isInComparison) {
removeSchool(schoolInfo.urn);
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
} else {
addSchool(schoolInfo);
track('compare_school_added', { urn: schoolInfo.urn, from: 'detail' });
}
};
useEffect(() => {
track('school_viewed', {
urn: schoolInfo.urn,
phase: schoolInfo.phase || 'secondary',
local_authority: schoolInfo.local_authority || 'unknown',
from: getNavigationSource(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schoolInfo.urn]);
// Build nav items dynamically based on available data
const navItems: { id: string; label: string }[] = [];
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
@@ -210,7 +223,13 @@ export function SecondarySchoolDetailView({
)}
{schoolInfo.website && (
<span className={styles.headerDetail}>
<a href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`} target="_blank" rel="noopener noreferrer">
<a
href={/^https?:\/\//i.test(schoolInfo.website) ? schoolInfo.website : `https://${schoolInfo.website}`}
target="_blank"
rel="noopener noreferrer"
data-umami-event="external_link_clicked"
data-umami-event-target="school_website"
>
School website
</a>
</span>
@@ -318,6 +337,7 @@ export function SecondarySchoolDetailView({
key={id}
href={`#${id}`}
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
onClick={() => track('section_nav_used', { section: id })}
>
{label}
</a>
@@ -340,6 +360,8 @@ export function SecondarySchoolDetailView({
target="_blank"
rel="noopener noreferrer"
className={styles.ofstedReportLink}
data-umami-event="external_link_clicked"
data-umami-event-target="ofsted"
>
Full report
</a>