diff --git a/nextjs-app/components/ComparisonView.tsx b/nextjs-app/components/ComparisonView.tsx index f97e679..9a62c05 100644 --- a/nextjs-app/components/ComparisonView.tsx +++ b/nextjs-app/components/ComparisonView.tsx @@ -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 */ } diff --git a/nextjs-app/components/FilterBar.tsx b/nextjs-app/components/FilterBar.tsx index 708468b..4c7731f 100644 --- a/nextjs-app/components/FilterBar.tsx +++ b/nextjs-app/components/FilterBar.tsx @@ -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: "" }); } }; diff --git a/nextjs-app/components/Footer.tsx b/nextjs-app/components/Footer.tsx index be11755..16fb8f7 100644 --- a/nextjs-app/components/Footer.tsx +++ b/nextjs-app/components/Footer.tsx @@ -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 @@ -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 diff --git a/nextjs-app/components/HomeView.tsx b/nextjs-app/components/HomeView.tsx index 53a3531..7f74143 100644 --- a/nextjs-app/components/HomeView.tsx +++ b/nextjs-app/components/HomeView.tsx @@ -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 = {}; @@ -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 (
{/* Combined Hero + Search and Filters */} @@ -620,7 +650,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp > s.urn === school.urn)} nationalAvgRwm={nationalAvgRwm} /> @@ -635,7 +665,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp 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} diff --git a/nextjs-app/components/PerformanceChart.tsx b/nextjs-app/components/PerformanceChart.tsx index 30b7d32..9870ec9 100644 --- a/nextjs-app/components/PerformanceChart.tsx +++ b/nextjs-app/components/PerformanceChart.tsx @@ -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} > diff --git a/nextjs-app/components/RankingsView.tsx b/nextjs-app/components/RankingsView.tsx index cc46d75..98795c3 100644 --- a/nextjs-app/components/RankingsView.tsx +++ b/nextjs-app/components/RankingsView.tsx @@ -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 diff --git a/nextjs-app/components/SchoolDetailView.tsx b/nextjs-app/components/SchoolDetailView.tsx index 23e98f6..5dcc1f0 100644 --- a/nextjs-app/components/SchoolDetailView.tsx +++ b/nextjs-app/components/SchoolDetailView.tsx @@ -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 = { @@ -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 && ( - + School website ↗ @@ -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} @@ -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 ↗ diff --git a/nextjs-app/components/SchoolSearchModal.tsx b/nextjs-app/components/SchoolSearchModal.tsx index bcedd92..798c2af 100644 --- a/nextjs-app/components/SchoolSearchModal.tsx +++ b/nextjs-app/components/SchoolSearchModal.tsx @@ -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 }; diff --git a/nextjs-app/components/SecondarySchoolDetailView.tsx b/nextjs-app/components/SecondarySchoolDetailView.tsx index e4224d4..eac56fa 100644 --- a/nextjs-app/components/SecondarySchoolDetailView.tsx +++ b/nextjs-app/components/SecondarySchoolDetailView.tsx @@ -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 = { @@ -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 && ( - + School website ↗ @@ -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} @@ -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 ↗ diff --git a/nextjs-app/lib/analytics.ts b/nextjs-app/lib/analytics.ts new file mode 100644 index 0000000..1046abc --- /dev/null +++ b/nextjs-app/lib/analytics.ts @@ -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; + +/** + * 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'; +} diff --git a/nextjs-app/lib/api.ts b/nextjs-app/lib/api.ts index 4fb0929..016750b 100644 --- a/nextjs-app/lib/api.ts +++ b/nextjs-app/lib/api.ts @@ -65,6 +65,16 @@ async function handleResponse(response: Response): Promise { // 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,