From a295f0e37b50365efd1ac9f314a5b130130903b2 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Sun, 26 Jul 2026 00:49:59 +0100 Subject: [PATCH] feat(ui): track solve_run analytics event GA4 carried only the six default events, so we could measure that people opened FlexFoil but not that they ran anything. Engagement time was the closest available proxy. Add a trackEvent helper to lib/analytics and fire solve_run from the three user-initiated solve paths in SolvePanel: single-point (alpha and CL targeting), alpha polar, and parameter sweeps. Params record which mode was used, viscous vs inviscid, and panel count. Fired at dispatch rather than completion so the count reflects solves requested, independent of convergence. Consent Mode still gates delivery, so this collects nothing extra from users who decline cookies. Co-Authored-By: claude-flow --- .../src/components/panels/SolvePanel.tsx | 19 +++++++++++ flexfoil-ui/src/lib/analytics.test.ts | 32 +++++++++++++++++++ flexfoil-ui/src/lib/analytics.ts | 8 +++++ 3 files changed, 59 insertions(+) create mode 100644 flexfoil-ui/src/lib/analytics.test.ts diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index c9860e98..a6cb3785 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 00000000..5865f6ee --- /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 3b9994f1..dca40e6f 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;