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:
@@ -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 */ }
|
||||
|
||||
@@ -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: "" });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user