Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
24

2 changes: 2 additions & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
24

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"html-to-image": "^1.11.13",
"jsonp": "^0.2.1",
"lucide-react": "^0.575.0",
"posthog-js": "^1.292.0",
"radix-ui": "^1.4.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
Expand Down
2 changes: 2 additions & 0 deletions app/src/components/common/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Component, ErrorInfo, ReactNode } from 'react';
import { captureReactBoundaryException } from '@/utils/errorTracking';

export interface ErrorBoundaryProps {
children: ReactNode;
Expand Down Expand Up @@ -37,6 +38,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt

componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({ errorInfo });
captureReactBoundaryException(error, errorInfo);
this.props.onError?.(error, errorInfo);
}

Expand Down
78 changes: 73 additions & 5 deletions app/src/components/household/HouseholdBuilderForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - Inline search for adding custom variables per person or household-level
*/

import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
import {
Accordion,
Expand All @@ -29,6 +29,11 @@ import {
} from '@/components/ui';
import { colors, spacing, typography } from '@/designTokens';
import { Household } from '@/types/ingredients/Household';
import {
trackHouseholdBuilderOpened,
trackHouseholdVariableAdded,
trackHouseholdVariableRemoved,
} from '@/utils/analytics';
import { sortPeopleKeys } from '@/utils/householdIndividuals';
import {
addVariable,
Expand All @@ -54,6 +59,7 @@ export interface HouseholdBuilderFormProps {
onMaritalStatusChange: (status: 'single' | 'married') => void;
onNumChildrenChange: (num: number) => void;
disabled?: boolean;
trackingMode?: 'report' | 'standalone';
}

export default function HouseholdBuilderForm({
Expand All @@ -68,6 +74,7 @@ export default function HouseholdBuilderForm({
onMaritalStatusChange,
onNumChildrenChange,
disabled = false,
trackingMode = 'report',
}: HouseholdBuilderFormProps) {
// State for custom variables
const [selectedVariables, setSelectedVariables] = useState<string[]>([]);
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand All @@ -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 (
Expand Down
7 changes: 7 additions & 0 deletions app/src/hooks/useCreateHousehold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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),
});
}
},
});
Expand Down
7 changes: 7 additions & 0 deletions app/src/hooks/useCreatePolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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),
});
}
},
});
Expand Down
27 changes: 27 additions & 0 deletions app/src/hooks/useCreateReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand All @@ -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');
Expand Down
73 changes: 69 additions & 4 deletions app/src/libs/calculations/CalcOrchestrator.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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 =
Expand All @@ -80,15 +86,48 @@ 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);

// 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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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;
Expand Down
Loading