diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index c9860e9..a6cb378 100644 --- a/flexfoil-ui/src/components/panels/SolvePanel.tsx +++ b/flexfoil-ui/src/components/panels/SolvePanel.tsx @@ -17,6 +17,7 @@ import { runSweep, type SweepConfig, type SweepRunData } from '../../lib/sweepEn import type { PolarPoint, SweepAxis, SweepParam, ReType } from '../../types'; import type { RunInsert } from '../../lib/storageBackend'; import { parseSweepValues, formatSweepValues } from '../../lib/parseSweepValues'; +import { trackEvent } from '../../lib/analytics'; type SolveOrCacheResult = { result: AnalysisResult | null; @@ -338,6 +339,12 @@ export function SolvePanel() { return; } + trackEvent('solve_run', { + solve_mode: runMode === 'alpha' ? 'single_alpha' : 'single_cl', + solver_mode: isViscous ? 'viscous' : 'inviscid', + n_panels: panels.length - 1, + }); + const jobLabel = runMode === 'alpha' ? `${isViscous ? 'Viscous' : 'Inviscid'} @ alpha=${targetAlpha.toFixed(1)}` : `${isViscous ? 'Viscous' : 'Inviscid'} -> CL=${targetCl.toFixed(3)}`; @@ -579,6 +586,12 @@ export function SolvePanel() { clearPolarSuppression(); + trackEvent('solve_run', { + solve_mode: 'polar', + solver_mode: isViscous ? 'viscous' : 'inviscid', + n_panels: panels.length - 1, + }); + const { id: jobId, signal } = jobDispatch( `Polar ${alphaStart.toFixed(0)} to ${alphaEnd.toFixed(0)} (${isViscous ? 'viscous' : 'inviscid'})` ); @@ -724,6 +737,12 @@ export function SolvePanel() { const controller = new AbortController(); sweepAbortRef.current = controller; + trackEvent('solve_run', { + solve_mode: sweepSecondary ? 'sweep_2d' : 'sweep_1d', + solver_mode: isViscous ? 'viscous' : 'inviscid', + n_panels: panels.length - 1, + }); + const sweepLabel = sweepSecondary ? `Sweep ${sweepPrimary.param} × ${sweepSecondary.param} (${isViscous ? 'viscous' : 'inviscid'})` : `Sweep ${sweepPrimary.param} ${sweepPrimary.start}→${sweepPrimary.end} (${isViscous ? 'viscous' : 'inviscid'})`; diff --git a/flexfoil-ui/src/lib/analytics.test.ts b/flexfoil-ui/src/lib/analytics.test.ts new file mode 100644 index 0000000..5865f6e --- /dev/null +++ b/flexfoil-ui/src/lib/analytics.test.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { trackEvent } from './analytics'; + +// Tests run in the node environment, so stand up the `window` the module targets. +const globals = globalThis as { window?: { gtag?: (...args: unknown[]) => void } }; + +beforeEach(() => { + globals.window = {}; +}); + +afterEach(() => { + delete globals.window; +}); + +describe('trackEvent', () => { + it('forwards the event name and params to gtag', () => { + const gtag = vi.fn(); + globals.window = { gtag }; + + trackEvent('solve_run', { solve_mode: 'polar', solver_mode: 'viscous', n_panels: 160 }); + + expect(gtag).toHaveBeenCalledWith('event', 'solve_run', { + solve_mode: 'polar', + solver_mode: 'viscous', + n_panels: 160, + }); + }); + + it('does not throw when the tag is blocked', () => { + expect(() => trackEvent('solve_run')).not.toThrow(); + }); +}); diff --git a/flexfoil-ui/src/lib/analytics.ts b/flexfoil-ui/src/lib/analytics.ts index 3b9994f..dca40e6 100644 --- a/flexfoil-ui/src/lib/analytics.ts +++ b/flexfoil-ui/src/lib/analytics.ts @@ -49,6 +49,14 @@ export function initAnalyticsConsent() { } } +/** + * Send a custom GA4 event. Consent Mode gates delivery, so callers do not need + * to check consent first. gtag is absent when the tag is blocked, hence optional. + */ +export function trackEvent(name: string, params?: Record) { + window.gtag?.('event', name, params); +} + declare global { interface Window { gtag?: (...args: unknown[]) => void;