From a58888f0e6a247ecc0a59c3e27159827d90d099f Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 11 Aug 2026 21:42:51 -0400 Subject: [PATCH 1/5] feat(analytics): integrate PostHog for event tracking and user consent management - Added PostHog SDK to package.json for analytics tracking. - Implemented analytics consent management with separate toggles for analytics and crash reporting. - Created analytics module to handle initialization, event tracking, and user identification. - Integrated analytics tracking into various components including onboarding, feedback prompts, and chore creation. - Added PrivacyAnalyticsSettings view for managing user consent preferences. - Enhanced feedback submission with analytics tracking for user interactions. - Updated device information utility for better context in analytics events. - Refactored existing code to utilize new analytics functions and ensure proper event sanitization. --- package-lock.json | 121 +++++++++- package.json | 1 + src/App.jsx | 7 + src/analytics/consent.js | 107 +++++++++ src/analytics/eventSchemas.js | 139 ++++++++++++ src/analytics/index.js | 211 ++++++++++++++++++ src/analytics/posthogClient.js | 35 +++ src/analytics/useAnalyticsIdentity.js | 28 +++ src/contexts/RouterContext.jsx | 5 + src/queries/ChoreQueries.jsx | 15 +- src/service/DiagnosticsSession.js | 2 +- src/service/FeedbackService.js | 44 +--- src/utils/ApiClient.js | 12 + src/utils/DeviceInfo.js | 27 +++ src/utils/Onboarding.js | 32 ++- src/views/Modals/FeedbackModal.jsx | 44 +++- src/views/Onboarding/HeardAboutView.jsx | 9 +- src/views/Onboarding/OnboardingView.jsx | 8 +- src/views/Onboarding/WorkspaceReadyView.jsx | 3 + .../Settings/PrivacyAnalyticsSettings.jsx | 86 +++++++ src/views/Settings/SettingsOverview.jsx | 7 + src/views/components/FeedbackPrompt.jsx | 9 +- 22 files changed, 874 insertions(+), 78 deletions(-) create mode 100644 src/analytics/consent.js create mode 100644 src/analytics/eventSchemas.js create mode 100644 src/analytics/index.js create mode 100644 src/analytics/posthogClient.js create mode 100644 src/analytics/useAnalyticsIdentity.js create mode 100644 src/utils/DeviceInfo.js create mode 100644 src/views/Settings/PrivacyAnalyticsSettings.jsx diff --git a/package-lock.json b/package-lock.json index c50f492..4e3888f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "js-cookie": "^3.0.5", "moment": "^2.30.1", "murmurhash": "^2.0.1", + "posthog-js": "^1.415.5", "prop-types": "^15.8.1", "qrcode": "^1.5.4", "quill": "^2.0.3", @@ -2322,8 +2323,6 @@ }, "node_modules/@emotion/cache": { "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz", - "integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0", @@ -4586,6 +4585,31 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@posthog/browser-common": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz", + "integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "^1.47.0", + "@posthog/types": "^1.402.2" + } + }, + "node_modules/@posthog/core": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz", + "integrity": "sha512-LW62V+9yx7G7mLd+EYpwW0PiaPZI8XRJ1tLuhw+9fXHpugLp8XQD5JuCQ2kIXvoUfyx1DpKfGQY3dUnzMOIn7Q==", + "license": "MIT", + "dependencies": { + "@posthog/types": "^1.402.2" + } + }, + "node_modules/@posthog/types": { + "version": "1.402.3", + "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz", + "integrity": "sha512-nnqKIGUqggeNbCZg6of/hYN+4shYZUoPFkILIIpYq0p9HDX8PgOrnrtBAUH8kr1MOmDh2nm+fY5Ceks7A5y6BQ==", + "license": "MIT" + }, "node_modules/@prettier/plugin-xml": { "version": "2.2.0", "dev": true, @@ -7391,6 +7415,20 @@ "version": "1.9.0", "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.45.1", "license": "MIT", @@ -7932,6 +7970,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "2.8.0", "dev": true, @@ -12018,6 +12065,48 @@ "dev": true, "license": "MIT" }, + "node_modules/posthog-js": { + "version": "1.415.5", + "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.415.5.tgz", + "integrity": "sha512-IUmPFtZjwDZYnRmttl+Xo5f/zDVdMdfWdZFxHqtAqq4wMwElr4dIpXmEUzh4nO8NtWT3zBmrL9+c7zZ3avzJZw==", + "license": "(Apache-2.0 AND MIT)", + "dependencies": { + "@posthog/browser-common": "^0.5.0", + "@posthog/core": "^1.47.0", + "@posthog/types": "^1.402.3", + "core-js": "^3.49.0", + "dompurify": "^3.4.13", + "fflate": "^0.4.8", + "preact": "^10.29.3", + "query-selector-shadow-dom": "^1.0.1", + "web-vitals": "^5.3.0", + "web-vitals-soft-navs": "npm:web-vitals@6.0.0" + } + }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.9", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz", + "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==", + "license": "MIT" + }, + "node_modules/preact": { + "version": "10.29.8", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz", + "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + }, + "peerDependencies": { + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, "node_modules/prebuild-install": { "version": "7.1.3", "devOptional": true, @@ -12402,6 +12491,12 @@ ], "license": "MIT" }, + "node_modules/query-selector-shadow-dom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz", + "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "dev": true, @@ -12466,15 +12561,6 @@ "regenerator-runtime": "^0.13.9" } }, - "node_modules/quilljs-markdown/node_modules/core-js": { - "version": "3.45.1", - "hasInstallScript": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, "node_modules/raf-schd": { "version": "4.0.3", "license": "MIT" @@ -15262,6 +15348,19 @@ "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", "license": "Apache-2.0" }, + "node_modules/web-vitals": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz", + "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==", + "license": "Apache-2.0" + }, + "node_modules/web-vitals-soft-navs": { + "name": "web-vitals", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz", + "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "license": "BSD-2-Clause" diff --git a/package.json b/package.json index 28d443c..7a79373 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "js-cookie": "^3.0.5", "moment": "^2.30.1", "murmurhash": "^2.0.1", + "posthog-js": "^1.415.5", "prop-types": "^15.8.1", "qrcode": "^1.5.4", "quill": "^2.0.3", diff --git a/src/App.jsx b/src/App.jsx index b1a275e..dc1ada6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -7,6 +7,8 @@ import { useRegisterSW } from 'virtual:pwa-register/react' import NavBar from '@/views/components/NavBar' +import { initialize as initializeAnalytics } from './analytics' +import useAnalyticsIdentity from './analytics/useAnalyticsIdentity' import { registerCapacitorListeners } from './CapacitorListener' import PageTransition from './components/animations/PageTransition' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' @@ -36,6 +38,7 @@ const AppContent = () => { const { showNotification } = useNotification() const location = useLocation() useSyncOnReconnect() + useAnalyticsIdentity() // Every route renders through this Outlet, so one listener here gives crash // reports the trail that led to the failure. @@ -142,6 +145,10 @@ function App() { registerCapacitorListeners(navigate) }, [navigate]) + useEffect(() => { + initializeAnalytics() + }, []) + return (
diff --git a/src/analytics/consent.js b/src/analytics/consent.js new file mode 100644 index 0000000..b9862f0 --- /dev/null +++ b/src/analytics/consent.js @@ -0,0 +1,107 @@ +import { Preferences } from '@capacitor/preferences' + +// Two independent consent axes, matching the existing onboarding UI +// (HeardAboutView's PrivacyPreferences): "analytics" gates track(), "crash" +// gates captureError(). A self-hosted user can opt into one without the other. +const CONSENT_KEYS = { + analytics: 'analytics_consent', + crash: 'analytics_crash_consent', +} + +const ANON_ID_KEY = 'analytics_anon_id' +const INSTALLATION_ID_KEY = 'analytics_installation_id' + +const generateUUID = () => { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID() + } + // Fallback for webviews without crypto.randomUUID — not cryptographically + // strong, but this identifier carries no user information either way. + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0 + const v = c === 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} + +const readPreference = async key => { + try { + const { value } = await Preferences.get({ key }) + return value ?? null + } catch { + return null + } +} + +const writePreference = async (key, value) => { + try { + await Preferences.set({ key, value }) + } catch { + // best-effort; consent falls back to 'unknown' on next read + } +} + +const removePreference = async key => { + try { + await Preferences.remove({ key }) + } catch { + // ignore + } +} + +export const getStoredConsent = async kind => { + const value = await readPreference(CONSENT_KEYS[kind]) + return value === 'enabled' || value === 'disabled' ? value : 'unknown' +} + +export const setStoredConsent = (kind, value) => + writePreference(CONSENT_KEYS[kind], value) + +/** + * Self-hosted `unknown` behaves as disabled (opt-in required); cloud + * `unknown` behaves as enabled (opt-out available). Nothing is persisted by + * this resolution alone — only an explicit setConsent() call writes a value. + */ +export const resolveEffectiveConsent = (stored, deploymentType) => { + if (stored === 'enabled' || stored === 'disabled') return stored + return deploymentType === 'cloud' ? 'enabled' : 'disabled' +} + +export const getOrCreateAnonId = async () => { + const existing = await readPreference(ANON_ID_KEY) + if (existing) return existing + const created = generateUUID() + await writePreference(ANON_ID_KEY, created) + return created +} + +export const getOrCreateInstallationId = async () => { + const existing = await readPreference(INSTALLATION_ID_KEY) + if (existing) return existing + const created = generateUUID() + await writePreference(INSTALLATION_ID_KEY, created) + return created +} + +/** + * Cloud + known user -> identify by the Donetick user id. Self-hosted (or + * cloud pre-login) -> a random id containing no user information, never + * derived from email/username/database id. + */ +export const resolveIdentity = async ({ deploymentType, userId }) => { + if (deploymentType === 'cloud' && userId) { + return { distinctId: String(userId), installationId: null } + } + const [anonId, installationId] = await Promise.all([ + getOrCreateAnonId(), + deploymentType === 'cloud' ? null : getOrCreateInstallationId(), + ]) + return { distinctId: anonId, installationId } +} + +export const clearAnonymousIdentity = async () => { + await Promise.all([ + removePreference(ANON_ID_KEY), + removePreference(INSTALLATION_ID_KEY), + ]) +} diff --git a/src/analytics/eventSchemas.js b/src/analytics/eventSchemas.js new file mode 100644 index 0000000..8934d80 --- /dev/null +++ b/src/analytics/eventSchemas.js @@ -0,0 +1,139 @@ +// Every event has an explicit property allowlist. Unknown events are +// dropped entirely; unknown or mistyped properties are dropped individually. +// This is the mechanism (not just a convention) that keeps user-generated +// content and PII out of PostHog — see spec.md sections 2, 14, 22. + +// Attached to every event so cohort analysis (e.g. "do Plus accounts behave +// differently") works without every call site having to pass them. +const COMMON_PROPS = { + is_plus_account: 'boolean', + circle_member_count: 'number', +} + +const withCommon = props => ({ ...props, ...COMMON_PROPS }) + +export const EVENT_SCHEMAS = { + onboarding_started: withCommon({}), + onboarding_completed: withCommon({}), + onboarding_skipped: withCommon({}), + onboarding_option_selected: withCommon({ + option: 'string', + step: 'string', + }), + + chore_created: withCommon({ + has_due_date: 'boolean', + has_assignee: 'boolean', + has_labels: 'boolean', + has_description: 'boolean', + has_recurrence: 'boolean', + recurrence_type: 'string', + priority: 'number', + source: 'string', + }), + + analytics_enabled: withCommon({ + source: 'enum:onboarding,settings', + }), + + feedback_prompt_shown: withCommon({ + source: 'enum:auto,settings', + shown_count: 'number', + }), + feedback_prompt_dismissed: withCommon({ + source: 'enum:auto,settings', + shown_count: 'number', + }), + feedback_sentiment_selected: withCommon({ + sentiment: 'enum:love,okay,issues', + }), + feedback_review_action: withCommon({ + action: 'enum:github,appStore,playStore', + }), + feedback_submitted: withCommon({ + category: + 'enum:bugs,missingFeature,tooComplicated,slow,notifications,ai,other', + has_message: 'boolean', + result: 'enum:sent,failed,unconfigured,misconfigured,self-hosted', + }), +} + +export const ERROR_SCHEMAS = { + api_error: { + http_status: 'string', + method: 'string', + error_code: 'string', + operation: 'string', + }, +} + +const MAX_STRING_LENGTH = 200 +// Defense in depth: operation/endpoint-like strings must never carry a query +// string even though callers are expected to have already stripped one. +const containsQueryOrDisallowedChars = value => /[?&=]/.test(value) + +const isValidValue = (value, type) => { + if (value === null || value === undefined) return false + + if (type === 'boolean') return typeof value === 'boolean' + if (type === 'number') return typeof value === 'number' && !isNaN(value) + + if (type.startsWith('enum:')) { + const allowed = type.slice('enum:'.length).split(',') + return typeof value === 'string' && allowed.includes(value) + } + + if (type === 'string') { + return ( + typeof value === 'string' && + value.length <= MAX_STRING_LENGTH && + !containsQueryOrDisallowedChars(value) + ) + } + + return false +} + +/** + * Drops the whole event if its name isn't recognized, then drops any + * property that isn't in the schema or fails its type/enum check. Never + * throws — a malformed call site loses data, it never crashes the app. + */ +export const sanitizeProperties = (schemas, eventName, properties = {}) => { + const schema = schemas[eventName] + if (!schema) { + if (import.meta.env.DEV) { + console.warn(`analytics: unknown event "${eventName}", dropping`) + } + return null + } + + const sanitized = {} + for (const [key, value] of Object.entries(properties || {})) { + const type = schema[key] + if (!type) { + if (import.meta.env.DEV) { + console.warn( + `analytics: dropping unknown property "${key}" on "${eventName}"`, + ) + } + continue + } + if (!isValidValue(value, type)) { + if (import.meta.env.DEV) { + console.warn( + `analytics: dropping invalid property "${key}" on "${eventName}"`, + ) + } + continue + } + sanitized[key] = value + } + return sanitized +} + +export const sanitizeEventProperties = (eventName, properties) => + sanitizeProperties(EVENT_SCHEMAS, eventName, properties) + +export const sanitizeErrorProperties = (errorType, properties) => + sanitizeProperties(ERROR_SCHEMAS, errorType, properties) diff --git a/src/analytics/index.js b/src/analytics/index.js new file mode 100644 index 0000000..e2e3ea0 --- /dev/null +++ b/src/analytics/index.js @@ -0,0 +1,211 @@ +import { getAppVersion, getDeviceContext } from '../utils/DeviceInfo' +import { isOfficialDonetickInstance } from '../utils/FeatureToggle' +import { + clearAnonymousIdentity, + getStoredConsent, + resolveEffectiveConsent, + resolveIdentity, + setStoredConsent, +} from './consent' +import { + sanitizeErrorProperties, + sanitizeEventProperties, +} from './eventSchemas' +import { getClient, getClientSync, isConfigured } from './posthogClient' + +const state = { + initialized: false, + initializing: null, + deploymentType: null, // 'cloud' | 'self_hosted' + consent: { analytics: 'unknown', crash: 'unknown' }, + distinctId: null, + common: { is_plus_account: false, circle_member_count: 0 }, +} + +const resolveDeploymentType = async () => { + const isCloud = await isOfficialDonetickInstance().catch(() => false) + return isCloud ? 'cloud' : 'self_hosted' +} + +const attachBaseProperties = async posthog => { + const [appVersion, device] = await Promise.all([ + getAppVersion(), + getDeviceContext(), + ]) + posthog.register({ + deployment_type: state.deploymentType, + app_version: appVersion, + platform: + typeof window !== 'undefined' && window.Capacitor + ? window.Capacitor.getPlatform() + : 'web', + os: device.osVersion, + }) +} + +const startPosthog = async () => { + const posthog = await getClient() + if (!posthog) return null + + const { distinctId } = await resolveIdentity({ + deploymentType: state.deploymentType, + userId: null, + }) + state.distinctId = distinctId + posthog.identify(distinctId) + posthog.opt_in_capturing() + await attachBaseProperties(posthog) + return posthog +} + +/** + * Reads stored consent, resolves cloud/self-hosted, and starts PostHog only + * if the *effective* analytics consent allows it. Safe to call multiple + * times; only does real work once. + */ +export const initialize = async () => { + if (state.initializing) return state.initializing + state.initializing = (async () => { + state.deploymentType = await resolveDeploymentType() + + const [storedAnalytics, storedCrash] = await Promise.all([ + getStoredConsent('analytics'), + getStoredConsent('crash'), + ]) + state.consent.analytics = storedAnalytics + state.consent.crash = storedCrash + + const effectiveAnalytics = resolveEffectiveConsent( + storedAnalytics, + state.deploymentType, + ) + + if (isConfigured() && effectiveAnalytics === 'enabled') { + await startPosthog() + } + + state.initialized = true + })() + return state.initializing +} + +/** Cloud only meaningfully identifies with the real user id; self-hosted + * never sends anything derived from user identity. */ +export const identify = async userId => { + if (!state.initialized) await initialize() + if (state.deploymentType !== 'cloud' || !userId) return + + const posthog = getClientSync() + if (!posthog) return + + state.distinctId = String(userId) + posthog.identify(state.distinctId) +} + +/** Kept fresh from the app's own data layer (react-query), not re-fetched by + * this module — see useAnalyticsIdentity. Refreshed as super-properties so + * every subsequent event carries the latest cohort values without needing to + * be passed at every call site. */ +export const updateCommonProperties = ({ + circle_member_count, + is_plus_account, +} = {}) => { + if (typeof is_plus_account === 'boolean') { + state.common.is_plus_account = is_plus_account + } + if (typeof circle_member_count === 'number') { + state.common.circle_member_count = circle_member_count + } + + const posthog = getClientSync() + if (!posthog) return + posthog.register({ ...state.common }) +} + +const canSend = kind => + state.initialized && + isConfigured() && + resolveEffectiveConsent(state.consent[kind], state.deploymentType) === + 'enabled' + +export const track = (eventName, properties = {}) => { + if (!canSend('analytics')) return + const posthog = getClientSync() + if (!posthog) return + + const sanitized = sanitizeEventProperties(eventName, { + ...state.common, + ...properties, + }) + if (!sanitized) return + + posthog.capture(eventName, sanitized) +} + +export const captureError = (errorType, properties = {}) => { + if (!canSend('crash')) return + const posthog = getClientSync() + if (!posthog) return + + const sanitized = sanitizeErrorProperties(errorType, properties) + if (!sanitized) return + + posthog.capture(errorType, sanitized) +} + +/** + * kind: 'analytics' | 'crash'. Enabling analytics (re-)initializes PostHog + * if needed and sends analytics_enabled; enabling crash-only never talks to + * PostHog by itself (it only unlocks captureError once something reports). + * Disabling never sends an event and clears identity/queued data. + */ +export const setConsent = async (kind, value, { source } = {}) => { + if (!state.initialized) await initialize() + + state.consent[kind] = value + await setStoredConsent(kind, value) + + if (value === 'disabled') { + const posthog = getClientSync() + if (posthog) { + posthog.opt_out_capturing() + posthog.reset() + } + if (kind === 'analytics' && state.consent.crash !== 'enabled') { + await clearAnonymousIdentity() + } + return + } + + // value === 'enabled' + if (kind === 'analytics') { + if (isConfigured()) { + await startPosthog() + track('analytics_enabled', { source: source || 'settings' }) + } + } else if (kind === 'crash') { + // Crash reporting alone doesn't need PostHog started with the analytics + // super-properties path, but it does need a live client + identity to + // send captureError() calls through. + if (isConfigured() && !getClientSync()) { + await startPosthog() + } + } +} + +export const getConsent = kind => + resolveEffectiveConsent(state.consent[kind], state.deploymentType) + +export const getRawConsent = kind => state.consent[kind] + +export const getDeploymentType = () => state.deploymentType + +export const shutdown = () => { + const posthog = getClientSync() + if (posthog) { + posthog.opt_out_capturing() + posthog.reset() + } + state.initialized = false + state.initializing = null +} diff --git a/src/analytics/posthogClient.js b/src/analytics/posthogClient.js new file mode 100644 index 0000000..38b265a --- /dev/null +++ b/src/analytics/posthogClient.js @@ -0,0 +1,35 @@ +// Isolates the posthog-js dependency so index.js never touches the SDK +// directly — keeps PostHog-specific config in one place and makes it +// possible to swap/mock the backend later without touching call sites. + +let posthog = null + +const KEY = import.meta.env.VITE_POSTHOG_KEY +const HOST = import.meta.env.VITE_POSTHOG_HOST + +/** No-ops entirely without a key — covers self-hosted builds from source + * that never configured one, and the current empty default. */ +export const isConfigured = () => Boolean(KEY) + +export const getClient = async () => { + if (!isConfigured()) return null + if (posthog) return posthog + + const module = await import('posthog-js') + posthog = module.default + + posthog.init(KEY, { + api_host: HOST, + autocapture: false, + capture_pageview: false, + capture_pageleave: false, + disable_session_recording: true, + session_recording: { recorder: undefined }, + persistence: 'localStorage', + opt_out_capturing_by_default: true, + }) + + return posthog +} + +export const getClientSync = () => posthog diff --git a/src/analytics/useAnalyticsIdentity.js b/src/analytics/useAnalyticsIdentity.js new file mode 100644 index 0000000..cceca0c --- /dev/null +++ b/src/analytics/useAnalyticsIdentity.js @@ -0,0 +1,28 @@ +import { useEffect } from 'react' + +import { useCircleMembers, useUserProfile } from '../queries/UserQueries' +import { isPlusAccount } from '../utils/Helpers' +import { identify, updateCommonProperties } from './index' + +/** + * Keeps the analytics module's identity and cohort super-properties in sync + * with the app's own data layer. Mounted once, high in the tree, alongside + * AuthProvider/QueryContext so both queries are already available. + */ +const useAnalyticsIdentity = () => { + const { data: userProfile } = useUserProfile() + const { data: circleMembers } = useCircleMembers() + + useEffect(() => { + if (userProfile?.id) identify(userProfile.id) + }, [userProfile?.id]) + + useEffect(() => { + updateCommonProperties({ + is_plus_account: Boolean(isPlusAccount(userProfile)), + circle_member_count: circleMembers?.res?.length ?? 0, + }) + }, [userProfile, circleMembers?.res?.length]) +} + +export default useAnalyticsIdentity diff --git a/src/contexts/RouterContext.jsx b/src/contexts/RouterContext.jsx index 190e31e..6c5ef53 100644 --- a/src/contexts/RouterContext.jsx +++ b/src/contexts/RouterContext.jsx @@ -42,6 +42,7 @@ import APITokenSettings from '../views/Settings/APITokenSettings' import LocalizationSettings from '../views/Settings/LocalizationSettings' import MFASettings from '../views/Settings/MFASettings' import NotificationSetting from '../views/Settings/NotificationSetting' +import PrivacyAnalyticsSettings from '../views/Settings/PrivacyAnalyticsSettings' import ProfileSettings from '../views/Settings/ProfileSettings' import SidepanelSettings from '../views/Settings/SidepanelSettings' import StorageSettings from '../views/Settings/StorageSettings' @@ -122,6 +123,10 @@ const Router = createBrowserRouter([ path: 'advanced', element: , }, + { + path: 'privacy', + element: , + }, { path: 'developer', element: , diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 3ca3059..b8aea2e 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -1,5 +1,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' + +import { track } from '../analytics' import { networkManager } from '../hooks/NetworkManager' import { commandQueue, CommandType } from '../utils/CommandQueue' import { @@ -226,6 +228,15 @@ export const useCreateChore = () => { if (!createdChore) { throw new Error('Failed to get created chore data') } + track('chore_created', { + has_due_date: Boolean(newTask.dueDate), + has_assignee: Boolean(newTask.assignedTo), + has_labels: Boolean(newTask.labelsV2?.length), + has_description: Boolean(newTask.description?.trim()), + has_recurrence: newTask.frequencyType !== 'once', + recurrence_type: newTask.frequencyType || 'once', + priority: typeof newTask.priority === 'number' ? newTask.priority : 0, + }) return { ...newTask, id: createdChore.res } } catch (error) { if (isNetworkError(error)) { @@ -458,7 +469,7 @@ export const useUpdateChoreHistory = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ choreId, historyId, historyData }) => { + mutationFn: async ({ choreId, historyData, historyId }) => { const applyOptimisticUpdate = async () => { queryClient.setQueryData(['choreHistory', choreId], oldData => { if (!oldData?.res) return oldData @@ -581,7 +592,7 @@ export const useMarkChoreComplete = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ choreId, body, completedDate, performer }) => { + mutationFn: async ({ body, choreId, completedDate, performer }) => { if (isOfflineFeatureEnabled() && !networkManager.isOnline) { await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, { id: choreId, diff --git a/src/service/DiagnosticsSession.js b/src/service/DiagnosticsSession.js index 1592f5b..7d2d3a4 100644 --- a/src/service/DiagnosticsSession.js +++ b/src/service/DiagnosticsSession.js @@ -94,7 +94,7 @@ export const getServerVersion = () => serverVersion // --------------------------------------------------------------------------- /** Strips ids and query strings so failures group by endpoint, not by row. */ -const normalizeEndpoint = endpoint => +export const normalizeEndpoint = endpoint => String(endpoint || '') .split('?')[0] .replace(/\/\d+/g, '/:id') diff --git a/src/service/FeedbackService.js b/src/service/FeedbackService.js index 70e1af7..c514ad2 100644 --- a/src/service/FeedbackService.js +++ b/src/service/FeedbackService.js @@ -1,7 +1,8 @@ -import { InAppReview } from '@capacitor-community/in-app-review' import { Capacitor } from '@capacitor/core' -import { Device } from '@capacitor/device' import { Preferences } from '@capacitor/preferences' +import { InAppReview } from '@capacitor-community/in-app-review' + +import { getAppVersion, getDeviceContext } from '../utils/DeviceInfo' import { isOfficialDonetickInstance } from '../utils/FeatureToggle' const STATE_KEY = 'feedbackState' @@ -26,6 +27,9 @@ const defaultState = { // null until the first prompt is shown/snoozed. lastPromptedAt: null, lastPromptedVersion: null, + // Cumulative across the device's lifetime, unlike lastPromptedAt/Version + // which only remember the most recent showing. + shownCount: 0, dismissCount: 0, reviewRequestedAt: null, lastSentiment: null, @@ -110,31 +114,6 @@ const hasRecentError = () => // Context collection // --------------------------------------------------------------------------- -const getAppVersion = async () => { - if (Capacitor.isNativePlatform()) { - try { - const { App } = await import('@capacitor/app') - const info = await App.getInfo() - return `${info.version} (${info.build})` - } catch { - // fall through to the web bundle version - } - } - return import.meta.env.VITE_APP_VERSION || 'web' -} - -const getDeviceContext = async () => { - try { - const info = await Device.getInfo() - return { - deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '), - osVersion: `${info.operatingSystem} ${info.osVersion}`, - } - } catch { - return { deviceModel: 'unknown', osVersion: 'unknown' } - } -} - /** * Everything we attach to a submission without asking the user for it. */ @@ -251,10 +230,11 @@ export const resetFeedbackState = async () => { } export const markPromptShown = async () => { - const version = await getAppVersion() + const [version, state] = await Promise.all([getAppVersion(), readState()]) return writeState({ lastPromptedAt: Date.now(), lastPromptedVersion: version, + shownCount: state.shownCount + 1, // A forced prompt is spent once shown, otherwise it would fire on every // visit to My Chores. devForced: false, @@ -348,10 +328,10 @@ export const SUBMIT_RESULT = { * see and edit it before anything is published. */ export const buildGithubIssueUrl = ({ - sentiment, category, - message, context, + message, + sentiment, }) => { const labelFor = { bugs: 'bug', @@ -402,10 +382,10 @@ export const buildGithubIssueUrl = ({ * pre-filled GitHub issue URL to send the user to instead. */ export const submitFeedback = async ({ - sentiment, category, - message, feature, + message, + sentiment, userProfile, }) => { const context = await collectFeedbackContext({ feature, userProfile }) diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js index 1d78e60..6560c5e 100644 --- a/src/utils/ApiClient.js +++ b/src/utils/ApiClient.js @@ -1,8 +1,10 @@ import { Preferences } from '@capacitor/preferences' +import { captureError } from '../analytics' import { API_URL } from '../Config' import { networkManager } from '../hooks/NetworkManager' import { + normalizeEndpoint, recordApiFailure, recordServerVersionFromResponse, } from '../service/DiagnosticsSession' @@ -228,6 +230,11 @@ class ApiClient { method: config.method, status: response.status, }) + captureError('api_error', { + http_status: String(response.status), + method: config.method || 'GET', + operation: normalizeEndpoint(endpoint), + }) } // 2. Check for 401 (Unauthorized) @@ -312,6 +319,11 @@ class ApiClient { if (!externalAbort) { networkManager.setServerUnreachable() recordApiFailure({ endpoint, method: config.method, status: 'network' }) + captureError('api_error', { + http_status: 'network', + method: config.method || 'GET', + operation: normalizeEndpoint(endpoint), + }) } console.error('Request failed', error) throw error diff --git a/src/utils/DeviceInfo.js b/src/utils/DeviceInfo.js new file mode 100644 index 0000000..2a1af26 --- /dev/null +++ b/src/utils/DeviceInfo.js @@ -0,0 +1,27 @@ +import { Capacitor } from '@capacitor/core' +import { Device } from '@capacitor/device' + +export const getAppVersion = async () => { + if (Capacitor.isNativePlatform()) { + try { + const { App } = await import('@capacitor/app') + const info = await App.getInfo() + return `${info.version} (${info.build})` + } catch { + // fall through to the web bundle version + } + } + return import.meta.env.VITE_APP_VERSION || 'web' +} + +export const getDeviceContext = async () => { + try { + const info = await Device.getInfo() + return { + deviceModel: [info.manufacturer, info.model].filter(Boolean).join(' '), + osVersion: `${info.operatingSystem} ${info.osVersion}`, + } + } catch { + return { deviceModel: 'unknown', osVersion: 'unknown' } + } +} diff --git a/src/utils/Onboarding.js b/src/utils/Onboarding.js index 254af11..57fc9ca 100644 --- a/src/utils/Onboarding.js +++ b/src/utils/Onboarding.js @@ -52,22 +52,21 @@ export const recordAcquisitionSource = source => { } } -const PRIVACY_PREFERENCES_KEY = 'privacyPreferences' - /** - * Stashes the self-hosted privacy opt-ins locally, same stub-for-now - * treatment as recordAcquisitionSource: no crash reporter or PostHog is wired - * up yet, so this is just the one place that'll change once there is one. + * Wires the self-hosted privacy opt-ins into the real analytics module. + * Analytics and crash reports are independent consent axes — a user can opt + * into one without the other, matching the two separate switches shown on + * this screen. Both default to disabled; this only ever runs once the user + * has made an explicit choice. */ -export const recordPrivacyPreferences = ({ crashReports, analytics }) => { - try { - localStorage.setItem( - PRIVACY_PREFERENCES_KEY, - JSON.stringify({ crashReports, analytics }), - ) - } catch { - // ignore, this is best-effort telemetry - } +export const recordPrivacyPreferences = async ({ analytics, crashReports }) => { + const { setConsent } = await import('../analytics') + await setConsent('analytics', analytics ? 'enabled' : 'disabled', { + source: 'onboarding', + }) + await setConsent('crash', crashReports ? 'enabled' : 'disabled', { + source: 'onboarding', + }) } /** @@ -81,9 +80,8 @@ export const recordPrivacyPreferences = ({ crashReports, analytics }) => { export const requestNotificationPermission = async () => { if (!isNativeApp()) return false try { - const { LocalNotifications } = await import( - '@capacitor/local-notifications' - ) + const { LocalNotifications } = + await import('@capacitor/local-notifications') const { Preferences } = await import('@capacitor/preferences') const result = await LocalNotifications.requestPermissions() diff --git a/src/views/Modals/FeedbackModal.jsx b/src/views/Modals/FeedbackModal.jsx index 04612e2..56354e8 100644 --- a/src/views/Modals/FeedbackModal.jsx +++ b/src/views/Modals/FeedbackModal.jsx @@ -1,4 +1,5 @@ import { Browser } from '@capacitor/browser' +import { Capacitor } from '@capacitor/core' import { Android, Apple, Favorite, GitHub } from '@mui/icons-material' import { Box, @@ -15,19 +16,21 @@ import { import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useLocation } from 'react-router-dom' + +import { track } from '../../analytics' import { useResponsiveModal } from '../../hooks/useResponsiveModal.js' import { useUserProfile } from '../../queries/UserQueries' import { FEEDBACK_CATEGORIES, + getFeedbackState, isCloudInstance, markSentiment, requestStoreReview, SENTIMENTS, storeLinks, - submitFeedback, SUBMIT_RESULT, + submitFeedback, } from '../../service/FeedbackService' -import { Capacitor } from '@capacitor/core' const STEP = { SENTIMENT: 'sentiment', @@ -57,7 +60,7 @@ const SENTIMENT_OPTIONS = [ * dialog (or star links on web); anything else collects structured feedback * and never asks for a review. */ -const FeedbackModal = ({ open, onClose, onDismiss }) => { +const FeedbackModal = ({ onClose, onDismiss, open, source = 'settings' }) => { const { t } = useTranslation() const { ResponsiveModal } = useResponsiveModal() const { data: userProfile } = useUserProfile() @@ -70,6 +73,7 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { const [submitting, setSubmitting] = useState(false) const [isCloud, setIsCloud] = useState(true) const [githubUrl, setGithubUrl] = useState(null) + const [shownCount, setShownCount] = useState(0) useEffect(() => { if (open) { @@ -80,18 +84,28 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { setSubmitting(false) setGithubUrl(null) isCloudInstance().then(setIsCloud) + getFeedbackState().then(state => { + const count = state.shownCount || 0 + setShownCount(count) + track('feedback_prompt_shown', { source, shown_count: count }) + }) } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const handleClose = () => { // Backing out before answering counts as a dismissal for the cooldown. - if (step === STEP.SENTIMENT) onDismiss?.() + if (step === STEP.SENTIMENT) { + onDismiss?.() + track('feedback_prompt_dismissed', { source, shown_count: shownCount }) + } onClose() } const handleSentiment = async value => { setSentiment(value) await markSentiment(value) + track('feedback_sentiment_selected', { sentiment: value }) if (value !== SENTIMENTS.LOVE) { setStep(STEP.DETAILS) @@ -110,7 +124,7 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { const handleSubmit = async () => { setSubmitting(true) - const { result, githubUrl: url } = await submitFeedback({ + const { githubUrl: url, result } = await submitFeedback({ sentiment, category, message, @@ -118,6 +132,11 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { userProfile, }) setSubmitting(false) + track('feedback_submitted', { + category, + has_message: message.trim().length > 0, + result, + }) // Self-hosted feedback is never relayed; hand the user a pre-filled issue // instead so they choose what gets published. @@ -248,7 +267,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { variant='outlined' color='neutral' startDecorator={} - onClick={() => openUrl(storeLinks.github)} + onClick={() => { + track('feedback_review_action', { action: 'github' }) + openUrl(storeLinks.github) + }} sx={{ justifyContent: 'flex-start' }} > {t('feedback.review.github')} @@ -257,7 +279,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { variant='outlined' color='neutral' startDecorator={} - onClick={() => openUrl(storeLinks.appStore)} + onClick={() => { + track('feedback_review_action', { action: 'appStore' }) + openUrl(storeLinks.appStore) + }} sx={{ justifyContent: 'flex-start' }} > {t('feedback.review.appStore')} @@ -266,7 +291,10 @@ const FeedbackModal = ({ open, onClose, onDismiss }) => { variant='outlined' color='neutral' startDecorator={} - onClick={() => openUrl(storeLinks.playStore)} + onClick={() => { + track('feedback_review_action', { action: 'playStore' }) + openUrl(storeLinks.playStore) + }} sx={{ justifyContent: 'flex-start' }} > {t('feedback.review.playStore')} diff --git a/src/views/Onboarding/HeardAboutView.jsx b/src/views/Onboarding/HeardAboutView.jsx index a1208b8..3211ad8 100644 --- a/src/views/Onboarding/HeardAboutView.jsx +++ b/src/views/Onboarding/HeardAboutView.jsx @@ -2,6 +2,7 @@ import { CheckRounded } from '@mui/icons-material' import { Box, Button, Input, Link, Switch, Typography } from '@mui/joy' import { useEffect, useState } from 'react' import { useNavigate } from 'react-router-dom' + import { isOfficialDonetickInstance } from '../../utils/FeatureToggle' import { haptic, @@ -61,7 +62,7 @@ const Shell = ({ children }) => ( /** * A one-question attribution survey dropped right after account creation, - * while the "why did I click install" is still fresh. Answering is optional + * while the "why did I click install" is still fresh. Answering is optional * blocking a brand-new user on a marketing question would cost more than the * data is worth so Continue is always enabled. Shown only on the official * donetick.com instance: a self-hosted server has no marketing funnel to @@ -228,8 +229,8 @@ const PrivacyPreferences = ({ onDone }) => { const values = { crashReports, analytics } - const finish = () => { - recordPrivacyPreferences(values) + const finish = async () => { + await recordPrivacyPreferences(values) onDone() } @@ -264,7 +265,7 @@ const PrivacyPreferences = ({ onDone }) => { ...enter(60), }} > - {PRIVACY_TOGGLES.map(({ key, label, description }) => ( + {PRIVACY_TOGGLES.map(({ description, key, label }) => ( ( +const Dots = ({ activeIndex, count, onSelect }) => ( {Array.from({ length: count }, (_, index) => { const active = index === activeIndex @@ -108,6 +110,10 @@ const OnboardingView = () => { const isLast = activeIndex === SLIDES.length - 1 const asksPermission = Boolean(SLIDES[activeIndex].permission) + useEffect(() => { + track('onboarding_started') + }, []) + const finish = useCallback(() => { markOnboardingSeen() navigate('/get-started', { replace: true }) diff --git a/src/views/Onboarding/WorkspaceReadyView.jsx b/src/views/Onboarding/WorkspaceReadyView.jsx index 8533417..4f363cf 100644 --- a/src/views/Onboarding/WorkspaceReadyView.jsx +++ b/src/views/Onboarding/WorkspaceReadyView.jsx @@ -4,6 +4,8 @@ import { Box, Button, Typography } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { useNavigate } from 'react-router-dom' + +import { track } from '../../analytics' import Logo from '../../Logo' import { useUserProfile } from '../../queries/UserQueries' import { haptic } from '../../utils/Onboarding' @@ -70,6 +72,7 @@ const WorkspaceReadyView = () => { console.log('Paywall skipped:', error) } finally { setBusy(false) + track('onboarding_completed') enterApp() } } diff --git a/src/views/Settings/PrivacyAnalyticsSettings.jsx b/src/views/Settings/PrivacyAnalyticsSettings.jsx new file mode 100644 index 0000000..3aa8ee8 --- /dev/null +++ b/src/views/Settings/PrivacyAnalyticsSettings.jsx @@ -0,0 +1,86 @@ +import { Box, FormControl, FormHelperText, Switch, Typography } from '@mui/joy' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { getConsent, initialize, setConsent } from '../../analytics' +import SettingsLayout from './SettingsLayout' + +const TOGGLES = [ + { + kind: 'analytics', + labelKey: 'analyticsToggle', + helperKey: 'analyticsHelper', + }, + { kind: 'crash', labelKey: 'crashToggle', helperKey: 'crashHelper' }, +] + +const PrivacyAnalyticsSettings = () => { + const { t } = useTranslation('settings') + const [consent, setConsentState] = useState({ + analytics: 'disabled', + crash: 'disabled', + }) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + initialize().then(() => { + if (cancelled) return + setConsentState({ + analytics: getConsent('analytics'), + crash: getConsent('crash'), + }) + setLoading(false) + }) + return () => { + cancelled = true + } + }, []) + + const handleToggle = kind => async event => { + const next = event.target.checked ? 'enabled' : 'disabled' + setConsentState(current => ({ ...current, [kind]: next })) + await setConsent(kind, next, { source: 'settings' }) + } + + return ( + +
+ + {t('privacyAnalytics.description')} + + + {TOGGLES.map(({ helperKey, kind, labelKey }) => ( + + + + {t(`privacyAnalytics.${labelKey}`)} + + + + + {t(`privacyAnalytics.${helperKey}`)} + + + ))} + + + {t('privacyAnalytics.footnote')} + +
+
+ ) +} + +export default PrivacyAnalyticsSettings diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx index 0e5ae66..4a0eb9b 100644 --- a/src/views/Settings/SettingsOverview.jsx +++ b/src/views/Settings/SettingsOverview.jsx @@ -11,6 +11,7 @@ import { Notifications, Palette, Person, + PrivacyTip, Security, Settings, Star, @@ -124,6 +125,12 @@ const SettingsOverview = () => { description: t('overview.sections.advanced.description'), icon: , }, + { + id: 'privacy', + title: t('overview.sections.privacy.title'), + description: t('overview.sections.privacy.description'), + icon: , + }, { id: 'developer', title: t('overview.sections.developer.title'), diff --git a/src/views/components/FeedbackPrompt.jsx b/src/views/components/FeedbackPrompt.jsx index 72c51e3..38821a8 100644 --- a/src/views/components/FeedbackPrompt.jsx +++ b/src/views/components/FeedbackPrompt.jsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react' + import { useUserProfile } from '../../queries/UserQueries' import { installFeedbackErrorListeners, @@ -31,9 +32,12 @@ const FeedbackPrompt = () => { shouldShowSentimentPrompt({ userProfile }).then(eligible => { if (!eligible || cancelled) return - timer = setTimeout(() => { + timer = setTimeout(async () => { + if (cancelled) return + // Awaited so the persisted shownCount is settled before the modal + // reads it back for the feedback_prompt_shown event. + await markPromptShown() if (cancelled) return - markPromptShown() setOpen(true) }, OPEN_DELAY_MS) }) @@ -51,6 +55,7 @@ const FeedbackPrompt = () => { open={open} onClose={() => setOpen(false)} onDismiss={markPromptDismissed} + source='auto' /> ) } From c88790abac9fda498244fa6421ad0ddf4025fb4e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 11 Aug 2026 23:28:04 -0400 Subject: [PATCH 2/5] feat(analytics): enhance event tracking and add privacy settings for analytics --- public/locales/en/settings.json | 13 ++++ src/analytics/eventSchemas.js | 22 +++++- src/analytics/index.js | 8 ++- src/queries/ChoreQueries.jsx | 18 ++++- src/views/ChoreEdit/ChoreEdit.jsx | 5 ++ src/views/Filters/FilterQueries.js | 5 +- src/views/Projects/ProjectQueries.js | 5 +- src/views/Settings/LocalizationSettings.jsx | 77 ++++++++++++++++----- src/views/Things/ThingsView.jsx | 10 ++- src/views/components/AddTaskModal.jsx | 10 +++ 10 files changed, 146 insertions(+), 27 deletions(-) diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json index 54b7274..a748179 100644 --- a/public/locales/en/settings.json +++ b/public/locales/en/settings.json @@ -78,6 +78,10 @@ "title": "Advanced Settings", "description": "Configure webhooks, real-time updates, and other advanced features for enhanced productivity." }, + "privacy": { + "title": "Privacy & Analytics", + "description": "Control anonymous usage analytics and crash reporting." + }, "developer": { "title": "Developer Settings", "description": "View technical information about authentication tokens, SSE connections, and debug data." @@ -490,5 +494,14 @@ "notConnected": "Real-time updates are enabled but not currently connected.", "basicPlanNotice": "Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when you or other circle members complete, skip, or modify tasks." } + }, + "privacyAnalytics": { + "title": "Privacy & Analytics", + "description": "Donetick never sends task names, descriptions, notes, or other task content. Session recording is never enabled.", + "analyticsToggle": "Anonymous analytics", + "analyticsHelper": "Help improve Donetick by sharing anonymous usage statistics.", + "crashToggle": "Crash reports", + "crashHelper": "Send us the error and what led to it when Donetick crashes no task content.", + "footnote": "You can change these choices at any time." } } diff --git a/src/analytics/eventSchemas.js b/src/analytics/eventSchemas.js index 8934d80..c85c671 100644 --- a/src/analytics/eventSchemas.js +++ b/src/analytics/eventSchemas.js @@ -29,7 +29,27 @@ export const EVENT_SCHEMAS = { has_recurrence: 'boolean', recurrence_type: 'string', priority: 'number', - source: 'string', + // quick_add/voice/scan = the AddTaskModal popup; full_page/clone = the + // dedicated create page (ChoreEdit.jsx with no existing chore id). + source: 'enum:quick_add,voice,scan,full_page,clone', + }), + chore_updated: withCommon({ + has_due_date: 'boolean', + has_assignee: 'boolean', + has_labels: 'boolean', + has_description: 'boolean', + has_recurrence: 'boolean', + recurrence_type: 'string', + priority: 'number', + }), + + thing_created: withCommon({}), + project_created: withCommon({}), + filter_created: withCommon({}), + + localization_setting_changed: withCommon({ + setting: 'enum:language,date_format,time_format,first_day_of_week', + value: 'string', }), analytics_enabled: withCommon({ diff --git a/src/analytics/index.js b/src/analytics/index.js index e2e3ea0..3197bcd 100644 --- a/src/analytics/index.js +++ b/src/analytics/index.js @@ -150,7 +150,13 @@ export const captureError = (errorType, properties = {}) => { const sanitized = sanitizeErrorProperties(errorType, properties) if (!sanitized) return - posthog.capture(errorType, sanitized) + // captureException (not capture) so this lands on PostHog's Error Tracking + // page, grouped by errorType — the message is deliberately generic, since + // any per-instance detail must go through the sanitized allowlist above, + // never straight into the exception message. + const error = new Error(errorType) + error.name = errorType + posthog.captureException(error, sanitized) } /** diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index b8aea2e..2fd41e2 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -214,7 +214,10 @@ export const useCreateChore = () => { } return useMutation({ - mutationFn: async newTask => { + mutationFn: async rawTask => { + // `source` is analytics-only metadata (typed/voice/scan/clone) — never + // send it to the backend as part of the chore payload. + const { source, ...newTask } = rawTask if (isOfflineFeatureEnabled() && !networkManager.isOnline) { return queueOfflineCreate(newTask) } @@ -236,6 +239,7 @@ export const useCreateChore = () => { has_recurrence: newTask.frequencyType !== 'once', recurrence_type: newTask.frequencyType || 'once', priority: typeof newTask.priority === 'number' ? newTask.priority : 0, + source: source || 'quick_add', }) return { ...newTask, id: createdChore.res } } catch (error) { @@ -298,6 +302,18 @@ export const useUpdateChore = () => { ), } }) + track('chore_updated', { + has_due_date: Boolean(updatedChore.dueDate), + has_assignee: Boolean(updatedChore.assignedTo), + has_labels: Boolean(updatedChore.labelsV2?.length), + has_description: Boolean(updatedChore.description?.trim()), + has_recurrence: updatedChore.frequencyType !== 'once', + recurrence_type: updatedChore.frequencyType || 'once', + priority: + typeof updatedChore.priority === 'number' + ? updatedChore.priority + : 0, + }) return updatedChoreRes?.res || updatedChore } catch (error) { if (isNetworkError(error)) { diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index f53b9c1..fbceee6 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -402,6 +402,11 @@ const ChoreEdit = () => { let SaveFunction = createChoreMutation.mutateAsync if (newChoreId > 0) { SaveFunction = updateChoreMutation.mutateAsync + } else { + // This is the dedicated create page, distinct from the AddTaskModal + // popup (which sets its own quick_add/voice/scan source). + chore.source = + searchParams.get('clone') === 'true' ? 'clone' : 'full_page' } SaveFunction(chore) diff --git a/src/views/Filters/FilterQueries.js b/src/views/Filters/FilterQueries.js index 893078e..72e2f82 100644 --- a/src/views/Filters/FilterQueries.js +++ b/src/views/Filters/FilterQueries.js @@ -1,4 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { track } from '../../analytics' import { CreateFilter, DeleteFilter, @@ -112,6 +114,7 @@ export const useCreateFilter = () => { const response = await CreateFilter(filterData) if (response.ok) { const data = await response.json() + track('filter_created', {}) return data.res || data } const errorData = await response.json() @@ -142,7 +145,7 @@ export const useUpdateFilter = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ filterId, filterData }) => { + mutationFn: async ({ filterData, filterId }) => { try { const response = await UpdateFilter(filterId, filterData) if (response.ok) { diff --git a/src/views/Projects/ProjectQueries.js b/src/views/Projects/ProjectQueries.js index 039d0b4..3fc6b32 100644 --- a/src/views/Projects/ProjectQueries.js +++ b/src/views/Projects/ProjectQueries.js @@ -1,4 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' + +import { track } from '../../analytics' import { CreateProject, DeleteProject, @@ -43,6 +45,7 @@ export const useCreateProject = () => { const response = await CreateProject(projectData) if (response.ok) { const data = await response.json() + track('project_created', {}) return data.res || data } throw new Error('Failed to create project') @@ -79,7 +82,7 @@ export const useUpdateProject = () => { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ projectId, projectData }) => { + mutationFn: async ({ projectData, projectId }) => { try { const response = await UpdateProject(projectId, projectData) if (response.ok) { diff --git a/src/views/Settings/LocalizationSettings.jsx b/src/views/Settings/LocalizationSettings.jsx index c7ef4c5..5cc52ff 100644 --- a/src/views/Settings/LocalizationSettings.jsx +++ b/src/views/Settings/LocalizationSettings.jsx @@ -1,8 +1,3 @@ -import { - DATE_FORMATS, - TIME_FORMATS, - useLocalization, -} from '@/contexts/LocalizationContext' import { Box, Button, @@ -16,21 +11,29 @@ import { } from '@mui/joy' import moment from 'moment' import { useTranslation } from 'react-i18next' + +import { + DATE_FORMATS, + TIME_FORMATS, + useLocalization, +} from '@/contexts/LocalizationContext' + +import { track } from '../../analytics' import SettingsLayout from './SettingsLayout' const LocalizationSettings = () => { const { t } = useTranslation('settings') const { - language, - setLanguage, - dateFormat, - setDateFormat, - timeFormat, - setTimeFormat, - firstDayOfWeek, - setFirstDayOfWeek, availableLanguages, + dateFormat, + firstDayOfWeek, isRTL, + language, + setDateFormat, + setFirstDayOfWeek, + setLanguage, + setTimeFormat, + timeFormat, } = useLocalization() const sampleDate = moment('2024-01-15 14:30:00') @@ -56,7 +59,13 @@ const LocalizationSettings = () => { setDateFormat(value)} + onChange={(_, value) => { + setDateFormat(value) + track('localization_setting_changed', { + setting: 'date_format', + value, + }) + }} sx={{ maxWidth: '300px' }} > {dateFormatOptions.map(option => ( @@ -119,7 +134,13 @@ const LocalizationSettings = () => {