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 type { ComparisonData, MetricDefinition, School } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, CHART_COLORS, schoolUrl } from '@/lib/utils';
|
||||||
import { fetchComparison } from '@/lib/api';
|
import { fetchComparison } from '@/lib/api';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './ComparisonView.module.css';
|
import styles from './ComparisonView.module.css';
|
||||||
|
|
||||||
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
const PRIMARY_CATEGORIES = ['expected', 'higher', 'progress', 'average', 'gender', 'equity', 'context', 'absence', 'trends'];
|
||||||
@@ -148,12 +149,28 @@ export function ComparisonView({
|
|||||||
setSelectedMetric(defaultMetric);
|
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) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
|
track('compare_metric_changed', { metric, phase: comparePhase });
|
||||||
setSelectedMetric(metric);
|
setSelectedMetric(metric);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveSchool = (urn: number) => {
|
const handleRemoveSchool = (urn: number) => {
|
||||||
removeSchool(urn);
|
removeSchool(urn);
|
||||||
|
track('compare_school_removed', { urn, from: 'compare' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
@@ -172,6 +189,7 @@ export function ComparisonView({
|
|||||||
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
if (typeof navigator !== 'undefined' && navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
|
||||||
try {
|
try {
|
||||||
await navigator.share(shareData);
|
await navigator.share(shareData);
|
||||||
|
track('compare_shared', { method: 'native', school_count: count });
|
||||||
return;
|
return;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// User cancelled — bail silently. Any other error falls through to clipboard.
|
// User cancelled — bail silently. Any other error falls through to clipboard.
|
||||||
@@ -180,6 +198,7 @@ export function ComparisonView({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
|
track('compare_shared', { method: 'clipboard', school_count: count });
|
||||||
setShareConfirm(true);
|
setShareConfirm(true);
|
||||||
setTimeout(() => setShareConfirm(false), 2000);
|
setTimeout(() => setShareConfirm(false), 2000);
|
||||||
} catch { /* fallback: do nothing */ }
|
} catch { /* fallback: do nothing */ }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
import { useState, useCallback, useTransition, useRef, useEffect } from "react";
|
||||||
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
import { useRouter, useSearchParams, usePathname } from "next/navigation";
|
||||||
import { isValidPostcode } from "@/lib/utils";
|
import { isValidPostcode } from "@/lib/utils";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import type { Filters, ResultFilters } from "@/lib/types";
|
import type { Filters, ResultFilters } from "@/lib/types";
|
||||||
import styles from "./FilterBar.module.css";
|
import styles from "./FilterBar.module.css";
|
||||||
|
|
||||||
@@ -93,14 +94,36 @@ export function FilterBar({ filters, isHero, resultFilters }: FilterBarProps) {
|
|||||||
return;
|
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({
|
updateURL({
|
||||||
postcode: omniValue.trim().toUpperCase(),
|
postcode: cleaned.toUpperCase(),
|
||||||
radius: currentRadius || "1",
|
radius: currentRadius || "1",
|
||||||
search: "",
|
search: "",
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
updateURL({ search: omniValue.trim(), postcode: "", radius: "" });
|
updateURL({ search: cleaned, postcode: "", radius: "" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export function Footer() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="dfe"
|
||||||
>
|
>
|
||||||
School Performance Tables
|
School Performance Tables
|
||||||
</a>
|
</a>
|
||||||
@@ -48,6 +50,8 @@ export function Footer() {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.link}
|
className={styles.link}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
>
|
>
|
||||||
Ofsted reports
|
Ofsted reports
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { useComparisonContext } from '@/context/ComparisonContext';
|
|||||||
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
import { fetchSchools, fetchLAaverages, fetchNationalAverages } from '@/lib/api';
|
||||||
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
import type { SchoolsResponse, Filters, School } from '@/lib/types';
|
||||||
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
import { schoolUrl, buildOfstedListBadge } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './HomeView.module.css';
|
import styles from './HomeView.module.css';
|
||||||
|
|
||||||
interface HomeViewProps {
|
interface HomeViewProps {
|
||||||
@@ -151,6 +152,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
|
|
||||||
const handleLoadMore = async () => {
|
const handleLoadMore = async () => {
|
||||||
if (isLoadingMore || !hasMore) return;
|
if (isLoadingMore || !hasMore) return;
|
||||||
|
track('results_load_more', { next_page: currentPage + 1 });
|
||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
try {
|
try {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
@@ -170,6 +172,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
|
|
||||||
const handleNearMe = useCallback(() => {
|
const handleNearMe = useCallback(() => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
|
track('near_me_used', { outcome: 'unsupported' });
|
||||||
setGeoState('error');
|
setGeoState('error');
|
||||||
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
|
setGeoError('Geolocation is not supported by your browser. Enter a postcode instead.');
|
||||||
return;
|
return;
|
||||||
@@ -187,12 +190,16 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
if (data.result && data.result.length > 0) {
|
if (data.result && data.result.length > 0) {
|
||||||
const postcode = data.result[0].postcode as string;
|
const postcode = data.result[0].postcode as string;
|
||||||
setGeoState('idle');
|
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`);
|
router.push(`/?postcode=${encodeURIComponent(postcode)}&radius=1`);
|
||||||
} else {
|
} else {
|
||||||
|
track('near_me_used', { outcome: 'no_postcode' });
|
||||||
setGeoState('error');
|
setGeoState('error');
|
||||||
setGeoError('No postcode found near your location. Try entering one above.');
|
setGeoError('No postcode found near your location. Try entering one above.');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
track('near_me_used', { outcome: 'lookup_error' });
|
||||||
setGeoState('error');
|
setGeoState('error');
|
||||||
setGeoError('Could not look up your location. Please try again.');
|
setGeoError('Could not look up your location. Please try again.');
|
||||||
}
|
}
|
||||||
@@ -200,8 +207,10 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
(err) => {
|
(err) => {
|
||||||
setGeoState('error');
|
setGeoState('error');
|
||||||
if (err.code === err.PERMISSION_DENIED) {
|
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.');
|
setGeoError('Location access was denied. Enter a postcode above to find nearby schools.');
|
||||||
} else {
|
} else {
|
||||||
|
track('near_me_used', { outcome: 'error' });
|
||||||
setGeoError('Could not get your location. Please try again or enter a postcode.');
|
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;
|
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 (
|
return (
|
||||||
<div className={styles.homeView}>
|
<div className={styles.homeView}>
|
||||||
{/* Combined Hero + Search and Filters */}
|
{/* Combined Hero + Search and Filters */}
|
||||||
@@ -620,7 +650,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
>
|
>
|
||||||
<CompactSchoolItem
|
<CompactSchoolItem
|
||||||
school={school}
|
school={school}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
@@ -635,7 +665,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
<button className={styles.closeSheetBtn} onClick={() => setSelectedMapSchool(null)}>×</button>
|
||||||
<CompactSchoolItem
|
<CompactSchoolItem
|
||||||
school={selectedMapSchool}
|
school={selectedMapSchool}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === selectedMapSchool.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
/>
|
/>
|
||||||
@@ -653,7 +683,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
isLocationSearch={isLocationSearch}
|
isLocationSearch={isLocationSearch}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
onRemoveFromCompare={removeSchool}
|
onRemoveFromCompare={removeSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
laAvgAttainment8={school.local_authority ? laAverages[school.local_authority] ?? null : null}
|
||||||
@@ -663,7 +693,7 @@ export function HomeView({ initialSchools, filters, totalSchools }: HomeViewProp
|
|||||||
key={school.urn}
|
key={school.urn}
|
||||||
school={school}
|
school={school}
|
||||||
isLocationSearch={isLocationSearch}
|
isLocationSearch={isLocationSearch}
|
||||||
onAddToCompare={addSchool}
|
onAddToCompare={addSchoolFromSearch}
|
||||||
onRemoveFromCompare={removeSchool}
|
onRemoveFromCompare={removeSchool}
|
||||||
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
isInCompare={selectedSchools.some(s => s.urn === school.urn)}
|
||||||
nationalAvgRwm={nationalAvgRwm}
|
nationalAvgRwm={nationalAvgRwm}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { ChartOptions, ChartDataset } from 'chart.js';
|
|||||||
import '@/lib/chartSetup';
|
import '@/lib/chartSetup';
|
||||||
import type { SchoolResult } from '@/lib/types';
|
import type { SchoolResult } from '@/lib/types';
|
||||||
import { formatAcademicYear } from '@/lib/utils';
|
import { formatAcademicYear } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import styles from './PerformanceChart.module.css';
|
import styles from './PerformanceChart.module.css';
|
||||||
|
|
||||||
interface NationalByYear {
|
interface NationalByYear {
|
||||||
@@ -389,7 +390,12 @@ export function PerformanceChart({
|
|||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={active}
|
aria-selected={active}
|
||||||
disabled={!enabled}
|
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}` : ''}`}
|
className={`${styles.chip}${active ? ` ${styles.chipActive}` : ''}`}
|
||||||
title={!enabled ? 'No data for this school' : undefined}
|
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 { useComparison } from '@/hooks/useComparison';
|
||||||
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
import type { RankingEntry, Filters, MetricDefinition } from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, schoolUrl } from '@/lib/utils';
|
||||||
|
import { track } from '@/lib/analytics';
|
||||||
import { EmptyState } from './EmptyState';
|
import { EmptyState } from './EmptyState';
|
||||||
import styles from './RankingsView.module.css';
|
import styles from './RankingsView.module.css';
|
||||||
|
|
||||||
@@ -79,6 +80,7 @@ export function RankingsView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleMetricChange = (metric: string) => {
|
const handleMetricChange = (metric: string) => {
|
||||||
|
track('metric_compared_in_rankings', { metric, phase: selectedPhase });
|
||||||
updateFilters({ metric });
|
updateFilters({ metric });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,6 +100,7 @@ export function RankingsView({
|
|||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
} as any);
|
} as any);
|
||||||
|
track('compare_school_added', { urn: ranking.urn, from: 'rankings' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get metric definition
|
// Get metric definition
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/lib/utils';
|
} from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
import SatsChart from './SatsChart';
|
import SatsChart from './SatsChart';
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SchoolDetailView.module.css';
|
import styles from './SchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -121,11 +122,24 @@ export function SchoolDetailView({
|
|||||||
const handleComparisonToggle = () => {
|
const handleComparisonToggle = () => {
|
||||||
if (isInComparison) {
|
if (isInComparison) {
|
||||||
removeSchool(schoolInfo.urn);
|
removeSchool(schoolInfo.urn);
|
||||||
|
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
} else {
|
} else {
|
||||||
addSchool(schoolInfo);
|
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) => {
|
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 <= 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).`;
|
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 && (
|
{schoolInfo.website && (
|
||||||
<span className={styles.headerDetail}>
|
<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 ↗
|
School website ↗
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -395,6 +415,7 @@ export function SchoolDetailView({
|
|||||||
key={id}
|
key={id}
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
className={`${styles.sectionNavLink}${activeSection === id ? ` ${styles.sectionNavLinkActive}` : ''}`}
|
||||||
|
onClick={() => track('section_nav_used', { section: id })}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@@ -417,6 +438,8 @@ export function SchoolDetailView({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.ofstedReportLink}
|
className={styles.ofstedReportLink}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
>
|
>
|
||||||
Full report ↗
|
Full report ↗
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Modal } from "./Modal";
|
|||||||
import { useComparison } from "@/hooks/useComparison";
|
import { useComparison } from "@/hooks/useComparison";
|
||||||
import { debounce } from "@/lib/utils";
|
import { debounce } from "@/lib/utils";
|
||||||
import { fetchSchools } from "@/lib/api";
|
import { fetchSchools } from "@/lib/api";
|
||||||
|
import { track } from "@/lib/analytics";
|
||||||
import type { School } from "@/lib/types";
|
import type { School } from "@/lib/types";
|
||||||
import styles from "./SchoolSearchModal.module.css";
|
import styles from "./SchoolSearchModal.module.css";
|
||||||
|
|
||||||
@@ -60,6 +61,11 @@ export function SchoolSearchModal({ isOpen, onClose }: SchoolSearchModalProps) {
|
|||||||
|
|
||||||
const handleAddSchool = (school: School) => {
|
const handleAddSchool = (school: School) => {
|
||||||
addSchool(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
|
// Don't close modal, allow adding multiple schools
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
import { formatPercentage, formatProgress, formatAcademicYear, buildOfstedHeroChip } from '@/lib/utils';
|
||||||
import { DeltaChip } from './DeltaChip';
|
import { DeltaChip } from './DeltaChip';
|
||||||
|
import { track, getNavigationSource } from '@/lib/analytics';
|
||||||
import styles from './SecondarySchoolDetailView.module.css';
|
import styles from './SecondarySchoolDetailView.module.css';
|
||||||
|
|
||||||
const OFSTED_LABELS: Record<number, string> = {
|
const OFSTED_LABELS: Record<number, string> = {
|
||||||
@@ -111,11 +112,23 @@ export function SecondarySchoolDetailView({
|
|||||||
const handleComparisonToggle = () => {
|
const handleComparisonToggle = () => {
|
||||||
if (isInComparison) {
|
if (isInComparison) {
|
||||||
removeSchool(schoolInfo.urn);
|
removeSchool(schoolInfo.urn);
|
||||||
|
track('compare_school_removed', { urn: schoolInfo.urn, from: 'detail' });
|
||||||
} else {
|
} else {
|
||||||
addSchool(schoolInfo);
|
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
|
// Build nav items dynamically based on available data
|
||||||
const navItems: { id: string; label: string }[] = [];
|
const navItems: { id: string; label: string }[] = [];
|
||||||
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
if (ofsted) navItems.push({ id: 'ofsted', label: 'Ofsted' });
|
||||||
@@ -210,7 +223,13 @@ export function SecondarySchoolDetailView({
|
|||||||
)}
|
)}
|
||||||
{schoolInfo.website && (
|
{schoolInfo.website && (
|
||||||
<span className={styles.headerDetail}>
|
<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 ↗
|
School website ↗
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
@@ -318,6 +337,7 @@ export function SecondarySchoolDetailView({
|
|||||||
key={id}
|
key={id}
|
||||||
href={`#${id}`}
|
href={`#${id}`}
|
||||||
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
className={`${styles.tabBtn}${activeSection === id ? ` ${styles.tabBtnActive}` : ''}`}
|
||||||
|
onClick={() => track('section_nav_used', { section: id })}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</a>
|
</a>
|
||||||
@@ -340,6 +360,8 @@ export function SecondarySchoolDetailView({
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={styles.ofstedReportLink}
|
className={styles.ofstedReportLink}
|
||||||
|
data-umami-event="external_link_clicked"
|
||||||
|
data-umami-event-target="ofsted"
|
||||||
>
|
>
|
||||||
Full report ↗
|
Full report ↗
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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<string, Primitive>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
@@ -65,6 +65,16 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|||||||
// If parsing JSON fails, use the default error
|
// 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(
|
throw new APIFetchError(
|
||||||
`API request failed: ${errorDetail}`,
|
`API request failed: ${errorDetail}`,
|
||||||
response.status,
|
response.status,
|
||||||
|
|||||||
Reference in New Issue
Block a user