diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index cd75430e..c7265c50 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -163,6 +163,11 @@ class CompileRequest(BaseModel): board_fqbn: str = "arduino:avr:uno" # Optional: associate this compile with a project for analytics project_id: str | None = None + # Optional: the editor's BoardKind (e.g. 'badger-2350'). Purely for + # analytics — several distinct boards can share one FQBN (the Pimoroni + # RP2350 boards all compile as rpipico2), and only the kind can tell + # them apart. Rides into the metric hook's `extra`. + board_kind: str | None = None # Per-board ESP32 build options (Partition Scheme, CPU Freq, Flash Mode, # PSRAM, etc.). Loose dict so the frontend can add fields without a # backend deploy — espidf_compiler.compile validates known keys and @@ -514,6 +519,7 @@ async def _compile_job( "has_wifi": response.has_wifi, "async": True, "initiated_by": request.initiated_by, + "board_kind": request.board_kind, "partition_scheme": (request.board_options or {}).get("partitionScheme"), "spiffs_file_count": len(request.spiffs_files or []), }, @@ -535,7 +541,7 @@ async def _compile_job( success=False, duration_ms=int((time.monotonic() - started) * 1000), error_kind="exception", - extra={"file_count": len(files), "exception": str(exc)[:200], "async": True, "initiated_by": request.initiated_by}, + extra={"file_count": len(files), "exception": str(exc)[:200], "async": True, "initiated_by": request.initiated_by, "board_kind": request.board_kind}, ) @@ -568,7 +574,7 @@ async def compile_sketch( success=False, duration_ms=int((time.monotonic() - started) * 1000), error_kind="exception", - extra={"file_count": len(files), "exception": str(e)[:200], "initiated_by": request.initiated_by}, + extra={"file_count": len(files), "exception": str(e)[:200], "initiated_by": request.initiated_by, "board_kind": request.board_kind}, request=http_request, ) raise HTTPException(status_code=500, detail=str(e)) @@ -585,6 +591,7 @@ async def compile_sketch( "file_count": len(files), "has_wifi": response.has_wifi, "initiated_by": request.initiated_by, + "board_kind": request.board_kind, "partition_scheme": (request.board_options or {}).get("partitionScheme"), "spiffs_file_count": len(request.spiffs_files or []), }, diff --git a/frontend/src/components/ComponentPickerModal.tsx b/frontend/src/components/ComponentPickerModal.tsx index b8506bef..58be7a36 100644 --- a/frontend/src/components/ComponentPickerModal.tsx +++ b/frontend/src/components/ComponentPickerModal.tsx @@ -43,6 +43,7 @@ import { ONLINE_ONLY_BOARD_ADS, ONLINE_ONLY_COMPONENT_ADS, ONLINE_EDITOR_URL, + isOnlineOnlyAdSuppressed, type OnlineOnlyBoardAd, type OnlineOnlyComponentAd, } from '../lib/onlineOnlyBoards'; @@ -293,6 +294,7 @@ export const ComponentPickerModal: React.FC = ({ return ONLINE_ONLY_COMPONENT_ADS.filter( (ad) => !registry.getById(ad.id) && + !isOnlineOnlyAdSuppressed(ad.id) && (selectedCategory === 'all' || ad.category === selectedCategory) && (!q || ad.label.toLowerCase().includes(q)), ); @@ -858,7 +860,10 @@ const BoardCard: React.FC = ({ kind, onSelect, hoverApi }) => { // Hidden automatically in any build that registers the real BoardKind. /** Recomputed on access (not module load): overlay board registration patches * BOARD_KIND_LABELS at mount, which must hide the corresponding ad. */ -const visibleBoardAds = () => ONLINE_ONLY_BOARD_ADS.filter((ad) => !(ad.id in BOARD_KIND_LABELS)); +const visibleBoardAds = () => + ONLINE_ONLY_BOARD_ADS.filter( + (ad) => !(ad.id in BOARD_KIND_LABELS) && !isOnlineOnlyAdSuppressed(ad.id), + ); /** Teal "ONLINE" pill: the board runs (free) in the hosted editor. */ const OnlineBadge: React.FC = () => ( diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 4d011d77..a27569d3 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -234,6 +234,7 @@ export const EditorToolbar = ({ void reportRunEvent({ project_id: currentProject?.id ?? null, board_fqbn: fqbn ?? null, + board_kind: boardKind ?? null, engine: engine ?? null, }); }, @@ -571,6 +572,7 @@ export const EditorToolbar = ({ { boardOptions: activeBoard?.boardOptions, spiffsFiles: activeBoard?.spiffsFiles, + boardKind: kind ?? undefined, // P2.4 — THIS board's declared manifest (compile scope). Per-board so // two boards can use different libraries without clashing. libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, @@ -1077,7 +1079,7 @@ export const EditorToolbar = ({ })), ]); }, - { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, + { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, boardKind: board.boardKind, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, ); const resultLogs = parseCompileResult(result, label, boardTarget, lastStreamedLen > 0); diff --git a/frontend/src/lib/onlineOnlyBoards.ts b/frontend/src/lib/onlineOnlyBoards.ts index e25ccf6f..3f2c5ec2 100644 --- a/frontend/src/lib/onlineOnlyBoards.ts +++ b/frontend/src/lib/onlineOnlyBoards.ts @@ -25,6 +25,27 @@ export interface OnlineOnlyBoardAd { /** Where the ad cards send the user. */ export const ONLINE_EDITOR_URL = 'https://velxio.com'; +/** + * Ad suppression — the hosted overlay's escape hatch. + * + * The "ad disappears when the real thing registers" contract breaks down in + * the hosted build itself: an item the overlay knows about but chose NOT to + * register (launch embargo) must not fall back to an ad card that says + * "available in the online editor" — the user is already IN the online + * editor. The overlay calls this once at mount with every id it manages; + * items it registers replace their ad anyway, items it embargoes simply + * vanish. Pure OSS builds never call it, so ads behave exactly as before. + */ +const suppressedAdIds = new Set(); + +export function suppressOnlineOnlyAds(ids: string[]): void { + for (const id of ids) suppressedAdIds.add(id); +} + +export function isOnlineOnlyAdSuppressed(id: string): boolean { + return suppressedAdIds.has(id); +} + export const ONLINE_ONLY_BOARD_ADS: OnlineOnlyBoardAd[] = [ { id: 'esp32-c6', diff --git a/frontend/src/services/compilation.ts b/frontend/src/services/compilation.ts index 41331a6c..27f9f032 100644 --- a/frontend/src/services/compilation.ts +++ b/frontend/src/services/compilation.ts @@ -27,6 +27,10 @@ export interface CompileExtras { // Who triggered the compile — 'agent' when the AI assistant's tool did. // Threads through to backend metrics; omitted = manual user action. initiatedBy?: 'agent'; + // The editor's BoardKind — analytics only. Distinct boards can share one + // FQBN (Pimoroni RP2350 boards all compile as rpipico2); the kind is the + // only identifier that tells them apart in the metrics. + boardKind?: string; } export interface CompileResult { @@ -122,6 +126,7 @@ export async function compileCode( project_id: projectId ?? null, board_options, spiffs_files, + board_kind: extras?.boardKind ?? null, libraries, language: extras?.language ?? null, initiated_by: extras?.initiatedBy ?? null, diff --git a/frontend/src/services/metricsService.ts b/frontend/src/services/metricsService.ts index 6a4d1e3c..bb162387 100644 --- a/frontend/src/services/metricsService.ts +++ b/frontend/src/services/metricsService.ts @@ -12,6 +12,8 @@ api.interceptors.request.use((config) => { export interface RunEventPayload { project_id?: string | null; board_fqbn?: string | null; + /** Editor BoardKind — disambiguates boards that share an FQBN (analytics). */ + board_kind?: string | null; /** Which engine took this run — 'instant' (browser) or 'linux' (guest). * Absent for boards where the question doesn't apply. */ engine?: 'instant' | 'linux' | null;