From a295f0e37b50365efd1ac9f314a5b130130903b2 Mon Sep 17 00:00:00 2001 From: aeronauty Date: Sun, 26 Jul 2026 00:49:59 +0100 Subject: [PATCH 1/5] 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; From 3e71453f610403b7596ec38f40dd169ca7d5633e Mon Sep 17 00:00:00 2001 From: aeronauty Date: Tue, 6 Oct 2026 08:36:57 +0200 Subject: [PATCH 2/5] fix(ui): make solve analytics explicitly opt in Keep solve event dispatch and parameter allocation disabled unless VITE_SOLVE_RUN_ANALYTICS=true. Cover all five solve modes with isolated default-off and explicit-on browser acceptance. Co-Authored-By: Codex --- README.md | 19 ++++++++++ e2e/analytics/.gitignore | 1 + e2e/analytics/fixture.html | 1 + e2e/analytics/fixture.tsx | 17 +++++++++ e2e/analytics/playwright.config.mts | 11 ++++++ e2e/analytics/solve-analytics.spec.ts | 22 +++++++++++ e2e/analytics/wasm-fixture.ts | 13 +++++++ flexfoil-ui/e2e/analytics.vite.config.ts | 12 ++++++ .../src/components/panels/SolvePanel.tsx | 38 +++++++++++-------- flexfoil-ui/src/lib/analytics.test.ts | 15 ++++++++ flexfoil-ui/src/lib/analytics.ts | 3 ++ flexfoil-ui/src/vite-env.d.ts | 1 + 12 files changed, 137 insertions(+), 16 deletions(-) create mode 100644 e2e/analytics/.gitignore create mode 100644 e2e/analytics/fixture.html create mode 100644 e2e/analytics/fixture.tsx create mode 100644 e2e/analytics/playwright.config.mts create mode 100644 e2e/analytics/solve-analytics.spec.ts create mode 100644 e2e/analytics/wasm-fixture.ts create mode 100644 flexfoil-ui/e2e/analytics.vite.config.ts diff --git a/README.md b/README.md index 5f0d77c2..997ae874 100644 --- a/README.md +++ b/README.md @@ -169,3 +169,22 @@ Copyright (c) 2026 Flexcompute, Inc. and Harry Smith. This repository includes third-party components and references commercial dependencies. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details on AG Grid Enterprise, the TWK Everett typeface, and other attributions. + +### Optional solve analytics + +Solve dispatches emit no `solve_run` events by default. To explicitly enable that feature, +set `VITE_SOLVE_RUN_ANALYTICS=true` when starting or building `flexfoil-ui`. The three solve +paths then report only solve mode, solver mode and panel count through the existing GA4 +helper and consent policy. Solver execution and results are unchanged by this flag. + +The opt-in regression checks run locally without GA or a built WASM solver: + +```sh +(cd flexfoil-ui && npx vitest run --maxWorkers=1 && npx tsc -b) +npx playwright test --config e2e/analytics/playwright.config.mts +``` + +The browser fixture mounts the real SolvePanel with an offline solver and in-memory +run persistence. It checks all five modes with the flag off and on, including solver +dispatch/results and the exact event payload. It does not validate solver numerics. +Chrome must be installed for this isolated fixture. diff --git a/e2e/analytics/.gitignore b/e2e/analytics/.gitignore new file mode 100644 index 00000000..51511d1f --- /dev/null +++ b/e2e/analytics/.gitignore @@ -0,0 +1 @@ +test-results/ diff --git a/e2e/analytics/fixture.html b/e2e/analytics/fixture.html new file mode 100644 index 00000000..d9cb5ae2 --- /dev/null +++ b/e2e/analytics/fixture.html @@ -0,0 +1 @@ +Solve analytics acceptance
diff --git a/e2e/analytics/fixture.tsx b/e2e/analytics/fixture.tsx new file mode 100644 index 00000000..46debb7d --- /dev/null +++ b/e2e/analytics/fixture.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import {createRoot} from 'react-dom/client'; +import {SolvePanel} from '../../flexfoil-ui/src/components/panels/SolvePanel'; +import {useRunStore} from '../../flexfoil-ui/src/stores/runStore'; +import {useRouteUiStore} from '../../flexfoil-ui/src/stores/routeUiStore'; +import {useAirfoilStore} from '../../flexfoil-ui/src/stores/airfoilStore'; +const mode = new URLSearchParams(location.search).get('mode'); +(window as any).__events = []; +(window as any).__solves = []; +window.gtag = (...args) => (window as any).__events.push(args); +useRunStore.setState({ready: true, lookup: () => null, addRun: async () => {}, addRunBatch: async () => {}, hashPanels: async () => 'offline-fixture'}); +useAirfoilStore.setState({solverMode: 'inviscid'}); +useRouteUiStore.setState({solveRunMode: mode === 'single_cl' ? 'cl' : 'alpha', solveTargetCl: 0.5, + solvePolarStart: 0, solvePolarEnd: 1, solvePolarStep: 1, + sweepPrimary: {param: 'alpha', start: 0, end: 1, step: 1}, + sweepSecondary: mode === 'sweep_2d' ? {param: 'mach', start: 0, end: 0.1, step: 0.1} : null}); +createRoot(document.getElementById('root')!).render(); diff --git a/e2e/analytics/playwright.config.mts b/e2e/analytics/playwright.config.mts new file mode 100644 index 00000000..656f9c79 --- /dev/null +++ b/e2e/analytics/playwright.config.mts @@ -0,0 +1,11 @@ +import {defineConfig} from '@playwright/test'; +const root = new URL('../../', import.meta.url).pathname; +export default defineConfig({testDir: '.', workers: 1, reporter: 'list', + outputDir: 'test-results/solve-analytics', use: {channel: 'chrome'}, + projects: [{name: 'default-off', use: {baseURL: 'http://127.0.0.1:18997'}}, + {name: 'explicit-on', use: {baseURL: 'http://127.0.0.1:18998'}}], + webServer: [false, true].map(on => ({cwd: root, + command: `flexfoil-ui/node_modules/.bin/vite --config flexfoil-ui/e2e/analytics.vite.config.ts --host 127.0.0.1 --port ${on ? 18998 : 18997} --strictPort`, + env: {VITE_SOLVE_RUN_ANALYTICS: on ? 'true' : 'false'}, + url: `http://127.0.0.1:${on ? 18998 : 18997}/e2e/analytics/fixture.html`, reuseExistingServer: false})), +}); diff --git a/e2e/analytics/solve-analytics.spec.ts b/e2e/analytics/solve-analytics.spec.ts new file mode 100644 index 00000000..dfe90194 --- /dev/null +++ b/e2e/analytics/solve-analytics.spec.ts @@ -0,0 +1,22 @@ +import {test, expect} from '@playwright/test'; +for (const mode of ['single_alpha', 'single_cl', 'polar', 'sweep_1d', 'sweep_2d']) { + test(`${mode}: analytics opt-in preserves solve dispatch`, async ({page}, info) => { + await page.route('https://**', route => route.abort()); + await page.goto(`/e2e/analytics/fixture.html?mode=${mode}`); + const button = page.getByRole('button', {name: mode === 'polar' ? 'α Polar' : mode.startsWith('sweep') ? 'Generate Sweep' : 'Run', exact: true}); + await expect(button).toBeEnabled(); + await button.click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBeGreaterThan(0); + await expect(button).toBeEnabled(); + const events = await page.evaluate(() => (window as any).__events); + const on = info.project.name === 'explicit-on'; + if (on) { + expect(events).toEqual([['event', 'solve_run', {solve_mode: mode, solver_mode: 'inviscid', n_panels: 24}]]); + } else expect(events).toEqual([]); + const solves = await page.evaluate(() => (window as any).__solves); + if (mode === 'single_cl') expect(solves.at(-1).cl).toBeCloseTo(0.5, 2); + else expect(solves).toEqual((mode === 'single_alpha' ? [0] : mode === 'sweep_2d' ? [0, 1, 0, 1] : [0, 1]) + .map(alpha => ({alpha, cl: alpha * 0.1}))); + await info.attach('solve-dispatch', {body: JSON.stringify({mode, on, solves}), contentType: 'application/json'}); + }); +} diff --git a/e2e/analytics/wasm-fixture.ts b/e2e/analytics/wasm-fixture.ts new file mode 100644 index 00000000..4758c37a --- /dev/null +++ b/e2e/analytics/wasm-fixture.ts @@ -0,0 +1,13 @@ +// An offline solver boundary for UI dispatch tests; these values are not CFD evidence. +export const isWasmReady = () => true; +export function analyzeAirfoilInviscid(_panels: unknown, alpha: number) { + const result = {success: true, converged: true, cl: alpha * 0.1, cd: 0.01, cm: 0, cp: [], cp_x: [], gamma: [], + psi_0: 0, iterations: 1, residual: 0, x_tr_upper: 1, x_tr_lower: 1}; + (window as any).__solves.push({alpha, cl: result.cl}); + return result; +} +export const analyzeAirfoil = analyzeAirfoilInviscid; +const unused = () => {throw new Error('Unexpected geometry call in offline analytics fixture');}; +export const generateNaca4 = unused, generateNaca4Xfoil = unused, + repanelWithSpacingAndCurvature = unused, repanelXfoil = unused, + deflectFlap = unused; diff --git a/flexfoil-ui/e2e/analytics.vite.config.ts b/flexfoil-ui/e2e/analytics.vite.config.ts new file mode 100644 index 00000000..46b753cb --- /dev/null +++ b/flexfoil-ui/e2e/analytics.vite.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react'; +import {fileURLToPath} from 'node:url'; +const root = fileURLToPath(new URL('../../', import.meta.url)); +const wasmFixture = fileURLToPath(new URL('../../e2e/analytics/wasm-fixture.ts', import.meta.url)); +export default defineConfig({root, plugins: [react(), { + name: 'offline-solver-for-analytics', enforce: 'pre', + resolveId(source) {if (source.endsWith('/lib/wasm') || source === './wasm') return wasmFixture;}, +}], resolve: {dedupe: ['react', 'react-dom'], alias: { + react: fileURLToPath(new URL('../node_modules/react', import.meta.url)), + 'react-dom': fileURLToPath(new URL('../node_modules/react-dom', import.meta.url)), +}}}); diff --git a/flexfoil-ui/src/components/panels/SolvePanel.tsx b/flexfoil-ui/src/components/panels/SolvePanel.tsx index a6cb3785..b487d2af 100644 --- a/flexfoil-ui/src/components/panels/SolvePanel.tsx +++ b/flexfoil-ui/src/components/panels/SolvePanel.tsx @@ -17,7 +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'; +import { SOLVE_RUN_ANALYTICS_ENABLED, trackEvent } from '../../lib/analytics'; type SolveOrCacheResult = { result: AnalysisResult | null; @@ -339,11 +339,13 @@ 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, - }); + if (SOLVE_RUN_ANALYTICS_ENABLED) { + 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)}` @@ -586,11 +588,13 @@ export function SolvePanel() { clearPolarSuppression(); - trackEvent('solve_run', { - solve_mode: 'polar', - solver_mode: isViscous ? 'viscous' : 'inviscid', - n_panels: panels.length - 1, - }); + if (SOLVE_RUN_ANALYTICS_ENABLED) { + 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'})` @@ -737,11 +741,13 @@ 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, - }); + if (SOLVE_RUN_ANALYTICS_ENABLED) { + 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'})` diff --git a/flexfoil-ui/src/lib/analytics.test.ts b/flexfoil-ui/src/lib/analytics.test.ts index 5865f6ee..8f5aca9e 100644 --- a/flexfoil-ui/src/lib/analytics.test.ts +++ b/flexfoil-ui/src/lib/analytics.test.ts @@ -10,6 +10,8 @@ beforeEach(() => { afterEach(() => { delete globals.window; + vi.unstubAllEnvs(); + vi.resetModules(); }); describe('trackEvent', () => { @@ -30,3 +32,16 @@ describe('trackEvent', () => { expect(() => trackEvent('solve_run')).not.toThrow(); }); }); + +describe('solve analytics opt-in', () => { + it.each([undefined, 'false', '1'])('is off without the exact explicit true value (%s)', async value => { + vi.stubEnv('VITE_SOLVE_RUN_ANALYTICS', value); + vi.resetModules(); + expect((await import('./analytics')).SOLVE_RUN_ANALYTICS_ENABLED).toBe(false); + }); + it('is enabled by the explicit build setting', async () => { + vi.stubEnv('VITE_SOLVE_RUN_ANALYTICS', 'true'); + vi.resetModules(); + expect((await import('./analytics')).SOLVE_RUN_ANALYTICS_ENABLED).toBe(true); + }); +}); diff --git a/flexfoil-ui/src/lib/analytics.ts b/flexfoil-ui/src/lib/analytics.ts index dca40e6f..a0d4f700 100644 --- a/flexfoil-ui/src/lib/analytics.ts +++ b/flexfoil-ui/src/lib/analytics.ts @@ -1,3 +1,6 @@ +// Explicit build-time opt-in; ordinary solve paths do not construct event parameters. +export const SOLVE_RUN_ANALYTICS_ENABLED = import.meta.env.VITE_SOLVE_RUN_ANALYTICS === 'true'; + const CONSENT_KEY = 'ff_cookie_consent'; const GA_ID = 'G-065GK6XBSR'; diff --git a/flexfoil-ui/src/vite-env.d.ts b/flexfoil-ui/src/vite-env.d.ts index b50d8409..4f747ccc 100644 --- a/flexfoil-ui/src/vite-env.d.ts +++ b/flexfoil-ui/src/vite-env.d.ts @@ -3,6 +3,7 @@ declare const __APP_VERSION__: string; interface ImportMetaEnv { + readonly VITE_SOLVE_RUN_ANALYTICS?: string; readonly VITE_AG_GRID_LICENSE_KEY: string; } From 433982773d37ae8b86f93174bfa49ef59a572c4a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 6 Oct 2026 06:43:47 +0000 Subject: [PATCH 3/5] fix(e2e): keep analytics specs out of the root Playwright suite The root config uses e2e/ as testDir, so solve-analytics.spec.ts ran against the Vite app and failed looking for fixture.html. --- playwright.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/playwright.config.ts b/playwright.config.ts index b04c7b2c..e03309ae 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -2,6 +2,8 @@ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: './e2e', + // Analytics specs need the isolated fixture servers in e2e/analytics/playwright.config.mts. + testIgnore: '**/analytics/**', fullyParallel: true, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, From ff097314724df94920549eb1d2cbc40ecbbfc02e Mon Sep 17 00:00:00 2001 From: aeronauty Date: Thu, 8 Oct 2026 14:18:04 +0200 Subject: [PATCH 4/5] feat: add feature requests and consented usage tracking --- .github/ISSUE_TEMPLATE/feature-request.md | 13 +++ README.md | 61 +++++++++++--- docs-site/docs/web-app.mdx | 23 +++++ e2e/analytics/actual-ui.spec.ts | 61 ++++++++++++++ e2e/analytics/fixture.tsx | 10 ++- e2e/analytics/playwright.config.mts | 19 +++-- e2e/analytics/product-analytics.spec.ts | 83 +++++++++++++++++++ e2e/analytics/solve-analytics.spec.ts | 4 +- flexfoil-ui/.env.production | 1 + flexfoil-ui/index.html | 7 +- flexfoil-ui/package-lock.json | 4 +- flexfoil-ui/package.json | 2 +- flexfoil-ui/src/App.css | 18 +++- flexfoil-ui/src/components/CookieConsent.tsx | 38 +++------ flexfoil-ui/src/components/DockingLayout.tsx | 14 +++- flexfoil-ui/src/components/FeedbackWidget.tsx | 55 ++++++++---- flexfoil-ui/src/components/MenuBar.tsx | 9 ++ flexfoil-ui/src/components/MobileLayout.tsx | 11 ++- flexfoil-ui/src/lib/analytics.test.ts | 80 ++++++++++++------ flexfoil-ui/src/lib/analytics.ts | 18 +++- flexfoil-ui/src/lib/version.ts | 10 +++ packages/flexfoil-python/pyproject.toml | 2 +- 22 files changed, 427 insertions(+), 116 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/feature-request.md create mode 100644 e2e/analytics/actual-ui.spec.ts create mode 100644 e2e/analytics/product-analytics.spec.ts create mode 100644 flexfoil-ui/.env.production diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md new file mode 100644 index 00000000..9f4ca96b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -0,0 +1,13 @@ +--- +name: Feature request +about: Suggest an improvement to Flexfoil and track its progress +labels: enhancement +--- + +## What problem are you trying to solve? + +## What would you like Flexfoil to do? + +## How do you handle this today? + +Please keep private geometry, contact details, and other sensitive information out of this public issue. diff --git a/README.md b/README.md index 997ae874..a87cdc47 100644 --- a/README.md +++ b/README.md @@ -170,21 +170,56 @@ This repository includes third-party components and references commercial dependencies. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) for details on AG Grid Enterprise, the TWK Everett typeface, and other attributions. -### Optional solve analytics - -Solve dispatches emit no `solve_run` events by default. To explicitly enable that feature, -set `VITE_SOLVE_RUN_ANALYTICS=true` when starting or building `flexfoil-ui`. The three solve -paths then report only solve mode, solver mode and panel count through the existing GA4 -helper and consent policy. Solver execution and results are unchanged by this flag. - -The opt-in regression checks run locally without GA or a built WASM solver: +### Feature requests and usage analytics + +The desktop and mobile **Feedback & requests** widget links to the public +[feature request tracker](https://github.com/flexcompute/flexfoil/issues?q=is%3Aissue+label%3Aenhancement). +Without `VITE_FEEDBACK_SHEET_URL`, the form opens a GitHub issue draft with the +chosen category and message. Users must sign in and submit on GitHub; a draft is +not a saved request. Feature requests use the existing `enhancement` label and +GitHub's open/closed status and discussion. A configured feedback service retains +the existing POST flow; its opaque response cannot confirm that a request was saved. + +Local development emits no custom usage events by default. Set +`VITE_SOLVE_RUN_ANALYTICS=true` to enable them. The tracked `.env.production` +enables this flag for production builds, including deployed and packaged UIs; +set `VITE_SOLVE_RUN_ANALYTICS=false` to override it. Each visitor must also accept analytics. **Analytics preferences** +in Help (desktop) or the mobile footer can reopen the consent choice. Rejection +or unavailable consent storage suppresses custom events. The initial Google tag +configuration is deferred until acceptance, with advertising signals disabled. + +| Event | Meaning | Coarse parameters | +| --- | --- | --- | +| `solve_run` | Solve requested, regardless of convergence | `solve_mode`, `solver_mode`, `n_panels` | +| `feature_use` | Panel selected, DAT imported/exported, SVG exported, documentation or feedback/tracker opened | `feature`, optional `panel_id` | +| `feedback_handoff` | GitHub draft opened; not a submitted issue | `feedback_type` | +| `feedback_sent` | Configured feedback POST completed; persistence unconfirmed | `feedback_type` | + +Event page metadata uses the application base URL and referrer origin only. +Custom payloads do not include geometry, filenames, feedback text, contacts or +precise location. Solving, importing and exporting still work with analytics off. +Google Analytics provides approximate country/city dimensions from network +location; no browser geolocation permission is requested. City availability and +accuracy depend on property settings, network routing and privacy thresholds. + +For reports, open the GA4 property containing measurement ID `G-065GK6XBSR`. +Register event-scoped custom dimensions `feature`, `panel_id`, `solve_mode`, +`solver_mode`, and `feedback_type`; register `n_panels` as a custom metric if needed. +Use an Exploration with event count and total users, broken down by feature/panel +or solve mode and Country/City. Filter for the events above and the Flexfoil +application base URL. Reports describe consenting visitors, not every user. +Property access and dimension registration are account-side setup; this code does +not configure the property or expose admin reports to public app users. Check +Enhanced Measurement settings so automatic events do not collect shared route +state or download names separately from these sanitized custom events. + +The regression checks run locally without contacting GA: ```sh -(cd flexfoil-ui && npx vitest run --maxWorkers=1 && npx tsc -b) +(cd flexfoil-ui && npx vitest run --maxWorkers=1 && npm run build) npx playwright test --config e2e/analytics/playwright.config.mts ``` -The browser fixture mounts the real SolvePanel with an offline solver and in-memory -run persistence. It checks all five modes with the flag off and on, including solver -dispatch/results and the exact event payload. It does not validate solver numerics. -Chrome must be installed for this isolated fixture. +The browser fixture mounts the real SolvePanel, feedback widget and consent UI +with an offline solver and in-memory run persistence. It verifies UI/event +contracts, not solver numerics or delivery to GA. Chrome must be installed. diff --git a/docs-site/docs/web-app.mdx b/docs-site/docs/web-app.mdx index f6b797c9..9c660c4d 100644 --- a/docs-site/docs/web-app.mdx +++ b/docs-site/docs/web-app.mdx @@ -178,3 +178,26 @@ click the **Search...** button in the menu bar. database for backup or sharing. - **Mobile layout** — the app adapts to smaller screens with a stacked panel layout. + +## Feature Requests and Usage Analytics + +Open **Feedback & requests** in the desktop or mobile footer to submit a bug, +feature request or question. The feature request tracker opens public GitHub +issues tagged `enhancement`; open/closed status and discussion show progress. +When no private feedback service is configured, the form opens a GitHub draft. +Sign in and submit it there to save the request. Do not include private data in +public issues. A configured private feedback service uses its existing POST flow; +Flexfoil cannot confirm persistence from the service's opaque response. + +With analytics consent, the production app measures solve requests, panel +selections, DAT imports/exports, SVG exports, documentation and feedback activity. +It sends coarse feature metadata, not geometry, filenames, feedback text or contact +details. Shared URL query/fragment data is omitted from custom event page metadata. +Google Analytics supplies approximate country/city reporting; Flexfoil does not +request precise browser location. Reports cover consenting visitors and are viewed +in the private GA4 property, not in the public application. + +Reopen **Help → Analytics preferences** on desktop, or **Analytics preferences** +in the mobile footer, to accept or reject analytics. Rejection stops custom usage +events. For build settings, event definitions and reporting setup, see the +repository README's “Feature requests and usage analytics” section. diff --git a/e2e/analytics/actual-ui.spec.ts b/e2e/analytics/actual-ui.spec.ts new file mode 100644 index 00000000..f4f8b2cf --- /dev/null +++ b/e2e/analytics/actual-ui.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from '@playwright/test'; + +test.beforeEach(async ({ page }) => { + await page.route('https://**', route => route.abort()); + await page.addInitScript(() => { + localStorage.setItem('flexfoil-onboarding', JSON.stringify({ + lastSeenVersion: '1.0.0', completedTours: ['welcome'], tourProgress: {}, + })); + localStorage.setItem('ff_cookie_consent', 'granted'); + }); +}); + +test('desktop panel selection and export use the real layout and WASM app', async ({ page }, info) => { + await page.goto('/'); + await page.locator('.flexlayout__tab_button').filter({ hasText: 'Data Explorer' }).click(); + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && event[1] === 'feature_use' + && (event[2] as any).panel_id === 'data-explorer'; + }))).toBe(true); + await page.getByRole('button', { name: 'File', exact: true }).click(); + const download = page.waitForEvent('download'); + await page.getByText('Export .dat...', { exact: true }).click(); + await download; + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && (event[2] as any)?.feature === 'export_dat'; + }))).toBe(true); + await page.getByRole('button', { name: 'Help', exact: true }).click(); + await page.getByText('Analytics preferences', { exact: true }).click(); + await expect(page.getByRole('button', { name: 'Reject', exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('link', { name: 'View feature request tracker' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('feedback.png'), animations: 'disabled' }); + await info.attach('desktop-feedback', { path: info.outputPath('feedback.png'), contentType: 'image/png' }); +}); + +test('mobile panels, feedback and consent controls are reachable', async ({ page }, info) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await page.locator('.mobile-tabs').getByRole('button', { name: 'Solve', exact: true }).click(); + await expect.poll(() => page.evaluate(() => window.dataLayer?.some(value => { + const event = Array.from(value as ArrayLike); + return event[0] === 'event' && (event[2] as any)?.panel_id === 'solve'; + }))).toBe(true); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('textbox', { name: 'Feedback message' })).toBeVisible(); + await expect(page.getByRole('link', { name: 'View feature request tracker' })).toBeVisible(); + await page.screenshot({ path: info.outputPath('feedback.png'), animations: 'disabled' }); + await info.attach('mobile-feedback', { path: info.outputPath('feedback.png'), contentType: 'image/png' }); + await page.setViewportSize({ width: 320, height: 568 }); + const dialog = page.getByRole('dialog', { name: 'Feedback form' }); + const box = await dialog.boundingBox(); + expect(box?.x).toBeGreaterThanOrEqual(0); + expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(320); + await page.getByRole('button', { name: 'Cancel', exact: true }).click(); + await page.getByRole('button', { name: 'Analytics preferences', exact: true }).click(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + expect(await page.evaluate(() => localStorage.getItem('ff_cookie_consent'))).toBe('denied'); +}); diff --git a/e2e/analytics/fixture.tsx b/e2e/analytics/fixture.tsx index 46debb7d..22ffdabe 100644 --- a/e2e/analytics/fixture.tsx +++ b/e2e/analytics/fixture.tsx @@ -1,10 +1,16 @@ import React from 'react'; +import '../../flexfoil-ui/src/App.css'; import {createRoot} from 'react-dom/client'; +import {FeedbackWidget} from '../../flexfoil-ui/src/components/FeedbackWidget'; +import {CookieConsent} from '../../flexfoil-ui/src/components/CookieConsent'; import {SolvePanel} from '../../flexfoil-ui/src/components/panels/SolvePanel'; import {useRunStore} from '../../flexfoil-ui/src/stores/runStore'; import {useRouteUiStore} from '../../flexfoil-ui/src/stores/routeUiStore'; import {useAirfoilStore} from '../../flexfoil-ui/src/stores/airfoilStore'; -const mode = new URLSearchParams(location.search).get('mode'); +const params = new URLSearchParams(location.search); +const mode = params.get('mode'); +const consent = params.get('consent') ?? 'granted'; +if (consent !== 'missing') localStorage.setItem('ff_cookie_consent', consent); (window as any).__events = []; (window as any).__solves = []; window.gtag = (...args) => (window as any).__events.push(args); @@ -14,4 +20,4 @@ useRouteUiStore.setState({solveRunMode: mode === 'single_cl' ? 'cl' : 'alpha', s solvePolarStart: 0, solvePolarEnd: 1, solvePolarStep: 1, sweepPrimary: {param: 'alpha', start: 0, end: 1, step: 1}, sweepSecondary: mode === 'sweep_2d' ? {param: 'mach', start: 0, end: 0.1, step: 0.1} : null}); -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(<>); diff --git a/e2e/analytics/playwright.config.mts b/e2e/analytics/playwright.config.mts index 656f9c79..03803575 100644 --- a/e2e/analytics/playwright.config.mts +++ b/e2e/analytics/playwright.config.mts @@ -2,10 +2,17 @@ import {defineConfig} from '@playwright/test'; const root = new URL('../../', import.meta.url).pathname; export default defineConfig({testDir: '.', workers: 1, reporter: 'list', outputDir: 'test-results/solve-analytics', use: {channel: 'chrome'}, - projects: [{name: 'default-off', use: {baseURL: 'http://127.0.0.1:18997'}}, - {name: 'explicit-on', use: {baseURL: 'http://127.0.0.1:18998'}}], - webServer: [false, true].map(on => ({cwd: root, - command: `flexfoil-ui/node_modules/.bin/vite --config flexfoil-ui/e2e/analytics.vite.config.ts --host 127.0.0.1 --port ${on ? 18998 : 18997} --strictPort`, - env: {VITE_SOLVE_RUN_ANALYTICS: on ? 'true' : 'false'}, - url: `http://127.0.0.1:${on ? 18998 : 18997}/e2e/analytics/fixture.html`, reuseExistingServer: false})), + projects: [{name: 'default-off', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18997'}}, + {name: 'explicit-on', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18998'}}, + {name: 'feedback-service', testIgnore: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:18999'}}, + {name: 'actual-app', testMatch: 'actual-ui.spec.ts', use: {baseURL: 'http://127.0.0.1:19000'}}], + webServer: [...[18997, 18998, 18999].map(port => ({cwd: root, + command: `flexfoil-ui/node_modules/.bin/vite --config flexfoil-ui/e2e/analytics.vite.config.ts --host 127.0.0.1 --port ${port} --strictPort`, + env: {VITE_SOLVE_RUN_ANALYTICS: port === 18997 ? 'false' : 'true', + VITE_FEEDBACK_SHEET_URL: port === 18999 ? '/feedback-service' : ''}, + url: `http://127.0.0.1:${port}/e2e/analytics/fixture.html`, reuseExistingServer: false})), { + command: 'npm run dev -- --host 127.0.0.1 --port 19000 --strictPort', + cwd: `${root}flexfoil-ui`, env: {VITE_SOLVE_RUN_ANALYTICS: 'true', VITE_FEEDBACK_SHEET_URL: ''}, + url: 'http://127.0.0.1:19000', reuseExistingServer: false, + }], }); diff --git a/e2e/analytics/product-analytics.spec.ts b/e2e/analytics/product-analytics.spec.ts new file mode 100644 index 00000000..a5b6cd3b --- /dev/null +++ b/e2e/analytics/product-analytics.spec.ts @@ -0,0 +1,83 @@ +import { test, expect } from '@playwright/test'; + +for (const consent of ['missing', 'denied']) { + test(`solving works without ${consent} analytics consent`, async ({ page }) => { + await page.route('https://**', route => route.abort()); + await page.goto(`/e2e/analytics/fixture.html?consent=${consent}`); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(1); + expect(await page.evaluate(() => (window as any).__events)).toEqual([]); + }); +} + +test('feature request handoff is public, correctly categorized and never claims submission', async ({ page }, info) => { + test.skip(info.project.name === 'feedback-service'); + await page.route('https://**', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?geometry=private#private-foil'); + await page.evaluate(() => { window.open = url => { (window as any).__draftUrl = String(url); return null; }; }); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await page.getByRole('button', { name: 'Feature', exact: true }).click(); + await page.getByRole('textbox', { name: 'Feedback message' }).fill('Please add a comparison plot\nPrivate example for the request only'); + const tracker = page.getByRole('link', { name: 'View feature request tracker' }); + expect(new URL(await tracker.getAttribute('href') as string).searchParams.get('q')).toBe('is:issue label:enhancement'); + await expect(page.getByText('A GitHub account is required', { exact: false })).toBeVisible(); + await page.getByRole('button', { name: 'Continue on GitHub' }).click(); + const url = new URL(await page.evaluate(() => (window as any).__draftUrl)); + expect(url.origin).toBe('https://github.com'); + expect(url.pathname).toBe('/flexcompute/flexfoil/issues/new'); + expect(url.searchParams.get('labels')).toBe('enhancement'); + expect(url.searchParams.get('body')).toContain('Please add a comparison plot'); + await expect(page.getByText('Opening a draft does not submit it.', { exact: false })).toBeVisible(); + const events = await page.evaluate(() => (window as any).__events); + if (info.project.name === 'explicit-on') { + expect(events.map((event: any[]) => event[1])).toEqual(['feature_use', 'feedback_handoff']); + expect(events[1][2].feedback_type).toBe('feature'); + expect(events[1][2].page_location).toBe(new URL('/', info.project.use.baseURL).href); + } else expect(events).toEqual([]); + expect(JSON.stringify(events)).not.toContain('private'); + expect(JSON.stringify(events)).not.toContain('comparison plot'); + await info.attach('request-handoff', { body: await page.screenshot(), contentType: 'image/png' }); + await page.getByRole('button', { name: 'Close', exact: true }).click(); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await expect(page.getByRole('textbox', { name: 'Feedback message' })).toHaveValue('Please add a comparison plot\nPrivate example for the request only'); +}); + +test('acceptance and revocation control subsequent custom events', async ({ page }, info) => { + await page.route('https://**', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?consent=missing'); + await page.getByRole('button', { name: 'Accept', exact: true }).click(); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(1); + const events = await page.evaluate(() => (window as any).__events.filter((event: any[]) => event[0] === 'event')); + expect(events.length).toBe(info.project.name === 'default-off' ? 0 : 1); + await page.getByRole('button', { name: 'Analytics preferences', exact: true }).click(); + await page.getByRole('button', { name: 'Reject', exact: true }).click(); + await page.evaluate(() => { (window as any).__events = []; }); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBe(2); + expect(await page.evaluate(() => (window as any).__events)).toEqual([]); +}); + +test('configured feedback handles transport failure and distinguishes sending from persistence', async ({ page }, info) => { + test.skip(info.project.name !== 'feedback-service'); + await page.route('https://**', route => route.abort()); + await page.route('**/feedback-service', route => route.abort()); + await page.goto('/e2e/analytics/fixture.html?geometry=private#private-foil'); + await page.getByRole('button', { name: 'Send feedback' }).click(); + await page.getByRole('textbox', { name: 'Feedback message' }).fill('A feature suggestion'); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('Something went wrong. Please try again.')).toBeVisible(); + await page.unroute('**/feedback-service'); + let body: any; + await page.route('**/feedback-service', async route => { + body = route.request().postDataJSON(); + await route.fulfill({ status: 200, body: '{}' }); + }); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText('delivery cannot be confirmed here.', { exact: false })).toBeVisible(); + expect(body.url).not.toContain('private'); + expect(body.message).toBe('A feature suggestion'); + const events = await page.evaluate(() => (window as any).__events); + expect(events.at(-1)[1]).toBe('feedback_sent'); + expect(JSON.stringify(events)).not.toContain('A feature suggestion'); +}); diff --git a/e2e/analytics/solve-analytics.spec.ts b/e2e/analytics/solve-analytics.spec.ts index dfe90194..e7e753ab 100644 --- a/e2e/analytics/solve-analytics.spec.ts +++ b/e2e/analytics/solve-analytics.spec.ts @@ -9,9 +9,9 @@ for (const mode of ['single_alpha', 'single_cl', 'polar', 'sweep_1d', 'sweep_2d' await expect.poll(() => page.evaluate(() => (window as any).__solves.length)).toBeGreaterThan(0); await expect(button).toBeEnabled(); const events = await page.evaluate(() => (window as any).__events); - const on = info.project.name === 'explicit-on'; + const on = info.project.name !== 'default-off'; if (on) { - expect(events).toEqual([['event', 'solve_run', {solve_mode: mode, solver_mode: 'inviscid', n_panels: 24}]]); + expect(events).toEqual([['event', 'solve_run', {solve_mode: mode, solver_mode: 'inviscid', n_panels: 24, page_location: new URL('/', info.project.use.baseURL).href, page_referrer: ''}]]); } else expect(events).toEqual([]); const solves = await page.evaluate(() => (window as any).__solves); if (mode === 'single_cl') expect(solves.at(-1).cl).toBeCloseTo(0.5, 2); diff --git a/flexfoil-ui/.env.production b/flexfoil-ui/.env.production new file mode 100644 index 00000000..75fda6d5 --- /dev/null +++ b/flexfoil-ui/.env.production @@ -0,0 +1 @@ +VITE_SOLVE_RUN_ANALYTICS=true diff --git a/flexfoil-ui/index.html b/flexfoil-ui/index.html index c4852411..b2e78386 100644 --- a/flexfoil-ui/index.html +++ b/flexfoil-ui/index.html @@ -16,18 +16,13 @@ ad_storage: 'denied', ad_user_data: 'denied', ad_personalization: 'denied', - wait_for_update: 500, - region: ['AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR', - 'DE','GR','HU','IE','IT','LV','LT','LU','MT','NL', - 'PL','PT','RO','SK','SI','ES','SE','IS','LI','NO', - 'GB','CH'] + wait_for_update: 500 }); diff --git a/flexfoil-ui/package-lock.json b/flexfoil-ui/package-lock.json index 85ac836b..f9d2f1f7 100644 --- a/flexfoil-ui/package-lock.json +++ b/flexfoil-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "flexfoil-ui", - "version": "1.1.0-dev", + "version": "1.1.8", "license": "MIT", "dependencies": { "ag-grid-community": "^35.1.0", diff --git a/flexfoil-ui/package.json b/flexfoil-ui/package.json index 6fea9a6a..e174fa11 100644 --- a/flexfoil-ui/package.json +++ b/flexfoil-ui/package.json @@ -2,7 +2,7 @@ "name": "flexfoil-ui", "private": true, "license": "MIT", - "version": "1.1.4", + "version": "1.1.8", "type": "module", "scripts": { "dev": "vite", diff --git a/flexfoil-ui/src/App.css b/flexfoil-ui/src/App.css index 27287ea7..2b5b89c9 100644 --- a/flexfoil-ui/src/App.css +++ b/flexfoil-ui/src/App.css @@ -988,8 +988,10 @@ button.primary:hover { display: flex; align-items: center; justify-content: center; - height: 24px; - padding: 0 10px; + min-height: 48px; + flex-wrap: wrap; + gap: 8px; + padding: 6px 10px; background: var(--brand-footer-surface); border-top: 1px solid var(--border-color); flex-shrink: 0; @@ -1130,13 +1132,16 @@ button.primary:hover { bottom: 42px; right: 16px; z-index: 9001; - width: 340px; + width: min(340px, calc(100vw - 32px)); + font-size: 13px; + color: var(--text-primary); + letter-spacing: normal; max-height: calc(100vh - 60px); background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 10px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35); - overflow: hidden; + overflow-y: auto; animation: feedback-slide-up 0.18s ease-out; } @@ -1145,6 +1150,11 @@ button.primary:hover { to { opacity: 1; transform: translateY(0); } } +.feedback-panel a, +.mobile-footer .brand-footer__link { + color: var(--text-primary); +} + .feedback-panel__form { display: flex; flex-direction: column; diff --git a/flexfoil-ui/src/components/CookieConsent.tsx b/flexfoil-ui/src/components/CookieConsent.tsx index 686abc10..2d9f610b 100644 --- a/flexfoil-ui/src/components/CookieConsent.tsx +++ b/flexfoil-ui/src/components/CookieConsent.tsx @@ -1,15 +1,18 @@ import { useCallback, useEffect, useState } from 'react'; -import { getStoredConsent, updateGtagConsent, type ConsentStatus } from '../lib/analytics'; +import { getStoredConsent, updateGtagConsent } from '../lib/analytics'; export function CookieConsent() { const [visible, setVisible] = useState(false); useEffect(() => { + const show = () => setVisible(true); + window.addEventListener('flexfoil:analytics-preferences', show); const stored = getStoredConsent(); - if (stored === null) { - const timer = setTimeout(() => setVisible(true), 1200); - return () => clearTimeout(timer); - } + const timer = stored === null ? setTimeout(() => setVisible(true), 1200) : undefined; + return () => { + window.removeEventListener('flexfoil:analytics-preferences', show); + clearTimeout(timer); + }; }, []); const handleAccept = useCallback(() => { @@ -28,8 +31,9 @@ export function CookieConsent() {

