Skip to content
Open
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
11 changes: 11 additions & 0 deletions .github/ISSUE_TEMPLATE/bug-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
name: Bug report
about: Report a problem in Flexfoil
labels: bug
---

## What happened?

## What did you expect?

Please keep private geometry, contact details, and other sensitive information out of this public issue.
13 changes: 13 additions & 0 deletions .github/ISSUE_TEMPLATE/feature-request.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions .github/ISSUE_TEMPLATE/question.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
name: Question
about: Ask a question about using Flexfoil
labels: question
---

## What do you need help with?

Please keep private geometry, contact details, and other sensitive information out of this public issue.
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,58 @@ 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.

### 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 using an explicit issue template. Labels come from
template metadata, so visitors do not need permission to assign labels. 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 && npm run build)
npx playwright test --config e2e/analytics/playwright.config.mts
```

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.
27 changes: 27 additions & 0 deletions docs-site/docs/web-app.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -178,3 +178,30 @@ 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.

GitHub feedback drafts select an explicit template for bugs, feature requests or
questions, preserving the typed title and message through the handoff. Template
metadata supplies the category label without requiring label-management permissions.
1 change: 1 addition & 0 deletions e2e/analytics/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test-results/
61 changes: 61 additions & 0 deletions e2e/analytics/actual-ui.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>);
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<unknown>);
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<unknown>);
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');
});
1 change: 1 addition & 0 deletions e2e/analytics/fixture.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<!doctype html><html><head><title>Solve analytics acceptance</title></head><body><div id="root"></div><script type="module" src="/e2e/analytics/fixture.tsx"></script></body></html>
23 changes: 23 additions & 0 deletions e2e/analytics/fixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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 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);
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(<><SolvePanel /><FeedbackWidget /><CookieConsent /><button onClick={() => window.dispatchEvent(new Event('flexfoil:analytics-preferences'))}>Analytics preferences</button></>);
18 changes: 18 additions & 0 deletions e2e/analytics/playwright.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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', 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,
}],
});
93 changes: 93 additions & 0 deletions e2e/analytics/product-analytics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';

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([]);
});
}

for (const [category, feedbackType, template, label] of [
['Feature', 'feature', 'feature-request.md', 'enhancement'],
['Bug', 'bug', 'bug-report.md', 'bug'],
['General', 'general', 'question.md', 'question'],
]) {
test(`${category} 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: category, 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('template')).toBe(template);
// Template metadata assigns labels without requiring the visitor to have label permissions.
expect(url.searchParams.has('labels')).toBe(false);
expect(readFileSync(`.github/ISSUE_TEMPLATE/${template}`, 'utf8')).toContain(`labels: ${label}`);
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(feedbackType);
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');
});
Loading
Loading