diff --git a/src/MarketingApp.jsx b/src/MarketingApp.jsx
index 4f862fb..9f1d182 100644
--- a/src/MarketingApp.jsx
+++ b/src/MarketingApp.jsx
@@ -23,6 +23,9 @@ const AppRedirect = () => {
const router = createBrowserRouter([
{ path: '/', element: },
+ // Mirrors the app router, where /welcome is the canonical Landing path —
+ // without it this falls through to AppRedirect and bounces to /login.
+ { path: '/welcome', element: },
{ path: '/privacy', element: },
{ path: '/terms', element: },
{ path: '*', element: },
@@ -50,17 +53,20 @@ const ThemeClass = () => {
return null
}
+// Same ordering constraint as contexts/Contexts.jsx: ThemeContext reads the
+// active language via useLocalization() to pick the text direction, so
+// LocalizationProvider has to sit above it.
const MarketingApp = () => (
-
-
-
-
+
+
+
+
-
-
-
+
+
+
)
export default MarketingApp
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..4d164aa
--- /dev/null
+++ b/src/analytics/eventSchemas.js
@@ -0,0 +1,165 @@
+// 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',
+ // 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({
+ 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',
+ },
+ // No message/stack field here by design — those come from the real Error
+ // object passed to posthog.captureException() itself, not from this
+ // sanitized properties bag. This schema only classifies how it was caught.
+ frontend_error: {
+ source: 'enum:window_error,unhandled_rejection',
+ },
+}
+
+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..b16d7e1
--- /dev/null
+++ b/src/analytics/index.js
@@ -0,0 +1,261 @@
+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)
+}
+
+/** Backend/API failures: a normal sanitized event, same as track() — not
+ * PostHog's Error Tracking product. There's no real Error object here (just
+ * an HTTP response), so there's no stack trace to gain from captureException. */
+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)
+}
+
+/**
+ * Frontend crashes only. Uses captureException (not capture) so these land
+ * on PostHog's Error Tracking page with a genuine message + stack trace —
+ * that text is NOT filtered by the sanitized allowlist below, since it comes
+ * from the exception object itself, not from `properties`.
+ */
+export const captureException = (error, properties = {}) => {
+ if (!canSend('crash')) return
+ const posthog = getClientSync()
+ if (!posthog) return
+
+ const sanitized = sanitizeErrorProperties('frontend_error', properties)
+ if (!sanitized) return
+
+ posthog.captureException(error, sanitized)
+}
+
+let globalHandlersInstalled = false
+
+/** Reports uncaught exceptions and unhandled promise rejections to
+ * PostHog's Error Tracking, gated by the same crash consent as api_error.
+ * Complements, doesn't overlap with, src/views/Error.jsx: that's a React
+ * Router error-boundary screen for render/loader errors, which React catches
+ * before they ever reach window.onerror — a different class of failure, with
+ * its own user-initiated "Report this problem" flow via ErrorReportService.
+ * These listeners only see what bypasses React's boundaries entirely (event
+ * handlers, timers, unhandled promise rejections). Safe to call multiple
+ * times; only installs once. */
+export const installGlobalErrorHandlers = () => {
+ if (globalHandlersInstalled || typeof window === 'undefined') return
+ globalHandlersInstalled = true
+
+ window.addEventListener('error', event => {
+ const error =
+ event?.error instanceof Error
+ ? event.error
+ : new Error(event?.message || 'Unknown window error')
+ captureException(error, { source: 'window_error' })
+ })
+
+ window.addEventListener('unhandledrejection', event => {
+ const reason = event?.reason
+ const error = reason instanceof Error ? reason : new Error(String(reason))
+ captureException(error, { source: 'unhandled_rejection' })
+ })
+}
+
+/**
+ * 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/constants/policyUpdates.js b/src/constants/policyUpdates.js
new file mode 100644
index 0000000..53176a7
--- /dev/null
+++ b/src/constants/policyUpdates.js
@@ -0,0 +1,13 @@
+// The single source of truth for "have the legal documents changed since the
+// user last saw them". Bump POLICY_VERSION whenever PrivacyPolicyView or
+// TermsView change in a way users should be told about, and move
+// POLICY_EFFECTIVE_DATE to the same date shown at the bottom of those pages.
+//
+// The notice always points at the documents, so a revision needs nothing here
+// beyond these two values plus, optionally, a summary.
+
+export const POLICY_VERSION = 2
+
+// ISO date. Accounts created on or after this date signed up under the current
+// documents, so they are never shown the update notice.
+export const POLICY_EFFECTIVE_DATE = '2026-08-12'
diff --git a/src/constants/settingsSections.js b/src/constants/settingsSections.js
index 3d64d8c..7e52e50 100644
--- a/src/constants/settingsSections.js
+++ b/src/constants/settingsSections.js
@@ -8,6 +8,7 @@ import {
Notifications,
Palette,
Person,
+ PrivacyTip,
Security,
Settings,
Storage,
@@ -30,5 +31,6 @@ export const SETTINGS_SECTIONS = [
{ id: 'theme', icon: Palette },
{ id: 'localization', icon: Language, isBeta: true },
{ id: 'advanced', icon: Settings },
+ { id: 'privacy', icon: PrivacyTip },
{ id: 'developer', icon: Code },
]
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/main.jsx b/src/main.jsx
index 0aefb3d..e2ea75c 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -5,10 +5,13 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
const marketingHosts = new Set(['donetick.com', 'www.donetick.com'])
+// ?site=marketing works in every build, not just dev, so preview deploys on
+// *.pages.dev can exercise the marketing bundle. Landing on the app host with
+// the flag set just renders the marketing pages — nothing sensitive is gated
+// on this.
const isMarketingSite =
marketingHosts.has(window.location.hostname) ||
- (import.meta.env.DEV &&
- new URLSearchParams(window.location.search).get('site') === 'marketing')
+ new URLSearchParams(window.location.search).get('site') === 'marketing'
export const Site = React.lazy(() =>
isMarketingSite ? import('./MarketingApp.jsx') : import('./Application.jsx'),
diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx
index 3ca3059..2fd41e2 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 {
@@ -212,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)
}
@@ -226,6 +231,16 @@ 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,
+ source: source || 'quick_add',
+ })
return { ...newTask, id: createdChore.res }
} catch (error) {
if (isNetworkError(error)) {
@@ -287,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)) {
@@ -458,7 +485,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 +608,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/service/PolicyUpdateService.js b/src/service/PolicyUpdateService.js
new file mode 100644
index 0000000..0b18271
--- /dev/null
+++ b/src/service/PolicyUpdateService.js
@@ -0,0 +1,90 @@
+import { Preferences } from '@capacitor/preferences'
+
+import {
+ POLICY_EFFECTIVE_DATE,
+ POLICY_VERSION,
+} from '../constants/policyUpdates'
+
+const STATE_KEY = 'policyUpdateState'
+
+const defaultState = {
+ // Highest POLICY_VERSION the user has dismissed. 0 = never acknowledged.
+ acknowledgedVersion: 0,
+ acknowledgedAt: null,
+ // Developer Settings escape hatch; never set in normal use.
+ devForced: false,
+}
+
+let cachedState = null
+
+const readState = async () => {
+ if (cachedState) return cachedState
+ try {
+ const { value } = await Preferences.get({ key: STATE_KEY })
+ cachedState = { ...defaultState, ...(value ? JSON.parse(value) : {}) }
+ } catch (error) {
+ console.warn('PolicyUpdateService: unable to read state', error)
+ cachedState = { ...defaultState }
+ }
+ return cachedState
+}
+
+const writeState = async patch => {
+ const current = await readState()
+ cachedState = { ...current, ...patch }
+ try {
+ await Preferences.set({
+ key: STATE_KEY,
+ value: JSON.stringify(cachedState),
+ })
+ } catch (error) {
+ // Worst case the notice is shown once more on the next launch, which is
+ // strictly better than suppressing a legal notice we failed to record.
+ console.warn('PolicyUpdateService: unable to persist state', error)
+ }
+}
+
+export const getPolicyUpdateState = readState
+
+// FeedbackService sees both spellings off the profile endpoint; match it.
+const getSignupDate = userProfile =>
+ userProfile?.createdAt || userProfile?.created_at || null
+
+/**
+ * The notice is for people who agreed to an *earlier* revision. Accounts
+ * created on or after the effective date already signed up under the current
+ * documents, so they are silently marked as acknowledged instead of being
+ * interrupted by a change they never experienced.
+ */
+export const shouldShowPolicyUpdate = async ({ userProfile } = {}) => {
+ const state = await readState()
+ if (state.devForced) return true
+ if (state.acknowledgedVersion >= POLICY_VERSION) return false
+
+ // An unreadable signup date errs toward showing: a missed legal notice is
+ // worse than one extra dismissal.
+ const signupDate = getSignupDate(userProfile)
+ const signedUpAt = signupDate ? new Date(signupDate) : null
+
+ if (
+ signedUpAt &&
+ !isNaN(signedUpAt.getTime()) &&
+ signedUpAt >= new Date(`${POLICY_EFFECTIVE_DATE}T00:00:00Z`)
+ ) {
+ await acknowledgePolicyUpdate()
+ return false
+ }
+
+ return true
+}
+
+export const acknowledgePolicyUpdate = () =>
+ writeState({
+ acknowledgedVersion: POLICY_VERSION,
+ acknowledgedAt: new Date().toISOString(),
+ devForced: false,
+ })
+
+/** Developer Settings only: replay the notice on the next eligible mount. */
+export const resetPolicyUpdate = () =>
+ writeState({ acknowledgedVersion: 0, acknowledgedAt: null, devForced: true })
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/Authorization/Signup.jsx b/src/views/Authorization/Signup.jsx
index 698d28a..debe00f 100644
--- a/src/views/Authorization/Signup.jsx
+++ b/src/views/Authorization/Signup.jsx
@@ -58,11 +58,9 @@ const SignupView = () => {
return
}
- // The "how did you hear about us" step (/heard-about) is
- // temporarily skipped; new accounts go straight to circle setup.
- // Re-enable by navigating to '/heard-about' again — that view
- // already forwards to '/circle-setup' when done.
- Navigate('/circle-setup', { replace: true })
+ // "How did you hear about us" (cloud) / privacy preferences (self-hosted)
+ // that view forwards to '/circle-setup' once the user answers.
+ Navigate('/heard-about', { replace: true })
}
const handleSignUpValidation = () => {
// Reset errors before validation
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/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index 7d893e8..bcd5d19 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -45,6 +45,7 @@ import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import LoadingComponent from '../components/Loading'
+import PolicyUpdatePrompt from '../components/PolicyUpdatePrompt.jsx'
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -353,6 +354,7 @@ const MyChores = () => {
membersData?.res &&
choresData?.res
) {
+ // throw new Error('FAKE ERROR') // For testing Sentry error tracking
const processEffectAsync = async () => {
// Sync local state with query data to ensure updates are reflected
setChores(processedChores)
@@ -1513,6 +1515,7 @@ const MyChores = () => {
/>
+
{addTaskModalOpen && (
{
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/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/Modals/PolicyUpdateModal.jsx b/src/views/Modals/PolicyUpdateModal.jsx
new file mode 100644
index 0000000..6afdd9b
--- /dev/null
+++ b/src/views/Modals/PolicyUpdateModal.jsx
@@ -0,0 +1,81 @@
+import { ChevronRight, Gavel, PrivacyTip } from '@mui/icons-material'
+import { Button, Stack } from '@mui/joy'
+import { useTranslation } from 'react-i18next'
+import { useNavigate } from 'react-router-dom'
+
+import ModalActions from '../../components/common/ModalActions.jsx'
+import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
+
+/**
+ * One-time notice that the Privacy Policy and Terms changed. The frame is
+ * generic and always points at the documents, so a future revision only needs
+ * POLICY_VERSION bumped.
+ */
+const PolicyUpdateModal = ({ onAcknowledge, onClose, open }) => {
+ const { t } = useTranslation()
+ const { ResponsiveModal } = useResponsiveModal()
+ const navigate = useNavigate()
+
+ const handleClose = () => {
+ onAcknowledge?.()
+ onClose()
+ }
+
+ const openDocument = path => {
+ handleClose()
+ navigate(path)
+ }
+
+ const documentButtonSx = {
+ justifyContent: 'flex-start',
+ fontWeight: 500,
+ '--Button-gap': '12px',
+ '& .MuiButton-endDecorator': { ml: 'auto' },
+ }
+
+ return (
+
+ }
+ >
+
+ }
+ endDecorator={}
+ onClick={() => openDocument('/privacy')}
+ sx={documentButtonSx}
+ >
+ {t('policyUpdate.readPrivacy')}
+
+ }
+ endDecorator={}
+ onClick={() => openDocument('/terms')}
+ sx={documentButtonSx}
+ >
+ {t('policyUpdate.readTerms')}
+
+
+
+ )
+}
+
+export default PolicyUpdateModal
diff --git a/src/views/Onboarding/HeardAboutView.jsx b/src/views/Onboarding/HeardAboutView.jsx
index a1208b8..3d794db 100644
--- a/src/views/Onboarding/HeardAboutView.jsx
+++ b/src/views/Onboarding/HeardAboutView.jsx
@@ -2,6 +2,8 @@ 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 { track } from '../../analytics'
import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
import {
haptic,
@@ -61,7 +63,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
@@ -78,7 +80,13 @@ const AcquisitionSurvey = ({ onDone }) => {
const finish = () => {
const answer = selected === OTHER ? detail.trim() || null : selected
- if (answer) recordAcquisitionSource(answer)
+ if (answer) {
+ recordAcquisitionSource(answer)
+ track('onboarding_option_selected', {
+ step: 'heard_about',
+ option: answer,
+ })
+ }
onDone()
}
@@ -228,8 +236,8 @@ const PrivacyPreferences = ({ onDone }) => {
const values = { crashReports, analytics }
- const finish = () => {
- recordPrivacyPreferences(values)
+ const finish = async () => {
+ await recordPrivacyPreferences(values)
onDone()
}
@@ -264,7 +272,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..97fb4f0 100644
--- a/src/views/Onboarding/WorkspaceReadyView.jsx
+++ b/src/views/Onboarding/WorkspaceReadyView.jsx
@@ -4,10 +4,13 @@ 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'
import { authButtonSx } from '../Authorization/authStyles'
+import { isOfficialDonetickInstance } from '../../utils/FeatureToggle'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
@@ -37,7 +40,8 @@ const WorkspaceReadyView = () => {
* and a missing offering or a store hiccup must never trap a new user here.
*/
const showPaywall = async () => {
- if (!Capacitor.isNativePlatform() || !userProfile?.id) return
+ const isDonetickDotCom = await isOfficialDonetickInstance()
+ if (!isDonetickDotCom || !Capacitor.isNativePlatform() || !userProfile?.id) return
const { Purchases } = await import('@revenuecat/purchases-capacitor')
const { RevenueCatUI } = await import('@revenuecat/purchases-capacitor-ui')
@@ -70,6 +74,7 @@ const WorkspaceReadyView = () => {
console.log('Paywall skipped:', error)
} finally {
setBusy(false)
+ track('onboarding_completed')
enterApp()
}
}
diff --git a/src/views/PrivacyPolicy/PrivacyPolicyView.jsx b/src/views/PrivacyPolicy/PrivacyPolicyView.jsx
index b818ee7..1724f18 100644
--- a/src/views/PrivacyPolicy/PrivacyPolicyView.jsx
+++ b/src/views/PrivacyPolicy/PrivacyPolicyView.jsx
@@ -1,97 +1,144 @@
const PrivacyPolicyView = () => {
return (
-
+
Privacy Policy
- Favoro LLC ("we," "us," or "our") operates the Donetick application and
- website (collectively, the "Service"). This Privacy Policy informs you
- of our policies regarding the collection, use, and disclosure of
- personal data when you use our Service and the choices you have
- associated with that data.
-
-
Information We Collect
-
- Personal Data: When you register for an account or use
- the Service, we may collect certain personally identifiable information,
- such as your name and email address.
+ How Donetick handles your data
- Usage Data: We collect information on how you use the
- Service, such as your IP address, browser type, pages visited, and the
- time and date of your visit.
+ Last updated August 12, 2026
+
+
+
+ Donetick is made by Favoro LLC ("we", "us", "our"). This policy covers
+ the Donetick Cloud Service and our website. If you run Donetick
+ yourself, your data lives on your own server and you control it. Only
+ the analytics section below applies, and only if you opt in.
+
+
+
1. What we collect
+
+ • Account info: your email, your name, and the circle
+ (household or group) you belong to.
- Task Data: We store the tasks and chores you create
- within the app, including their details and any assigned users.
-
-
How We Use Your Information
-
- Provide and Maintain the Service: We use your
- information to operate, maintain, and improve the Service.
+ • Your content: the tasks, chores, labels, notes, and
+ schedules you create.
- Communicate with You: We may use your email address to
- send you notifications, updates, and promotional materials related to
- the Service.
+ • Usage data: basic, privacy-respecting information
+ about how the app is used so we can keep it working and improve it.
- Analyze Usage: We analyze usage data to understand how
- the Service is used and to make improvements.
+ • Billing: if you upgrade, payment is handled by
+ Stripe. We never see or store your full card details.
-
How We Share Your Information
+
+
2. How we use it
- With Your Consent: We will not share your personal data
- with third parties without your consent, except as described in this
- Privacy Policy.
+ To run the core product: store and sync your tasks across your devices,
+ remind you when things are due, share chores with your circle, and
+ provide support. We use your content to operate the Service for you, not
+ to build advertising profiles, and never to train AI models.
+
+
+
3. Analytics and error reports
+
+ We use{' '}
+
+ PostHog
+ {' '}
+ to see which features are used and to catch crashes. Every event is
+ checked against a fixed list of allowed properties before it is sent, so
+ only this can leave your device: feature usage (for example, that a task
+ was created and whether it had a due date), technical details about your
+ installation (platform, OS version, app version, Cloud or self-hosted),
+ whether your account is on a paid plan and how many people are in your
+ circle, and, for errors, status codes and stack traces.
- Service Providers: We may engage third-party companies
- or individuals to perform services on our behalf (e.g., hosting,
- analytics). These third parties have access to your personal data only
- to perform these tasks and are obligated not to disclose or use it for
- any other purpose.
+ We never send the content of your tasks, chores, notes, search queries,
+ circle names, or label names. We don't use session recording or
+ automatic click capture.
- Compliance with Law: We may disclose your personal data
- if required to do so by law or in response to valid requests by public
- authorities (e.g., a court or government agency).
+ For our public website we also use{' '}
+
+ Plausible
+
+ , a privacy-friendly analytics tool that counts page views without
+ cookies, without cross-site tracking, and without building a profile of
+ you.
-
Security
+
+
4. Your analytics choices
- We value your privacy and have implemented reasonable security measures
- to protect your personal data from unauthorized access, disclosure,
- alteration, or destruction. However, no method of transmission over the
- Internet or electronic storage is 100% secure, and we cannot guarantee
- absolute security.
+ Product analytics and crash reporting are two separate switches under{' '}
+ Settings → Privacy & Analytics. On the Cloud
+ Service they're on by default and you can turn them off at any time. On
+ self-hosted installations they're off by default and require you to opt
+ in. Turning one off stops new data of that type immediately.
-
Your Choices
+
+
5. Where it lives
- Account Information: You can update or correct your
- account information at any time.
+ Cloud Service data is stored on our hosted infrastructure, scoped so
+ that only you and the circle you share with can access it. Files you
+ upload, like task photos and attachments, are kept in secure cloud
+ object storage provided by Cloudflare, and are served over private,
+ expiring links rather than public URLs. We use reasonable safeguards to
+ protect all of it, though no system on the internet is perfectly secure.
+
+
6. Sharing
- Marketing Communications: You can opt out of receiving
- promotional emails by following the unsubscribe instructions included in
- those emails.
+ We don't sell your data. We share it only with the service providers
+ that make Donetick work (Cloudflare for hosting and file storage,
+ PostHog and Plausible for analytics, Stripe for payments), or when
+ required by law.
-
Children's Privacy
+
+
7. Your choices
- Our Service is not intended for children under 13 years of age. We do
- not knowingly collect personal data from children under 13. If you are a
- parent or guardian and you are aware that your child has provided us
- with personal data, please contact us.
+ You can update your profile and permanently delete your account at any
+ time from Settings. Deleting your account removes your
+ content from our systems. You can also opt out of promotional emails
+ using the unsubscribe link in any of them.
-
Changes to This Privacy Policy
+
+
8. Retention
- We may update our Privacy Policy from time to time. We will notify you
- of any changes by posting the new Privacy Policy on this page and
- updating the "Effective Date" at the top of this Privacy Policy.
+ We keep your data while your account is active. When you delete your
+ account, we delete your content (backups age out on a rolling basis).
-
Contact Us
+
+
9. Children
- If you have any questions about this Privacy Policy, please contact us
- at:
+ Donetick isn't directed to children under 13, and we don't knowingly
+ collect their data. Child accounts created by a parent inside a circle
+ are managed by that parent.
+
+
10. Changes
+
+ We'll post any updates here with a new date, and flag material changes
+ with a one-time notice inside the app.
+