feat(analytics): enhance event tracking and add privacy settings for analytics
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user