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.
This commit is contained in:
Mo Tarbin
2026-08-11 21:42:51 -04:00
parent 7e8802052a
commit a58888f0e6
22 changed files with 874 additions and 78 deletions

View File

@@ -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 (
<div>
<NetworkBanner />

107
src/analytics/consent.js Normal file
View File

@@ -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),
])
}

View File

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

211
src/analytics/index.js Normal file
View File

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

View File

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

View File

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

View File

@@ -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: <AdvancedSettings />,
},
{
path: 'privacy',
element: <PrivacyAnalyticsSettings />,
},
{
path: 'developer',
element: <DeveloperSettings />,

View File

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

View File

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

View File

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

View File

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

27
src/utils/DeviceInfo.js Normal file
View File

@@ -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' }
}
}

View File

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

View File

@@ -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={<GitHub />}
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={<Apple />}
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={<Android />}
onClick={() => openUrl(storeLinks.playStore)}
onClick={() => {
track('feedback_review_action', { action: 'playStore' })
openUrl(storeLinks.playStore)
}}
sx={{ justifyContent: 'flex-start' }}
>
{t('feedback.review.playStore')}

View File

@@ -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 }) => (
<Box
key={key}
sx={{

View File

@@ -5,6 +5,8 @@ import {
import { Box, Button, Typography } from '@mui/joy'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { track } from '../../analytics'
import Logo from '../../Logo'
import {
haptic,
@@ -63,7 +65,7 @@ const SLIDES = [
},
]
const Dots = ({ count, activeIndex, onSelect }) => (
const Dots = ({ activeIndex, count, onSelect }) => (
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1 }}>
{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 })

View File

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

View File

@@ -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 (
<SettingsLayout title={t('privacyAnalytics.title')}>
<div className='grid gap-4'>
<Typography level='body-md'>
{t('privacyAnalytics.description')}
</Typography>
{TOGGLES.map(({ helperKey, kind, labelKey }) => (
<FormControl key={kind} sx={{ mt: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
gap: 1.5,
}}
>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{t(`privacyAnalytics.${labelKey}`)}
</Typography>
<Switch
checked={consent[kind] === 'enabled'}
onChange={handleToggle(kind)}
disabled={loading}
/>
</Box>
<FormHelperText>
{t(`privacyAnalytics.${helperKey}`)}
</FormHelperText>
</FormControl>
))}
<Typography level='body-sm' sx={{ color: 'text.secondary', mt: 1 }}>
{t('privacyAnalytics.footnote')}
</Typography>
</div>
</SettingsLayout>
)
}
export default PrivacyAnalyticsSettings

View File

@@ -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: <Settings />,
},
{
id: 'privacy',
title: t('overview.sections.privacy.title'),
description: t('overview.sections.privacy.description'),
icon: <PrivacyTip />,
},
{
id: 'developer',
title: t('overview.sections.developer.title'),

View File

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