From 57feb9052b8204d8a7e3813b035690ac6015f96f Mon Sep 17 00:00:00 2001 From: Anthony Volk Date: Mon, 6 Apr 2026 19:09:21 +0200 Subject: [PATCH 1/2] Implement PostHog monitoring for Next apps --- .node-version | 1 + .nvmrc | 1 + app/package.json | 1 + app/src/components/common/ErrorBoundary.tsx | 2 + .../household/HouseholdBuilderForm.tsx | 78 +++- app/src/hooks/useCreateHousehold.ts | 7 + app/src/hooks/useCreatePolicy.ts | 7 + app/src/hooks/useCreateReport.ts | 27 ++ app/src/libs/calculations/CalcOrchestrator.ts | 73 +++- .../household/HouseholdReportOrchestrator.ts | 22 + .../report-output/HouseholdReportOutput.tsx | 31 +- .../report-output/SocietyWideReportOutput.tsx | 31 +- .../pages/reportBuilder/ReportBuilderPage.tsx | 8 + .../components/ReportMetaPanel.tsx | 19 +- .../hooks/useReportSubmission.ts | 28 +- .../hooks/useSimulationCanvas.ts | 81 +++- .../modals/PopulationBrowseModal.tsx | 19 + .../population/HouseholdCreationContent.tsx | 1 + .../report/views/policy/PolicySubmitView.tsx | 7 +- .../views/population/HouseholdBuilderView.tsx | 18 +- app/src/utils/analytics.ts | 390 +++++++++++++++++- app/src/utils/analyticsSchemas.ts | 158 +++++++ app/src/utils/analyticsSnapshots.ts | 225 ++++++++++ app/src/utils/errorTracking.ts | 66 +++ app/src/utils/posthogClient.ts | 18 + bun.lock | 235 +++++++++++ calculator-app/.env.example | 13 + calculator-app/instrumentation-client.ts | 35 ++ calculator-app/instrumentation.ts | 90 ++++ calculator-app/next.config.ts | 31 +- calculator-app/package.json | 3 + calculator-app/src/app/[countryId]/error.tsx | 5 + calculator-app/src/app/error.tsx | 5 + calculator-app/src/app/global-error.tsx | 46 +++ calculator-app/src/app/layout.tsx | 5 +- calculator-app/src/app/providers.tsx | 12 + calculator-app/src/lib/posthog-server.ts | 34 ++ calculator-app/src/lib/posthogProxy.ts | 85 ++++ website/.env.example | 14 + website/instrumentation-client.ts | 35 ++ website/instrumentation.ts | 90 ++++ website/next.config.ts | 26 +- website/package.json | 3 + .../[countryId]/research/ResearchClient.tsx | 27 ++ .../research/[slug]/ArticleClient.tsx | 15 +- website/src/app/error.tsx | 36 ++ website/src/app/global-error.tsx | 40 ++ website/src/app/layout.tsx | 3 +- website/src/app/providers.tsx | 12 + website/src/components/Footer.tsx | 43 +- website/src/components/Header.tsx | 8 +- website/src/components/home/HeroCTA.tsx | 11 +- website/src/lib/posthog-events.ts | 62 +++ website/src/lib/posthog-server.ts | 34 ++ website/src/lib/posthogProxy.ts | 85 ++++ 55 files changed, 2385 insertions(+), 77 deletions(-) create mode 100644 .node-version create mode 100644 .nvmrc create mode 100644 app/src/utils/analyticsSchemas.ts create mode 100644 app/src/utils/analyticsSnapshots.ts create mode 100644 app/src/utils/errorTracking.ts create mode 100644 app/src/utils/posthogClient.ts create mode 100644 calculator-app/.env.example create mode 100644 calculator-app/instrumentation-client.ts create mode 100644 calculator-app/instrumentation.ts create mode 100644 calculator-app/src/app/global-error.tsx create mode 100644 calculator-app/src/app/providers.tsx create mode 100644 calculator-app/src/lib/posthog-server.ts create mode 100644 calculator-app/src/lib/posthogProxy.ts create mode 100644 website/instrumentation-client.ts create mode 100644 website/instrumentation.ts create mode 100644 website/src/app/error.tsx create mode 100644 website/src/app/global-error.tsx create mode 100644 website/src/app/providers.tsx create mode 100644 website/src/lib/posthog-events.ts create mode 100644 website/src/lib/posthog-server.ts create mode 100644 website/src/lib/posthogProxy.ts diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +24 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..a45fd52cc --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/app/package.json b/app/package.json index f4ef04d5a..d48456387 100644 --- a/app/package.json +++ b/app/package.json @@ -49,6 +49,7 @@ "html-to-image": "^1.11.13", "jsonp": "^0.2.1", "lucide-react": "^0.575.0", + "posthog-js": "^1.364.5", "radix-ui": "^1.4.3", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/app/src/components/common/ErrorBoundary.tsx b/app/src/components/common/ErrorBoundary.tsx index 9eb754065..892e4e272 100644 --- a/app/src/components/common/ErrorBoundary.tsx +++ b/app/src/components/common/ErrorBoundary.tsx @@ -1,4 +1,5 @@ import { Component, ErrorInfo, ReactNode } from 'react'; +import { captureReactBoundaryException } from '@/utils/errorTracking'; export interface ErrorBoundaryProps { children: ReactNode; @@ -37,6 +38,7 @@ export class ErrorBoundary extends Component void; onNumChildrenChange: (num: number) => void; disabled?: boolean; + trackingMode?: 'report' | 'standalone'; } export default function HouseholdBuilderForm({ @@ -68,6 +74,7 @@ export default function HouseholdBuilderForm({ onMaritalStatusChange, onNumChildrenChange, disabled = false, + trackingMode = 'report', }: HouseholdBuilderFormProps) { // State for custom variables const [selectedVariables, setSelectedVariables] = useState([]); @@ -138,6 +145,14 @@ export default function HouseholdBuilderForm({ [householdSearchValue, allInputVariables] ); + useEffect(() => { + trackHouseholdBuilderOpened({ + countryId: household.countryId, + year, + mode: trackingMode, + }); + }, [household.countryId, trackingMode, year]); + // Get variables for a specific person (custom only, not basic inputs) const getPersonVariables = (personName: string): string[] => { const personData = household.householdData.people[personName]; @@ -198,10 +213,25 @@ export default function HouseholdBuilderForm({ const newHousehold = addVariableToEntity(household, variable.name, metadata, year, person); onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; + if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? person : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setActivePersonSearch(null); setPersonSearchValue(''); setIsPersonSearchFocused(false); @@ -223,9 +253,23 @@ export default function HouseholdBuilderForm({ ); // If no one else has it, remove from selectedVariables + const nextSelectedVariables = stillUsedByOthers + ? selectedVariables + : selectedVariables.filter((v) => v !== varName); + if (!stillUsedByOthers) { - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + setSelectedVariables(nextSelectedVariables); } + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'person', + entityName: person, + selectedVariableCount: nextSelectedVariables.length, + }); }; // Handle opening household search @@ -272,10 +316,24 @@ export default function HouseholdBuilderForm({ ); } onChange(newHousehold); + const nextSelectedVariables = selectedVariables.includes(variable.name) + ? selectedVariables + : [...selectedVariables, variable.name]; if (!selectedVariables.includes(variable.name)) { - setSelectedVariables([...selectedVariables, variable.name]); + setSelectedVariables(nextSelectedVariables); } + trackHouseholdVariableAdded({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: variable.name, + variableLabel: variable.label, + entityScope: isPerson ? 'person' : 'household', + entityName: isPerson ? 'all_people' : undefined, + selectedVariableCount: nextSelectedVariables.length, + }); + setIsHouseholdSearchActive(false); setHouseholdSearchValue(''); setIsHouseholdSearchFocused(false); @@ -285,7 +343,17 @@ export default function HouseholdBuilderForm({ const handleRemoveHouseholdVariable = (varName: string) => { const newHousehold = removeVariable(household, varName, metadata); onChange(newHousehold); - setSelectedVariables(selectedVariables.filter((v) => v !== varName)); + const nextSelectedVariables = selectedVariables.filter((v) => v !== varName); + setSelectedVariables(nextSelectedVariables); + + trackHouseholdVariableRemoved({ + countryId: household.countryId, + year, + household: newHousehold, + variableName: varName, + entityScope: 'household', + selectedVariableCount: nextSelectedVariables.length, + }); }; return ( diff --git a/app/src/hooks/useCreateHousehold.ts b/app/src/hooks/useCreateHousehold.ts index e3480d646..ef1a87b7a 100644 --- a/app/src/hooks/useCreateHousehold.ts +++ b/app/src/hooks/useCreateHousehold.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createHousehold } from '@/api/household'; import { MOCK_USER_ID } from '@/constants'; import { countryIds } from '@/libs/countries'; +import { captureApiException } from '@/utils/errorTracking'; import { useCreateHouseholdAssociation } from './useUserHousehold'; export function useCreateHousehold(householdLabel?: string) { @@ -22,6 +23,12 @@ export function useCreateHousehold(householdLabel?: string) { }); } catch (error) { console.error('Household created but association failed:', error); + captureApiException(error, { + source: 'household_association', + country_id: variables.country_id, + household_id: data.result.household_id, + has_label: Boolean(householdLabel), + }); } }, }); diff --git a/app/src/hooks/useCreatePolicy.ts b/app/src/hooks/useCreatePolicy.ts index 9a6b377ae..5d0c3645a 100644 --- a/app/src/hooks/useCreatePolicy.ts +++ b/app/src/hooks/useCreatePolicy.ts @@ -2,6 +2,7 @@ import { useMutation } from '@tanstack/react-query'; import { createPolicy } from '@/api/policy'; import { MOCK_USER_ID } from '@/constants'; import { PolicyCreationPayload } from '@/types/payloads'; +import { captureApiException } from '@/utils/errorTracking'; import { useCurrentCountry } from './useCurrentCountry'; import { useCreatePolicyAssociation } from './useUserPolicy'; @@ -26,6 +27,12 @@ export function useCreatePolicy(policyLabel?: string) { }); } catch (error) { console.error('Policy created but association failed:', error); + captureApiException(error, { + source: 'policy_association', + country_id: countryId, + policy_id: data.result.policy_id, + has_label: Boolean(policyLabel), + }); } }, }); diff --git a/app/src/hooks/useCreateReport.ts b/app/src/hooks/useCreateReport.ts index 7a7b4d5ef..d4d477899 100644 --- a/app/src/hooks/useCreateReport.ts +++ b/app/src/hooks/useCreateReport.ts @@ -8,6 +8,11 @@ import { Geography } from '@/types/ingredients/Geography'; import { Household } from '@/types/ingredients/Household'; import { Simulation } from '@/types/ingredients/Simulation'; import { ReportCreationPayload } from '@/types/payloads'; +import { trackReportCreated } from '@/utils/analytics'; +import { + captureCalculationException, + captureCalculatorException, +} from '@/utils/errorTracking'; interface CreateReportAndBeginCalculationParams { countryId: (typeof countryIds)[number]; @@ -83,12 +88,23 @@ export function useCreateReport(reportLabel?: string) { try { const { report, simulations, populations } = result; const reportIdStr = String(report.id); + const simulationList = [simulations?.simulation1, simulations?.simulation2].filter( + (simulation): simulation is Simulation => Boolean(simulation) + ); // Invalidate report association queries so the Reports page picks up the new report queryClient.invalidateQueries({ queryKey: reportAssociationKeys.all }); // Cache the report data using consistent key structure queryClient.setQueryData(reportKeys.byId(reportIdStr), report); + trackReportCreated({ + countryId: report.countryId, + report: { + ...report, + id: reportIdStr, + }, + simulations: simulationList, + }); // Determine calculation type from simulation const simulation1 = simulations?.simulation1; @@ -147,6 +163,13 @@ export function useCreateReport(reportLabel?: string) { `[useCreateReport] Failed to start calculation for simulation ${sim.id}:`, error ); + captureCalculationException(error, { + source: 'use_create_report_household_start', + country_id: report.countryId, + year: report.year, + report_id: reportIdStr, + simulation_id: sim.id, + }); }); } } else { @@ -173,6 +196,10 @@ export function useCreateReport(reportLabel?: string) { } } catch (error) { console.error('[useCreateReport] Post-creation tasks failed:', error); + captureCalculatorException(error, { + source: 'use_create_report_on_success', + report_label: reportLabel, + }); } finally { if (import.meta.env.DEV) { (window as any).__journeyProfiler?.markEnd('report-onSuccess', 'render'); diff --git a/app/src/libs/calculations/CalcOrchestrator.ts b/app/src/libs/calculations/CalcOrchestrator.ts index 56f368af5..bfac40f31 100644 --- a/app/src/libs/calculations/CalcOrchestrator.ts +++ b/app/src/libs/calculations/CalcOrchestrator.ts @@ -1,7 +1,12 @@ import { QueryClient, QueryObserver } from '@tanstack/react-query'; import { calculationQueries } from '@/libs/queries/calculationQueries'; import type { CalcMetadata, CalcParams, CalcStartConfig, CalcStatus } from '@/types/calculation'; -import { trackSimulationCompleted } from '@/utils/analytics'; +import { + trackCalculationFailed, + trackCalculationStarted, + trackSimulationCompleted, +} from '@/utils/analytics'; +import { captureCalculationException } from '@/utils/errorTracking'; import type { CalcOrchestratorManager } from './CalcOrchestratorManager'; import { ResultPersister } from './ResultPersister'; @@ -59,6 +64,7 @@ export class CalcOrchestrator { // Build metadata and params const metadata = this.buildMetadata(config); const params = this.buildParams(config); + trackCalculationStarted({ config }); // Create query options (includes refetchInterval from strategy) const queryOptions = @@ -80,7 +86,32 @@ export class CalcOrchestrator { } // Execute initial queryFn - const initialStatus = await queryOptions.queryFn(); + let initialStatus: CalcStatus; + + try { + initialStatus = await queryOptions.queryFn(); + } catch (error) { + trackCalculationFailed({ + calcId: config.calcId, + targetType: config.targetType, + countryId: config.countryId, + year: config.year, + calcType: metadata.calcType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + error, + config, + }); + captureCalculationException(error, { + source: 'calc_orchestrator_query_fn', + calc_id: config.calcId, + target_type: config.targetType, + country_id: config.countryId, + year: config.year, + report_id: config.reportId, + }); + throw error; + } // Set result in cache this.queryClient.setQueryData(queryOptions.queryKey, initialStatus); @@ -88,7 +119,15 @@ export class CalcOrchestrator { // CRITICAL DECISION POINT: Household vs Economy if (initialStatus.status === 'complete') { // HOUSEHOLD CASE: Calculation completed synchronously - trackSimulationCompleted({ calcType: metadata.calcType, countryId: config.countryId }); + trackSimulationCompleted({ + calcType: metadata.calcType, + countryId: config.countryId, + year: config.year, + calcId: config.calcId, + targetType: config.targetType, + reportId: config.reportId, + durationMs: Date.now() - metadata.startedAt, + }); await this.resultPersister.persist(initialStatus, config.countryId, config.year); // Notify manager to cleanup this orchestrator @@ -157,7 +196,15 @@ export class CalcOrchestrator { // Handle completion if (status.status === 'complete' && status.result) { - trackSimulationCompleted({ calcType: _metadata.calcType, countryId }); + trackSimulationCompleted({ + calcType: _metadata.calcType, + countryId, + year, + calcId, + targetType: _metadata.targetType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + }); this.resultPersister .persist(status, countryId, year) .catch((error) => { @@ -179,6 +226,24 @@ export class CalcOrchestrator { // Handle error if (status.status === 'error') { console.error('[CalcOrchestrator] Calculation error:', status.error); + trackCalculationFailed({ + calcId, + targetType: _metadata.targetType, + countryId, + year, + calcType: _metadata.calcType, + reportId: _metadata.reportId, + durationMs: Date.now() - _metadata.startedAt, + error: status.error, + }); + captureCalculationException(status.error, { + source: 'calc_orchestrator_polling', + calc_id: calcId, + target_type: _metadata.targetType, + country_id: countryId, + year, + report_id: _metadata.reportId, + }); unsubscribe(); this.currentUnsubscribe = null; diff --git a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts index 71a7a10cc..89baa9801 100644 --- a/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts +++ b/app/src/libs/calculations/household/HouseholdReportOrchestrator.ts @@ -6,6 +6,7 @@ import type { HouseholdReportConfig, SimulationConfig } from '@/types/calculatio import type { HouseholdData } from '@/types/ingredients/Household'; import type { Report } from '@/types/ingredients/Report'; import { cacheMonitor } from '@/utils/cacheMonitor'; +import { captureCalculationException } from '@/utils/errorTracking'; import { HouseholdProgressCoordinator } from './HouseholdProgressCoordinator'; import { buildHouseholdReportOutput } from './householdReportUtils'; import { HouseholdSimCalculator } from './HouseholdSimCalculator'; @@ -93,6 +94,11 @@ export class HouseholdReportOrchestrator { }) .catch((error) => { console.error('[HouseholdReportOrchestrator] Error in parallel execution:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_parallel', + country_id: countryId, + report_id: reportId, + }); // Mark report as error return this.markReportError(config.report, countryId, reportId); @@ -147,6 +153,12 @@ export class HouseholdReportOrchestrator { await this.persistSimulation(countryId, simulationId, result); } catch (error) { console.error('[HouseholdReportOrchestrator] Simulation failed:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_simulation', + country_id: countryId, + report_id: reportId, + simulation_id: simulationId, + }); // Notify progress coordinator that this simulation failed progressCoordinator.failSimulation(simulationId); @@ -256,6 +268,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report complete:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_complete', + country_id: countryId, + report_id: reportId, + }); } } @@ -291,6 +308,11 @@ export class HouseholdReportOrchestrator { } } catch (error) { console.error('[HouseholdReportOrchestrator] Failed to mark report error:', error); + captureCalculationException(error, { + source: 'household_report_orchestrator_mark_error', + country_id: countryId, + report_id: reportId, + }); } } } diff --git a/app/src/pages/report-output/HouseholdReportOutput.tsx b/app/src/pages/report-output/HouseholdReportOutput.tsx index 178754608..3a8cbf582 100644 --- a/app/src/pages/report-output/HouseholdReportOutput.tsx +++ b/app/src/pages/report-output/HouseholdReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSimulationProgressDisplay } from '@/hooks/household'; import type { Household } from '@/types/ingredients/Household'; import type { Policy } from '@/types/ingredients/Policy'; @@ -10,6 +10,7 @@ import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import DynamicsSubPage from './DynamicsSubPage'; import ErrorPage from './ErrorPage'; import { HouseholdComparativeAnalysisPage } from './HouseholdComparativeAnalysisPage'; @@ -153,6 +154,34 @@ export function HouseholdReportOutput({ }: HouseholdReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('household', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'household', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Build view model (memoized - recomputes only when props change) const viewModel = useMemo( () => new HouseholdReportViewModel(report, simulations, userSimulations, userPolicies), diff --git a/app/src/pages/report-output/SocietyWideReportOutput.tsx b/app/src/pages/report-output/SocietyWideReportOutput.tsx index 8bc36867d..c623fab2f 100644 --- a/app/src/pages/report-output/SocietyWideReportOutput.tsx +++ b/app/src/pages/report-output/SocietyWideReportOutput.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { SocietyWideReportOutput as SocietyWideOutput } from '@/api/societyWideCalculation'; import { useCalculationStatus } from '@/hooks/useCalculationStatus'; @@ -16,6 +16,7 @@ import type { UserSimulation } from '@/types/ingredients/UserSimulation'; import { resolveDefaultReportOutputSubpage } from '@/utils/reportOutputSubpage'; import { convertPoliciesToV1Format } from '@/utils/reproducibilityCode'; import { getDisplayStatus } from '@/utils/statusMapping'; +import { trackReportOutputSubpageViewed, trackReportOutputViewed } from '@/utils/analytics'; import { ComparativeAnalysisPage } from './ComparativeAnalysisPage'; import { ConstituencySubPage } from './ConstituencySubPage'; import DynamicsSubPage from './DynamicsSubPage'; @@ -159,6 +160,34 @@ export function SocietyWideReportOutput({ }: SocietyWideReportOutputProps) { const normalizedSubpage = resolveDefaultReportOutputSubpage('societyWide', subpage); + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [report?.id]); + + useEffect(() => { + if (!report) { + return; + } + + trackReportOutputSubpageViewed({ + report, + simulations, + calcType: 'societyWide', + subpage: normalizedSubpage, + activeView, + }); + }, [activeView, normalizedSubpage, report, simulations]); + // Read datasets from metadata for the reproduce tab const datasets = useSelector((state: RootState) => state.metadata.economyOptions?.datasets); diff --git a/app/src/pages/reportBuilder/ReportBuilderPage.tsx b/app/src/pages/reportBuilder/ReportBuilderPage.tsx index d47692879..719d596ba 100644 --- a/app/src/pages/reportBuilder/ReportBuilderPage.tsx +++ b/app/src/pages/reportBuilder/ReportBuilderPage.tsx @@ -13,6 +13,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { CURRENT_YEAR } from '@/constants'; import { useAppNavigate } from '@/contexts/NavigationContext'; import { useCurrentCountry } from '@/hooks/useCurrentCountry'; +import { trackSocietyWideBuilderOpened } from '@/utils/analytics'; import { initializeSimulationState } from '@/utils/pathwayState/initializeSimulationState'; import { getReportOutputPath } from '@/utils/reportRouting'; import { ReportBuilderShell, SimulationBlockFull } from './components'; @@ -43,6 +44,13 @@ export default function ReportBuilderPage() { ingredientType: 'policy', }); + useEffect(() => { + trackSocietyWideBuilderOpened({ + countryId, + year: CURRENT_YEAR, + }); + }, [countryId]); + // Submission logic (extracted hook) const { handleSubmit, isSubmitting, isReportConfigured } = useReportSubmission({ reportState, diff --git a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx index f641a77db..5344ec7fd 100644 --- a/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx +++ b/app/src/pages/reportBuilder/components/ReportMetaPanel.tsx @@ -20,7 +20,9 @@ import { } from '@/components/ui'; import { CURRENT_YEAR } from '@/constants'; import { colors, spacing, typography } from '@/designTokens'; +import { useCurrentCountry } from '@/hooks/useCurrentCountry'; import { getTaxYears } from '@/libs/metadataUtils'; +import { trackReportYearSelected } from '@/utils/analytics'; import { FONT_SIZES } from '../constants'; import type { ReportBuilderState } from '../types'; @@ -43,6 +45,7 @@ interface ReportMetaPanelProps { } export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: ReportMetaPanelProps) { + const countryId = useCurrentCountry(); const yearOptions = useSelector(getTaxYears); const [isEditingLabel, setIsEditingLabel] = useState(false); const [labelInput, setLabelInput] = useState(''); @@ -219,9 +222,19 @@ export function ReportMetaPanel({ reportState, setReportState, isReadOnly }: Rep setEmail(event.currentTarget.value)} - disabled={status === 'loading'} + disabled={status === "loading"} /> {message && (

{message} @@ -188,23 +216,27 @@ export default function Footer() { data-testid="site-footer" className="tw:w-full" style={{ - padding: `${spacing['4xl']} ${spacing['5xl']}`, + padding: `${spacing["4xl"]} ${spacing["5xl"]}`, background: `linear-gradient(to right, ${colors.primary[800]}, ${colors.primary[600]})`, }} > - PolicyEngine + PolicyEngine

{[ - { href: CONTACT_LINKS.about, text: 'About us' }, - { href: CONTACT_LINKS.donate, text: 'Donate' }, - { href: CONTACT_LINKS.privacy, text: 'Privacy policy' }, - { href: CONTACT_LINKS.terms, text: 'Terms and conditions' }, + { href: CONTACT_LINKS.about, text: "About us" }, + { href: CONTACT_LINKS.donate, text: "Donate" }, + { href: CONTACT_LINKS.privacy, text: "Privacy policy" }, + { href: CONTACT_LINKS.terms, text: "Terms and conditions" }, ].map(({ href, text }) => (

- © {new Date().getFullYear()} PolicyEngine. All rights reserved. + © {new Date().getFullYear()} PolicyEngine. All rights + reserved.

diff --git a/website/src/components/Header.tsx b/website/src/components/Header.tsx index c3a26163b..c66ec6152 100644 --- a/website/src/components/Header.tsx +++ b/website/src/components/Header.tsx @@ -435,7 +435,13 @@ const mobileNavLinkStyle: React.CSSProperties = { display: "block", }; -function MobileNavLink({ item, onClose }: { item: NavItemSetup; onClose: () => void }) { +function MobileNavLink({ + item, + onClose, +}: { + item: NavItemSetup; + onClose: () => void; +}) { const Tag = item.external ? "a" : Link; return ( @@ -588,8 +594,18 @@ export default function Header() { const navItems: NavItemSetup[] = [ { label: "Research", href: `/${countryId}/research`, hasDropdown: false }, - { label: "Model", href: `/${countryId}/model`, hasDropdown: false, external: true }, - { label: "API", href: `/${countryId}/api`, hasDropdown: false, external: true }, + { + label: "Model", + href: `/${countryId}/model`, + hasDropdown: false, + external: true, + }, + { + label: "API", + href: `/${countryId}/api`, + hasDropdown: false, + external: true, + }, { label: "About", hasDropdown: true, diff --git a/website/src/lib/posthog-events.ts b/website/src/lib/posthog-events.ts index c95df71fe..f96abe3b8 100644 --- a/website/src/lib/posthog-events.ts +++ b/website/src/lib/posthog-events.ts @@ -10,7 +10,7 @@ function getClient() { export function captureWebsiteEvent( eventName: string, - properties: WebsiteEventProperties = {} + properties: WebsiteEventProperties = {}, ) { getClient().capture(eventName, { surface: "website", @@ -20,7 +20,7 @@ export function captureWebsiteEvent( export function captureWebsiteException( error: unknown, - properties: WebsiteEventProperties = {} + properties: WebsiteEventProperties = {}, ) { const normalizedError = error instanceof Error @@ -33,27 +33,39 @@ export function captureWebsiteException( }); } -export function trackEnterCalculatorClicked(properties: WebsiteEventProperties = {}) { +export function trackEnterCalculatorClicked( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("enter_calculator_clicked", properties); } -export function trackNewsletterSignupStarted(properties: WebsiteEventProperties = {}) { +export function trackNewsletterSignupStarted( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("newsletter_signup_started", properties); } -export function trackNewsletterSignupSucceeded(properties: WebsiteEventProperties = {}) { +export function trackNewsletterSignupSucceeded( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("newsletter_signup_succeeded", properties); } -export function trackNewsletterSignupFailed(properties: WebsiteEventProperties = {}) { +export function trackNewsletterSignupFailed( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("newsletter_signup_failed", properties); } -export function trackResearchArticleViewed(properties: WebsiteEventProperties = {}) { +export function trackResearchArticleViewed( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("research_article_viewed", properties); } -export function trackResearchFiltersChanged(properties: WebsiteEventProperties = {}) { +export function trackResearchFiltersChanged( + properties: WebsiteEventProperties = {}, +) { captureWebsiteEvent("research_filters_changed", properties); } diff --git a/website/src/lib/posthogProxy.ts b/website/src/lib/posthogProxy.ts index ffea137f9..40c45b396 100644 --- a/website/src/lib/posthogProxy.ts +++ b/website/src/lib/posthogProxy.ts @@ -37,7 +37,7 @@ function normalizeHost(host: string | null | undefined): URL | null { } export function getPostHogProxyConfig( - host: string | null | undefined + host: string | null | undefined, ): PostHogProxyConfig | null { const normalizedHost = normalizeHost(host); @@ -60,7 +60,7 @@ export function getPostHogProxyConfig( } export function getPostHogProxyRewrites( - host: string | null | undefined + host: string | null | undefined, ): Array<{ destination: string; source: string }> { const proxyConfig = getPostHogProxyConfig(host);