From 2182c3803272a1e4dabacf8907b8c023ca0730be Mon Sep 17 00:00:00 2001 From: OthmaneZ05 Date: Wed, 19 Aug 2026 23:31:10 -0400 Subject: [PATCH] feat(telemetry): opt-in anonymous learning-funnel events --- README.md | 22 +++ .../learning/hooks/useLearningPlayer.test.ts | 137 ++++++++++++++++++ .../learning/hooks/useLearningPlayer.ts | 81 ++++++++++- .../src/features/telemetry/consent.test.ts | 62 ++++++++ frontend/src/features/telemetry/consent.ts | 65 +++++++++ .../src/features/telemetry/telemetry.test.ts | 76 ++++++++++ frontend/src/features/telemetry/telemetry.ts | 62 ++++++++ .../features/telemetry/useTelemetryConsent.ts | 8 + frontend/src/locales/en.json | 8 + frontend/src/locales/fr.json | 8 + .../src/pages/ProjectsPage/ProjectsPage.tsx | 3 + .../ProjectsPage/components/PageHeader.tsx | 2 + .../components/TelemetryConsentCard.tsx | 63 ++++++++ .../src/shared/components/TelemetryToggle.tsx | 29 ++++ 14 files changed, 623 insertions(+), 3 deletions(-) create mode 100644 frontend/src/features/telemetry/consent.test.ts create mode 100644 frontend/src/features/telemetry/consent.ts create mode 100644 frontend/src/features/telemetry/telemetry.test.ts create mode 100644 frontend/src/features/telemetry/telemetry.ts create mode 100644 frontend/src/features/telemetry/useTelemetryConsent.ts create mode 100644 frontend/src/pages/ProjectsPage/components/TelemetryConsentCard.tsx create mode 100644 frontend/src/shared/components/TelemetryToggle.tsx diff --git a/README.md b/README.md index 0ec1bdc..faa2e03 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,28 @@ TOROLLO_ALLOWED_ORIGINS=http://:23232 --- +## Telemetry + +Torollo can send **anonymous, opt-in** usage events so we can see where the roadmaps lose people. **Nothing is ever sent unless you explicitly enable it** — the app asks once on the home screen, and until you answer (or if you decline) it makes zero telemetry requests. + +If you opt in, exactly five events are sent, and nothing else: + +| Event | Sent when | +|---|---| +| `roadmap_started` | you open a roadmap with no saved progress | +| `step_validated` | a step's validators all pass | +| `step_failed` | a validation runs and the step doesn't pass (engine errors are not counted) | +| `roadmap_completed` | the last remaining step of a roadmap passes | +| `roadmap_abandoned` | you leave a roadmap before finishing it | + +Each event carries only: the roadmap id and step id from the catalogue, the app version, and a random install id generated locally (never derived from your machine). No personal data, no project names, no container contents, no code. You can inspect every payload in your browser's network tab. + +**Turning it off (or on) later:** click the activity icon in the home-screen header, or clear the `torollo_telemetry_consent` key from the browser's localStorage. Revoking consent also deletes the install id, so re-enabling later starts a fresh anonymous identity. + +Forks and self-hosters can point events at their own [Plausible](https://plausible.io)-compatible endpoint (or disable telemetry entirely) at build time with `VITE_TELEMETRY_ENDPOINT` and `VITE_TELEMETRY_DOMAIN` (an empty `VITE_TELEMETRY_ENDPOINT` hard-disables it). + +--- + ## Architecture * **Backend** — Node.js, Express, TypeScript, Socket.IO, Dockerode. The backend is the supervisor: it drives the local Docker daemon, compiles your visual topology into real `iptables` rules applied inside the containers, and persists state in `~/.torollo/projects.json`. Every node image must ship with `iptables` and `iproute2` — see [Required tooling inside every node image](docs/adding-a-node.md#required-tooling-inside-every-node-image). diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts index 9c1b046..dc84370 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.test.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.test.ts @@ -1,12 +1,16 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import { useLearningPlayer } from './useLearningPlayer'; +import { trackEvent } from '../../telemetry/telemetry'; import type { Roadmap, RoadmapProgressResponse, StepValidationResponse, } from '../../../shared/types/roadmap'; +vi.mock('../../telemetry/telemetry', () => ({ trackEvent: vi.fn() })); +const trackEventMock = vi.mocked(trackEvent); + function jsonResponse(ok: boolean, body: unknown): Response { return { ok, json: () => Promise.resolve(body) } as Response; } @@ -72,6 +76,7 @@ describe('useLearningPlayer', () => { fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + trackEventMock.mockClear(); }); afterEach(() => { @@ -456,4 +461,136 @@ describe('useLearningPlayer', () => { expect(result.current.currentStepIndex).toBe(0); }); }); + + describe('telemetry events', () => { + const doneStep = { passed: true, attempts: 1, revealedHints: 0 }; + const halfDoneProgress: RoadmapProgressResponse = { + projectId: 'p1', + roadmapId: roadmap.id, + steps: { 'create-web-server': doneStep }, + }; + const allDoneProgress: RoadmapProgressResponse = { + projectId: 'p1', + roadmapId: roadmap.id, + steps: { 'create-web-server': doneStep, 'add-database': doneStep }, + }; + const failResponse: StepValidationResponse = { + roadmapId: roadmap.id, + stepId: 'create-web-server', + stepPassed: false, + results: [{ index: 0, type: 'container_running', status: 'fail', message: 'Not running.' }], + checkedAt: '2026-07-15T10:00:00.000Z', + }; + const errorResponse: StepValidationResponse = { + roadmapId: roadmap.id, + stepId: 'create-web-server', + stepPassed: false, + results: [{ index: 0, type: 'container_running', status: 'error', message: 'Docker down.' }], + checkedAt: '2026-07-15T10:00:00.000Z', + }; + + it('fires roadmap_started when the persisted progress is empty', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + expect(trackEventMock).toHaveBeenCalledWith('roadmap_started', { roadmap: roadmap.id }); + }); + + it('does not fire roadmap_started on a resumed run or an unreachable progress store', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, halfDoneProgress); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, roadmap)); + fetchMock.mockRejectedValueOnce(new Error('network down')); + await act(async () => { + await result.current.openRoadmap({ id: roadmap.id, language: 'en' }); + }); + + expect(trackEventMock).not.toHaveBeenCalledWith('roadmap_started', expect.anything()); + }); + + it('fires step_validated on a pass and step_failed on a fail, never on an engine error', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, failResponse)); + await act(async () => { + await result.current.validateCurrentStep(); + }); + expect(trackEventMock).toHaveBeenCalledWith('step_failed', { + roadmap: roadmap.id, + step: 'create-web-server', + }); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, errorResponse)); + await act(async () => { + await result.current.validateCurrentStep(); + }); + expect(trackEventMock).not.toHaveBeenCalledWith('step_validated', expect.anything()); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, passResponse)); + await act(async () => { + await result.current.validateCurrentStep(); + }); + expect(trackEventMock).toHaveBeenCalledWith('step_validated', { + roadmap: roadmap.id, + step: 'create-web-server', + }); + }); + + it('fires roadmap_completed once, on the validation that completes the roadmap', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, halfDoneProgress); + expect(result.current.currentStep?.id).toBe('add-database'); + + fetchMock.mockResolvedValueOnce(jsonResponse(true, { ...passResponse, stepId: 'add-database' })); + await act(async () => { + await result.current.validateCurrentStep(); + }); + expect(trackEventMock).toHaveBeenCalledWith('roadmap_completed', { roadmap: roadmap.id }); + + // Re-validating a step of the already complete roadmap must not recount. + fetchMock.mockResolvedValueOnce(jsonResponse(true, { ...passResponse, stepId: 'add-database' })); + await act(async () => { + await result.current.validateCurrentStep(); + }); + const completions = trackEventMock.mock.calls.filter(([name]) => name === 'roadmap_completed'); + expect(completions).toHaveLength(1); + }); + + it('fires roadmap_abandoned with keepalive when an unfinished roadmap is closed', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + act(() => result.current.closeRoadmap()); + + expect(trackEventMock).toHaveBeenCalledWith( + 'roadmap_abandoned', + { roadmap: roadmap.id, step: 'create-web-server' }, + { keepalive: true } + ); + }); + + it('does not fire roadmap_abandoned when the roadmap is complete', async () => { + const { result } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock, allDoneProgress); + + act(() => result.current.closeRoadmap()); + + expect(trackEventMock).not.toHaveBeenCalledWith( + 'roadmap_abandoned', + expect.anything(), + expect.anything() + ); + }); + + it('fires roadmap_abandoned exactly once on unmount with the player open', async () => { + const { result, unmount } = renderHook(() => useLearningPlayer({ projectId: 'p1' })); + await openExampleRoadmap(result, fetchMock); + + unmount(); + + const abandons = trackEventMock.mock.calls.filter(([name]) => name === 'roadmap_abandoned'); + expect(abandons).toHaveLength(1); + }); + }); }); diff --git a/frontend/src/features/learning/hooks/useLearningPlayer.ts b/frontend/src/features/learning/hooks/useLearningPlayer.ts index 137d078..619df40 100644 --- a/frontend/src/features/learning/hooks/useLearningPlayer.ts +++ b/frontend/src/features/learning/hooks/useLearningPlayer.ts @@ -1,6 +1,8 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import { API_BASE } from '../../../shared/types'; import { readErrorMessage } from '../../../shared/utils/readErrorMessage'; +import { trackEvent } from '../../telemetry/telemetry'; +import { stepOutcome } from '../validationStatus'; import type { Roadmap, RoadmapProgressResponse, @@ -57,9 +59,51 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { // Two quick openRoadmap clicks race on fetch resolution order; only the // latest request is allowed to commit its result. const openSeqRef = useRef(0); + // Telemetry: the open play-through, read by the abandon paths (close, + // unmount, pagehide) — a ref so those handlers see the latest state. + const telemetrySessionRef = useRef<{ roadmapId: string; stepId: string; complete: boolean } | null>( + null + ); const currentStep: RoadmapStep | null = roadmap?.steps[currentStepIndex] ?? null; + useEffect(() => { + if (!roadmap || !currentStep) { + telemetrySessionRef.current = null; + return; + } + telemetrySessionRef.current = { + roadmapId: roadmap.id, + stepId: currentStep.id, + complete: roadmap.steps.every( + step => completedStepIds[step.id] || resultsByStepId[step.id]?.stepPassed + ), + }; + }, [roadmap, currentStep, completedStepIds, resultsByStepId]); + + // Leaving an unfinished roadmap is the abandon event, whatever the exit: + // closing the player, unmounting it, or closing/refreshing the whole tab. + // Nulling the ref makes the event once-per-play-through — close followed by + // unmount must not double-count. + const fireAbandon = useCallback(() => { + const session = telemetrySessionRef.current; + if (!session || session.complete) return; + telemetrySessionRef.current = null; + trackEvent( + 'roadmap_abandoned', + { roadmap: session.roadmapId, step: session.stepId }, + { keepalive: true } + ); + }, []); + + useEffect(() => { + window.addEventListener('pagehide', fireAbandon); + return () => { + window.removeEventListener('pagehide', fireAbandon); + fireAbandon(); + }; + }, [fireAbandon]); + const progressUrl = useCallback( (roadmapId: string) => `${API_BASE}/api/learning/progress/${encodeURIComponent(projectId)}/${encodeURIComponent(roadmapId)}`, @@ -92,12 +136,14 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { // Progress is a convenience: if it can't be read, open fresh anyway. let progressSteps: Record = {}; let recovered = false; + let progressLoaded = false; try { const progressRes = await fetch(progressUrl(data.id)); if (progressRes.ok) { const progress: RoadmapProgressResponse = await progressRes.json(); progressSteps = progress.steps ?? {}; recovered = progress.storeRecovered === true; + progressLoaded = true; } else { console.error('Failed to load roadmap progress: HTTP', progressRes.status); } @@ -129,6 +175,12 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setProgressNotice(recovered); setValidationError(null); setResetError(null); + + // A confirmed-empty progress store is what "started" means; a failed + // progress fetch could be a resumed run, so it never counts as one. + if (progressLoaded && Object.keys(progressSteps).length === 0) { + trackEvent('roadmap_started', { roadmap: data.id }); + } } catch (err) { console.error('Failed to load roadmap:', err); if (seq === openSeqRef.current) setRoadmapError(''); @@ -140,6 +192,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { ); const closeRoadmap = useCallback(() => { + fireAbandon(); setRoadmap(null); setRoadmapError(null); setCurrentStepIndex(0); @@ -149,7 +202,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { setProgressNotice(false); setValidationError(null); setResetError(null); - }, []); + }, [fireAbandon]); const goToStep = useCallback( (index: number) => { @@ -196,6 +249,28 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { } const response: StepValidationResponse = await res.json(); setResultsByStepId(prev => ({ ...prev, [currentStep.id]: response })); + + // An engine error is not a pedagogical failure — it is not counted. + const outcome = stepOutcome(response); + if (outcome !== 'error') { + trackEvent(outcome === 'passed' ? 'step_validated' : 'step_failed', { + roadmap: roadmap.id, + step: currentStep.id, + }); + } + + // Fresh completion only — this verdict turned the last missing step + // green. Re-validating a step of an already complete roadmap must not + // count the roadmap as completed again. + const stepDone = (step: RoadmapStep) => + completedStepIds[step.id] || resultsByStepId[step.id]?.stepPassed; + const wasComplete = roadmap.steps.every(stepDone); + const nowComplete = + response.stepPassed && + roadmap.steps.every(step => step.id === currentStep.id || stepDone(step)); + if (nowComplete && !wasComplete) { + trackEvent('roadmap_completed', { roadmap: roadmap.id }); + } } catch (err) { console.error('Failed to validate step:', err); setValidationError(''); @@ -203,7 +278,7 @@ export function useLearningPlayer({ projectId }: UseLearningPlayerOptions) { validatingRef.current = false; setValidating(false); } - }, [projectId, roadmap, currentStep]); + }, [projectId, roadmap, currentStep, completedStepIds, resultsByStepId]); /** Forgets this roadmap's persisted progress and restarts it from step 1. */ const resetProgress = useCallback(async () => { diff --git a/frontend/src/features/telemetry/consent.test.ts b/frontend/src/features/telemetry/consent.test.ts new file mode 100644 index 0000000..1ff3d96 --- /dev/null +++ b/frontend/src/features/telemetry/consent.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + getInstallId, + getTelemetryConsent, + setTelemetryConsent, + subscribeTelemetryConsent, +} from './consent'; + +describe('telemetry consent', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('defaults to unset when nothing is stored', () => { + expect(getTelemetryConsent()).toBe('unset'); + }); + + it('round-trips accepted and declined', () => { + setTelemetryConsent('accepted'); + expect(getTelemetryConsent()).toBe('accepted'); + setTelemetryConsent('declined'); + expect(getTelemetryConsent()).toBe('declined'); + }); + + it('treats an unknown stored value as unset', () => { + localStorage.setItem('torollo_telemetry_consent', 'yes-please'); + expect(getTelemetryConsent()).toBe('unset'); + }); + + it('falls back to unset when storage throws', () => { + const spy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('storage disabled'); + }); + expect(getTelemetryConsent()).toBe('unset'); + spy.mockRestore(); + }); + + it('notifies subscribers on change and stops after unsubscribe', () => { + const listener = vi.fn(); + const unsubscribe = subscribeTelemetryConsent(listener); + setTelemetryConsent('accepted'); + expect(listener).toHaveBeenCalledTimes(1); + unsubscribe(); + setTelemetryConsent('declined'); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('keeps the install id stable across calls', () => { + const first = getInstallId(); + expect(first).toBeTruthy(); + expect(getInstallId()).toBe(first); + }); + + it('forgets the install id when consent is revoked', () => { + setTelemetryConsent('accepted'); + const first = getInstallId(); + setTelemetryConsent('declined'); + expect(localStorage.getItem('torollo_telemetry_install_id')).toBeNull(); + setTelemetryConsent('accepted'); + expect(getInstallId()).not.toBe(first); + }); +}); diff --git a/frontend/src/features/telemetry/consent.ts b/frontend/src/features/telemetry/consent.ts new file mode 100644 index 0000000..f4fc4f7 --- /dev/null +++ b/frontend/src/features/telemetry/consent.ts @@ -0,0 +1,65 @@ +const CONSENT_KEY = 'torollo_telemetry_consent'; +const INSTALL_ID_KEY = 'torollo_telemetry_install_id'; + +/** + * Tri-state on purpose: `unset` (never asked or storage unavailable) must + * behave exactly like `declined` — zero network — while still telling the UI + * that the consent prompt has not been answered yet. + */ +export type TelemetryConsent = 'accepted' | 'declined' | 'unset'; + +const listeners = new Set<() => void>(); + +export function getTelemetryConsent(): TelemetryConsent { + try { + const stored = localStorage.getItem(CONSENT_KEY); + return stored === 'accepted' || stored === 'declined' ? stored : 'unset'; + } catch { + // Storage disabled (private mode, hardened profile): treat as never + // asked, which the sender treats as declined. + return 'unset'; + } +} + +export function setTelemetryConsent(value: 'accepted' | 'declined'): void { + try { + localStorage.setItem(CONSENT_KEY, value); + // Revoking consent also forgets the install id: re-enabling later starts + // a fresh anonymous identity instead of relinking to the old one. + if (value === 'declined') localStorage.removeItem(INSTALL_ID_KEY); + } catch { + // Nothing to persist to — the runtime default (unset → no events) holds. + } + listeners.forEach(listener => listener()); +} + +/** Subscription for useSyncExternalStore, so every consent UI stays in sync. */ +export function subscribeTelemetryConsent(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +/** + * Random, locally-generated install id. Created lazily on first use — which + * only ever happens after consent — so a declined install carries no id at + * all. Never derived from anything about the machine or the user. + */ +export function getInstallId(): string { + try { + const existing = localStorage.getItem(INSTALL_ID_KEY); + if (existing) return existing; + const id = generateId(); + localStorage.setItem(INSTALL_ID_KEY, id); + return id; + } catch { + // No storage means a new id per event; anonymity is preserved either way. + return generateId(); + } +} + +function generateId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`; +} diff --git a/frontend/src/features/telemetry/telemetry.test.ts b/frontend/src/features/telemetry/telemetry.test.ts new file mode 100644 index 0000000..2c54dca --- /dev/null +++ b/frontend/src/features/telemetry/telemetry.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { trackEvent } from './telemetry'; +import { setTelemetryConsent } from './consent'; + +describe('trackEvent', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + localStorage.clear(); + fetchMock = vi.fn().mockResolvedValue({ ok: true }); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('performs zero network requests while consent is unset', () => { + trackEvent('roadmap_started', { roadmap: 'r1' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('performs zero network requests when consent is declined', () => { + setTelemetryConsent('declined'); + trackEvent('step_validated', { roadmap: 'r1', step: 's1' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('sends one Plausible-shaped event when consent is accepted', () => { + setTelemetryConsent('accepted'); + trackEvent('step_failed', { roadmap: 'r1', step: 's2' }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0]; + expect(init.method).toBe('POST'); + expect(init.keepalive).toBe(false); + const payload = JSON.parse(init.body); + expect(payload.name).toBe('step_failed'); + expect(payload.domain).toBeTruthy(); + expect(payload.url).toBe('app://torollo/roadmap/r1'); + expect(payload.props.roadmap).toBe('r1'); + expect(payload.props.step).toBe('s2'); + expect(payload.props.install_id).toBeTruthy(); + expect(payload.props.app_version).toBeTruthy(); + // The exhaustive prop list — anything beyond it would be an undocumented + // (and potentially identifying) leak. + expect(Object.keys(payload.props).sort()).toEqual([ + 'app_version', + 'install_id', + 'roadmap', + 'step', + ]); + }); + + it('reuses the same install id across events', () => { + setTelemetryConsent('accepted'); + trackEvent('roadmap_started', { roadmap: 'r1' }); + trackEvent('roadmap_completed', { roadmap: 'r1' }); + const ids = fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body).props.install_id); + expect(ids[0]).toBe(ids[1]); + }); + + it('passes keepalive through for exit events', () => { + setTelemetryConsent('accepted'); + trackEvent('roadmap_abandoned', { roadmap: 'r1', step: 's3' }, { keepalive: true }); + expect(fetchMock.mock.calls[0][1].keepalive).toBe(true); + }); + + it('never throws when the network call fails', async () => { + setTelemetryConsent('accepted'); + fetchMock.mockRejectedValue(new Error('offline')); + expect(() => trackEvent('roadmap_started', { roadmap: 'r1' })).not.toThrow(); + // Let the rejected promise settle — the .catch inside must absorb it. + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}); diff --git a/frontend/src/features/telemetry/telemetry.ts b/frontend/src/features/telemetry/telemetry.ts new file mode 100644 index 0000000..fe2f41e --- /dev/null +++ b/frontend/src/features/telemetry/telemetry.ts @@ -0,0 +1,62 @@ +import { getInstallId, getTelemetryConsent } from './consent'; + +/** + * The five learning-funnel events — the exhaustive list, mirrored verbatim in + * the README's Telemetry section. Adding an event here means updating the + * README in the same change. + */ +export type TelemetryEventName = + | 'roadmap_started' + | 'step_validated' + | 'step_failed' + | 'roadmap_completed' + | 'roadmap_abandoned'; + +export interface TelemetryEventProps { + /** Roadmap id from the public catalogue — never a user-chosen name. */ + roadmap: string; + /** Step id within the roadmap file, for the funnel/abandon breakdowns. */ + step?: string; +} + +// Overridable at build time so self-hosters and forks can point events at +// their own instance — or build with an empty endpoint to hard-disable. +const ENDPOINT: string = + (import.meta.env.VITE_TELEMETRY_ENDPOINT as string | undefined) ?? 'https://plausible.io/api/event'; +const DOMAIN: string = + (import.meta.env.VITE_TELEMETRY_DOMAIN as string | undefined) ?? 'torollo.app'; + +declare const __APP_VERSION__: string; + +/** + * Sends one event as a Plausible custom event (Umami and self-hosted + * Plausible accept the same shape). Hard rules, enforced by tests: + * without an explicit `accepted` consent this performs ZERO network requests, + * and the payload never carries PII — only catalogue ids, the app version and + * a random local install id. + * + * Fire-and-forget: telemetry must never throw, block, or surface an error. + */ +export function trackEvent( + name: TelemetryEventName, + props: TelemetryEventProps, + options: { keepalive?: boolean } = {} +): void { + if (getTelemetryConsent() !== 'accepted' || !ENDPOINT) return; + try { + void fetch(ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + url: `app://torollo/roadmap/${props.roadmap}`, + domain: DOMAIN, + props: { ...props, install_id: getInstallId(), app_version: __APP_VERSION__ }, + }), + // keepalive lets the abandon event survive the page being closed. + keepalive: options.keepalive === true, + }).catch(() => {}); + } catch { + // A broken fetch environment must never take the app down with it. + } +} diff --git a/frontend/src/features/telemetry/useTelemetryConsent.ts b/frontend/src/features/telemetry/useTelemetryConsent.ts new file mode 100644 index 0000000..b56494f --- /dev/null +++ b/frontend/src/features/telemetry/useTelemetryConsent.ts @@ -0,0 +1,8 @@ +import { useSyncExternalStore } from 'react'; +import { getTelemetryConsent, subscribeTelemetryConsent } from './consent'; +import type { TelemetryConsent } from './consent'; + +/** Reactive view of the consent value — card and toggle stay in sync. */ +export function useTelemetryConsent(): TelemetryConsent { + return useSyncExternalStore(subscribeTelemetryConsent, getTelemetryConsent); +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 8865a2d..9b8a9dd 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -14,6 +14,14 @@ "light": "Light", "dark": "Dark" }, + "telemetry": { + "title": "Anonymous usage stats", + "body": "Opt in to send five anonymous learning events (roadmap started, step validated, step failed, roadmap completed, roadmap abandoned) so we can see where roadmaps lose people. No personal data, no project names, no code — details in the README. Nothing is sent unless you enable it.", + "accept": "Enable", + "decline": "No thanks", + "toggleOn": "Anonymous usage stats: on — click to disable", + "toggleOff": "Anonymous usage stats: off — click to enable" + }, "learning": { "panelTitle": "Learning", "close": "Close panel", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index a74673a..2c65248 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -14,6 +14,14 @@ "light": "Clair", "dark": "Sombre" }, + "telemetry": { + "title": "Statistiques d'usage anonymes", + "body": "Activez l'envoi de cinq événements d'apprentissage anonymes (roadmap démarrée, étape validée, étape échouée, roadmap complétée, roadmap abandonnée) pour nous aider à voir où les roadmaps perdent les gens. Aucune donnée personnelle, aucun nom de projet, aucun code — détails dans le README. Rien n'est envoyé sans votre accord.", + "accept": "Activer", + "decline": "Non merci", + "toggleOn": "Statistiques d'usage anonymes : activées — cliquer pour désactiver", + "toggleOff": "Statistiques d'usage anonymes : désactivées — cliquer pour activer" + }, "learning": { "panelTitle": "Apprentissage", "close": "Fermer le panneau", diff --git a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx index 1a73b64..e0b1c22 100644 --- a/frontend/src/pages/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/ProjectsPage/ProjectsPage.tsx @@ -9,6 +9,7 @@ import ProjectsSection from './components/ProjectsSection'; import type { HomeView } from './components/SideRail'; import ProjectPickerModal from './components/ProjectPickerModal'; import LearningSection from './components/learning/LearningSection'; +import TelemetryConsentCard from './components/TelemetryConsentCard'; import RoadmapDetailPage from './components/learning/detail/RoadmapDetailPage'; import { filterByUiLanguage } from '../../features/learning/roadmapLanguage'; import { markLearningPitchSeen } from '../../features/learning/onboarding'; @@ -215,6 +216,8 @@ export default function ProjectsPage({ onSelectProject }: ProjectsPageProps) { {route.kind === 'projects' ? ( <> + + {storeRecovered && (
)}
+ + +
+
+ ); +} + +const styles: Record = { + card: { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-6)', + flexWrap: 'wrap', + padding: 'var(--space-4) var(--space-5)', + background: 'var(--bg-surface-solid)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-lg)', + }, + copy: { + flex: '1 1 380px', + display: 'flex', + flexDirection: 'column', + gap: 'var(--space-1)', + }, + title: { + fontSize: 'var(--text-md)', + fontWeight: 600, + color: 'var(--color-text-primary)', + }, + body: { + fontSize: 'var(--text-sm)', + color: 'var(--color-text-secondary)', + lineHeight: 1.5, + margin: 0, + }, + actions: { + display: 'flex', + gap: 'var(--space-2)', + flexWrap: 'wrap', + }, +}; diff --git a/frontend/src/shared/components/TelemetryToggle.tsx b/frontend/src/shared/components/TelemetryToggle.tsx new file mode 100644 index 0000000..8f6646c --- /dev/null +++ b/frontend/src/shared/components/TelemetryToggle.tsx @@ -0,0 +1,29 @@ +import { Activity } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import Button from './Button'; +import { setTelemetryConsent } from '../../features/telemetry/consent'; +import { useTelemetryConsent } from '../../features/telemetry/useTelemetryConsent'; + +/** + * The revocation path the README points at: telemetry stays reversible after + * the one-time consent card is gone. `unset` toggles to accepted — it already + * behaves as declined on the wire. + */ +export default function TelemetryToggle() { + const { t } = useTranslation(); + const consent = useTelemetryConsent(); + const enabled = consent === 'accepted'; + const label = enabled ? t('telemetry.toggleOn') : t('telemetry.toggleOff'); + + return ( + + ); +}