feat(analytics): enhance event tracking and add privacy settings for analytics

This commit is contained in:
Mo Tarbin
2026-08-11 23:28:04 -04:00
parent a58888f0e6
commit c88790abac
10 changed files with 146 additions and 27 deletions

View File

@@ -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({

View File

@@ -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)
}
/**

View File

@@ -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)) {

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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 = () => {
<FormControl>
<Select
value={language}
onChange={(_, value) => setLanguage(value)}
onChange={(_, value) => {
setLanguage(value)
track('localization_setting_changed', {
setting: 'language',
value,
})
}}
sx={{ maxWidth: '300px' }}
>
{availableLanguages.map(lang => (
@@ -83,7 +92,13 @@ const LocalizationSettings = () => {
<FormControl>
<Select
value={dateFormat}
onChange={(_, value) => 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 = () => {
<FormControl>
<Select
value={timeFormat}
onChange={(_, value) => setTimeFormat(value)}
onChange={(_, value) => {
setTimeFormat(value)
track('localization_setting_changed', {
setting: 'time_format',
value,
})
}}
sx={{ maxWidth: '300px' }}
>
<Option value={TIME_FORMATS.HOUR_12}>
@@ -169,19 +190,37 @@ const LocalizationSettings = () => {
<ButtonGroup variant='outlined'>
<Button
variant={firstDayOfWeek === 0 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(0)}
onClick={() => {
setFirstDayOfWeek(0)
track('localization_setting_changed', {
setting: 'first_day_of_week',
value: 'sunday',
})
}}
>
{t('localization.sunday')}
</Button>
<Button
variant={firstDayOfWeek === 1 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(1)}
onClick={() => {
setFirstDayOfWeek(1)
track('localization_setting_changed', {
setting: 'first_day_of_week',
value: 'monday',
})
}}
>
{t('localization.monday')}
</Button>
<Button
variant={firstDayOfWeek === 6 ? 'solid' : 'outlined'}
onClick={() => setFirstDayOfWeek(6)}
onClick={() => {
setFirstDayOfWeek(6)
track('localization_setting_changed', {
setting: 'first_day_of_week',
value: 'saturday',
})
}}
>
{t('localization.saturday')}
</Button>

View File

@@ -1,11 +1,12 @@
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Type as ListType,
SwipeableList,
SwipeableListItem,
SwipeAction,
TrailingActions,
Type as ListType,
} from '@meauxt/react-swipeable-list'
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Add,
Delete,
@@ -28,6 +29,8 @@ import {
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics'
import EmptyState from '../../components/common/EmptyState'
import { useNotification } from '../../service/NotificationProvider'
import {
@@ -42,7 +45,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import CreateThingModal from '../Modals/Inputs/CreateThingModal'
import EditThingStateModal from '../Modals/Inputs/EditThingState'
const ThingCardContent = ({ thing, onCardClick, onToggleActions }) => {
const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
const getThingIcon = type => {
if (type === 'text') {
return <Flip />
@@ -242,6 +245,7 @@ const ThingsView = () => {
const currentThings = [...things]
currentThings.push(data.res)
setThings(currentThings)
track('thing_created', {})
}
showNotification({
type: 'success',

View File

@@ -266,6 +266,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
// Identities (type + text) of the highlights from the previous parse, so
// the appear animation only plays for tokens detected just now
const prevHighlightKeysRef = useRef(new Set())
// Which capture method last populated the form, for chore_created's
// analytics `source` property. Reset to 'quick_add' whenever the modal
// closes — this is the AddTaskModal popup, distinct from the full-page
// create flow in ChoreEdit.jsx.
const taskSourceRef = useRef('quick_add')
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
@@ -867,6 +872,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
dueDate: extractedDue,
taskName,
}) => {
taskSourceRef.current = 'scan'
if (attachmentImage) {
attachScannedImage(attachmentImage)
}
@@ -890,6 +896,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
// reviews it with the normal pickers before creating.
const handleVoiceSingle = (text, overrides = {}) => {
setShowVoice(false)
taskSourceRef.current = 'voice'
if (Object.keys(overrides).length > 0) {
pendingVoiceOverridesRef.current = overrides
}
@@ -907,6 +914,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
projectId,
notificationTemplates,
})
chore.source = 'voice'
try {
const result = await createChoreMutation.mutateAsync(chore)
if (result?._pendingCreate) {
@@ -987,6 +995,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
setUseCustomTime(false)
setAttachments([])
setDraftId(generateUUID())
taskSourceRef.current = 'quick_add'
}
const createChore = () => {
@@ -1035,6 +1044,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
draftId: draftId,
source: taskSourceRef.current,
}
// Reminders are a Plus feature and only make sense when the user kept at