From c88790abac9fda498244fa6421ad0ddf4025fb4e Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 11 Aug 2026 23:28:04 -0400 Subject: [PATCH] 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 = () => {