- We use cookies for anonymous usage analytics to improve this tool. - No personal data is collected or shared with advertisers.{' '} + With your permission, Google Analytics measures feature use and approximate + location to help us improve Flexfoil. We do not send airfoil geometry, + feedback text, or contact details as analytics events.{' '} ); } - -/** - * Manage consent after initial choice -- call from a settings menu to let - * users change their mind (GDPR right to withdraw). - */ -export function useCookieConsent() { - const [consent, setConsent] = useState(getStoredConsent); - - const grant = useCallback(() => { - updateGtagConsent('granted'); - setConsent('granted'); - }, []); - - const revoke = useCallback(() => { - updateGtagConsent('denied'); - setConsent('denied'); - }, []); - - return { consent, grant, revoke }; -} diff --git a/flexfoil-ui/src/components/DockingLayout.tsx b/flexfoil-ui/src/components/DockingLayout.tsx index 84f7cde1..c558a47b 100644 --- a/flexfoil-ui/src/components/DockingLayout.tsx +++ b/flexfoil-ui/src/components/DockingLayout.tsx @@ -3,7 +3,7 @@ */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { Layout, Model, Actions, DockLocation, type IJsonModel } from 'flexlayout-react'; +import { Layout, Model, Actions, DockLocation, TabNode, type IJsonModel } from 'flexlayout-react'; // Panel components import { AirfoilCanvas } from './AirfoilCanvas'; @@ -24,9 +24,10 @@ import { FeedbackWidget } from './FeedbackWidget'; import { SolverStatusIndicator } from './SolverStatusBar'; import { MobileLayout } from './MobileLayout'; import { LayoutProvider } from '../contexts/LayoutContext'; -import { defaultLayoutJson, PANELS } from '../layoutConfig'; +import { defaultLayoutJson, PANELS, isPanelId } from '../layoutConfig'; import { useRouteUiStore } from '../stores/routeUiStore'; import { useIsMobile } from '../hooks/useIsMobile'; +import { trackEvent } from '../lib/analytics'; // Storage keys const LAYOUT_STORAGE_KEY = 'flexfoil-layout-v4'; @@ -444,7 +445,14 @@ function DesktopLayout({ wasmStatus }: DockingLayoutProps) { ref={layoutRef} model={model} factory={factory} - onModelChange={handleModelChange} + onModelChange={(nextModel, action) => { + handleModelChange(nextModel); + if (action.type === Actions.SELECT_TAB) { + const node = nextModel.getNodeById(action.data.tabNode); + const panel = node instanceof TabNode ? node.getComponent() : undefined; + if (isPanelId(panel)) trackEvent('feature_use', { feature: 'panel_select', panel_id: panel }); + } + }} onRenderTabSet={onRenderTabSet} />

diff --git a/flexfoil-ui/src/components/FeedbackWidget.tsx b/flexfoil-ui/src/components/FeedbackWidget.tsx index e6c80c29..5d2c8436 100644 --- a/flexfoil-ui/src/components/FeedbackWidget.tsx +++ b/flexfoil-ui/src/components/FeedbackWidget.tsx @@ -1,4 +1,7 @@ import { useCallback, useRef, useState } from 'react'; +import { trackEvent } from '../lib/analytics'; + +const REQUEST_TRACKER_URL = 'https://github.com/flexcompute/flexfoil/issues?q=is%3Aissue+label%3Aenhancement'; const GOOGLE_SHEET_URL: string | undefined = import.meta.env.VITE_FEEDBACK_SHEET_URL; @@ -10,7 +13,7 @@ const TYPE_LABELS: Record = { general: { label: 'General', icon: '💬' }, }; -type SubmitState = 'idle' | 'sending' | 'success' | 'error'; +type SubmitState = 'idle' | 'sending' | 'success' | 'github' | 'error'; export function FeedbackWidget() { const [open, setOpen] = useState(false); @@ -30,6 +33,7 @@ export function FeedbackWidget() { const handleClose = useCallback(() => { setOpen(false); if (submitState === 'success') reset(); + else if (submitState === 'github') setSubmitState('idle'); }, [submitState, reset]); const handleSubmit = useCallback( @@ -37,6 +41,18 @@ export function FeedbackWidget() { e.preventDefault(); if (!message.trim()) return; + if (!GOOGLE_SHEET_URL) { + const params = new URLSearchParams({ + title: message.trim().split('\n')[0].slice(0, 80), + body: message.trim(), + labels: type === 'feature' ? 'enhancement' : type === 'bug' ? 'bug' : 'question', + }); + window.open(`https://github.com/flexcompute/flexfoil/issues/new?${params}`, '_blank', 'noopener,noreferrer'); + trackEvent('feedback_handoff', { feedback_type: type }); + setSubmitState('github'); + return; + } + setSubmitState('sending'); const payload = { @@ -44,24 +60,20 @@ export function FeedbackWidget() { message: message.trim(), contact: contact.trim() || undefined, timestamp: new Date().toISOString(), - url: window.location.href, + url: window.location.origin + window.location.pathname, userAgent: navigator.userAgent, }; - if (!GOOGLE_SHEET_URL) { - console.info('[Feedback] No VITE_FEEDBACK_SHEET_URL configured. Payload:', payload); - setSubmitState('success'); - return; - } - try { await fetch(GOOGLE_SHEET_URL, { method: 'POST', + // ponytail: opaque responses cannot confirm persistence; use a CORS-aware service for receipts. mode: 'no-cors', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); setSubmitState('success'); + trackEvent('feedback_sent', { feedback_type: type }); } catch (err) { console.error('Feedback submission failed:', err); setSubmitState('error'); @@ -77,6 +89,8 @@ export function FeedbackWidget() { onClick={() => { setOpen((prev) => !prev); if (submitState === 'success') reset(); + else if (submitState === 'github') setSubmitState('idle'); + if (!open) trackEvent('feature_use', { feature: 'feedback_open' }); }} aria-label="Send feedback" title="Send feedback" @@ -90,16 +104,16 @@ export function FeedbackWidget() { strokeLinejoin="round" /> - Feedback + Feedback & requests {open && (
- {submitState === 'success' ? ( + {submitState === 'success' || submitState === 'github' ? (
✓ -

Thanks for your feedback!

-

We read every submission.

+

{submitState === 'github' ? 'Finish your submission on GitHub' : 'Feedback sent'}

+

{submitState === 'github' ? 'Submit your draft on GitHub to add it to the tracker. Opening a draft does not submit it.' : 'The request was sent to our feedback service; delivery cannot be confirmed here.'}

@@ -107,7 +121,7 @@ export function FeedbackWidget() { ) : (
- Send Feedback + Feedback & feature requests
@@ -116,6 +130,8 @@ export function FeedbackWidget() { key={t} type="button" className={`feedback-panel__type-btn${type === t ? ' feedback-panel__type-btn--active' : ''}`} + aria-label={TYPE_LABELS[t].label} + aria-pressed={type === t} onClick={() => setType(t)} > {TYPE_LABELS[t].icon} @@ -126,6 +142,7 @@ export function FeedbackWidget() {