Merge pull request #218 from donetick/analytics
Integrate PostHog and Plausible for enhanced analytics and privacy management
This commit is contained in:
22
index.html
22
index.html
@@ -16,6 +16,28 @@
|
||||
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
|
||||
<meta name="msapplication-TileColor" content="#da532c" />
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
|
||||
<!--
|
||||
Plausible. This index.html is shared by the app and the marketing bundles
|
||||
(see src/main.jsx), so the tag is gated rather than hardcoded: it loads
|
||||
only when the page host matches VITE_APP_PLAUSIBLE_DOMAIN, which is the
|
||||
site registered in Plausible. On app.donetick.com the host never matches,
|
||||
so nothing loads. Unset the variable to disable it entirely.
|
||||
-->
|
||||
<script>
|
||||
;(function () {
|
||||
var domain = 'donetick.com'
|
||||
// Vite leaves the literal placeholder when the variable is unset.
|
||||
var host = window.location.hostname
|
||||
if (host !== domain) return
|
||||
var s = document.createElement('script')
|
||||
s.defer = true
|
||||
// s.setAttribute('data-domain', domain)
|
||||
s.setAttribute('data-domain', 'donetick.com')
|
||||
s.src = 'https://collector.wannaknow.link/js/script.js'
|
||||
document.head.appendChild(s)
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
121
package-lock.json
generated
121
package-lock.json
generated
@@ -64,6 +64,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"moment": "^2.30.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"posthog-js": "^1.415.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
@@ -2322,8 +2323,6 @@
|
||||
},
|
||||
"node_modules/@emotion/cache": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz",
|
||||
"integrity": "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@emotion/memoize": "^0.9.0",
|
||||
@@ -4586,6 +4585,31 @@
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz",
|
||||
"integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.47.0",
|
||||
"@posthog/types": "^1.402.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.47.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
|
||||
"integrity": "sha512-LW62V+9yx7G7mLd+EYpwW0PiaPZI8XRJ1tLuhw+9fXHpugLp8XQD5JuCQ2kIXvoUfyx1DpKfGQY3dUnzMOIn7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.402.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.402.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz",
|
||||
"integrity": "sha512-nnqKIGUqggeNbCZg6of/hYN+4shYZUoPFkILIIpYq0p9HDX8PgOrnrtBAUH8kr1MOmDh2nm+fY5Ceks7A5y6BQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prettier/plugin-xml": {
|
||||
"version": "2.2.0",
|
||||
"dev": true,
|
||||
@@ -7391,6 +7415,20 @@
|
||||
"version": "1.9.0",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/core-js-compat": {
|
||||
"version": "3.45.1",
|
||||
"license": "MIT",
|
||||
@@ -7932,6 +7970,15 @@
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.13",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz",
|
||||
"integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
@@ -12018,6 +12065,48 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.415.5",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.415.5.tgz",
|
||||
"integrity": "sha512-IUmPFtZjwDZYnRmttl+Xo5f/zDVdMdfWdZFxHqtAqq4wMwElr4dIpXmEUzh4nO8NtWT3zBmrL9+c7zZ3avzJZw==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.5.0",
|
||||
"@posthog/core": "^1.47.0",
|
||||
"@posthog/types": "^1.402.3",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.3",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.3.0",
|
||||
"web-vitals-soft-navs": "npm:web-vitals@6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js/node_modules/fflate": {
|
||||
"version": "0.4.9",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
|
||||
"integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.8",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
|
||||
"integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"devOptional": true,
|
||||
@@ -12402,6 +12491,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/query-selector-shadow-dom": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
|
||||
"integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-microtask": {
|
||||
"version": "1.2.3",
|
||||
"dev": true,
|
||||
@@ -12466,15 +12561,6 @@
|
||||
"regenerator-runtime": "^0.13.9"
|
||||
}
|
||||
},
|
||||
"node_modules/quilljs-markdown/node_modules/core-js": {
|
||||
"version": "3.45.1",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/raf-schd": {
|
||||
"version": "4.0.3",
|
||||
"license": "MIT"
|
||||
@@ -15262,6 +15348,19 @@
|
||||
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/web-vitals-soft-navs": {
|
||||
"name": "web-vitals",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
|
||||
"integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"license": "BSD-2-Clause"
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"js-cookie": "^3.0.5",
|
||||
"moment": "^2.30.1",
|
||||
"murmurhash": "^2.0.1",
|
||||
"posthog-js": "^1.415.5",
|
||||
"prop-types": "^15.8.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"quill": "^2.0.3",
|
||||
|
||||
@@ -70,6 +70,13 @@
|
||||
"typeToSearch": "Type to search"
|
||||
}
|
||||
},
|
||||
"policyUpdate": {
|
||||
"title": "We've updated our Privacy Policy and Terms",
|
||||
"subtitle": "Please take a moment to review them. By continuing to use Donetick, you accept the updated documents.",
|
||||
"readPrivacy": "Privacy Policy",
|
||||
"readTerms": "Terms",
|
||||
"acknowledge": "Got it"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
|
||||
@@ -78,6 +78,10 @@
|
||||
"title": "Advanced Settings",
|
||||
"description": "Configure webhooks, real-time updates, and other advanced features for enhanced productivity."
|
||||
},
|
||||
"privacy": {
|
||||
"title": "Privacy & Analytics",
|
||||
"description": "Control anonymous usage analytics and crash reporting."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Developer Settings",
|
||||
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
||||
@@ -491,6 +495,15 @@
|
||||
"basicPlanNotice": "Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when you or other circle members complete, skip, or modify tasks."
|
||||
}
|
||||
},
|
||||
"privacyAnalytics": {
|
||||
"title": "Privacy & Analytics",
|
||||
"description": "Donetick never sends task names, descriptions, notes, or other task content. Session recording is never enabled.",
|
||||
"analyticsToggle": "Anonymous analytics",
|
||||
"analyticsHelper": "Help improve Donetick by sharing anonymous usage statistics.",
|
||||
"crashToggle": "Crash reports",
|
||||
"crashHelper": "Send us the error and what led to it when Donetick crashes no task content.",
|
||||
"footnote": "You can change these choices at any time."
|
||||
},
|
||||
"subscription": {
|
||||
"sessionFailed": "Failed to create subscription session",
|
||||
"errorTitle": "Subscription Error",
|
||||
|
||||
11
src/App.jsx
11
src/App.jsx
@@ -7,6 +7,11 @@ import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
|
||||
import {
|
||||
initialize as initializeAnalytics,
|
||||
installGlobalErrorHandlers,
|
||||
} from './analytics'
|
||||
import useAnalyticsIdentity from './analytics/useAnalyticsIdentity'
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
@@ -36,6 +41,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 +148,11 @@ function App() {
|
||||
registerCapacitorListeners(navigate)
|
||||
}, [navigate])
|
||||
|
||||
useEffect(() => {
|
||||
initializeAnalytics()
|
||||
installGlobalErrorHandlers()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<NetworkBanner />
|
||||
|
||||
@@ -23,6 +23,9 @@ const AppRedirect = () => {
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <Landing /> },
|
||||
// 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: <Landing /> },
|
||||
{ path: '/privacy', element: <PrivacyPolicyView /> },
|
||||
{ path: '/terms', element: <TermsView /> },
|
||||
{ path: '*', element: <AppRedirect /> },
|
||||
@@ -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 = () => (
|
||||
<ThemeContext>
|
||||
<ThemeClass />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocalizationProvider>
|
||||
<LocalizationProvider>
|
||||
<ThemeContext>
|
||||
<ThemeClass />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ImpersonateUserProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ImpersonateUserProvider>
|
||||
</LocalizationProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeContext>
|
||||
</QueryClientProvider>
|
||||
</ThemeContext>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
|
||||
export default MarketingApp
|
||||
|
||||
107
src/analytics/consent.js
Normal file
107
src/analytics/consent.js
Normal 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),
|
||||
])
|
||||
}
|
||||
165
src/analytics/eventSchemas.js
Normal file
165
src/analytics/eventSchemas.js
Normal file
@@ -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)
|
||||
261
src/analytics/index.js
Normal file
261
src/analytics/index.js
Normal file
@@ -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
|
||||
}
|
||||
35
src/analytics/posthogClient.js
Normal file
35
src/analytics/posthogClient.js
Normal 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
|
||||
28
src/analytics/useAnalyticsIdentity.js
Normal file
28
src/analytics/useAnalyticsIdentity.js
Normal 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
|
||||
13
src/constants/policyUpdates.js
Normal file
13
src/constants/policyUpdates.js
Normal file
@@ -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'
|
||||
@@ -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 },
|
||||
]
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
90
src/service/PolicyUpdateService.js
Normal file
90
src/service/PolicyUpdateService.js
Normal file
@@ -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 })
|
||||
@@ -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
27
src/utils/DeviceInfo.js
Normal 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' }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</Box>
|
||||
<NotificationAccessSnackbar />
|
||||
<PolicyUpdatePrompt />
|
||||
<FeedbackPrompt />
|
||||
{addTaskModalOpen && (
|
||||
<TaskInput
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import {
|
||||
CreateFilter,
|
||||
DeleteFilter,
|
||||
@@ -112,6 +114,7 @@ export const useCreateFilter = () => {
|
||||
const response = await CreateFilter(filterData)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
track('filter_created', {})
|
||||
return data.res || data
|
||||
}
|
||||
const errorData = await response.json()
|
||||
@@ -142,7 +145,7 @@ export const useUpdateFilter = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ filterId, filterData }) => {
|
||||
mutationFn: async ({ filterData, filterId }) => {
|
||||
try {
|
||||
const response = await UpdateFilter(filterId, filterData)
|
||||
if (response.ok) {
|
||||
|
||||
@@ -1,4 +1,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')}
|
||||
|
||||
81
src/views/Modals/PolicyUpdateModal.jsx
Normal file
81
src/views/Modals/PolicyUpdateModal.jsx
Normal file
@@ -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 (
|
||||
<ResponsiveModal
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
size='sm'
|
||||
title={t('policyUpdate.title')}
|
||||
description={t('policyUpdate.subtitle')}
|
||||
footer={
|
||||
<ModalActions
|
||||
primary={{
|
||||
label: t('policyUpdate.acknowledge'),
|
||||
onClick: handleClose,
|
||||
sx: { width: { xs: '100%', sm: 'auto' } },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Stack spacing={1}>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
startDecorator={<PrivacyTip />}
|
||||
endDecorator={<ChevronRight />}
|
||||
onClick={() => openDocument('/privacy')}
|
||||
sx={documentButtonSx}
|
||||
>
|
||||
{t('policyUpdate.readPrivacy')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
fullWidth
|
||||
startDecorator={<Gavel />}
|
||||
endDecorator={<ChevronRight />}
|
||||
onClick={() => openDocument('/terms')}
|
||||
sx={documentButtonSx}
|
||||
>
|
||||
{t('policyUpdate.readTerms')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyUpdateModal
|
||||
@@ -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 }) => (
|
||||
<Box
|
||||
key={key}
|
||||
sx={{
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,144 @@
|
||||
const PrivacyPolicyView = () => {
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
padding: '20px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
<h1>Privacy Policy</h1>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<h2>Information We Collect</h2>
|
||||
<p>
|
||||
<strong>Personal Data:</strong> When you register for an account or use
|
||||
the Service, we may collect certain personally identifiable information,
|
||||
such as your name and email address.
|
||||
<em>How Donetick handles your data</em>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Usage Data:</strong> 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.
|
||||
<strong>Last updated August 12, 2026</strong>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>1. What we collect</h2>
|
||||
<p>
|
||||
• <strong>Account info:</strong> your email, your name, and the circle
|
||||
(household or group) you belong to.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Task Data:</strong> We store the tasks and chores you create
|
||||
within the app, including their details and any assigned users.
|
||||
</p>
|
||||
<h2>How We Use Your Information</h2>
|
||||
<p>
|
||||
<strong>Provide and Maintain the Service:</strong> We use your
|
||||
information to operate, maintain, and improve the Service.
|
||||
• <strong>Your content:</strong> the tasks, chores, labels, notes, and
|
||||
schedules you create.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Communicate with You:</strong> We may use your email address to
|
||||
send you notifications, updates, and promotional materials related to
|
||||
the Service.
|
||||
• <strong>Usage data:</strong> basic, privacy-respecting information
|
||||
about how the app is used so we can keep it working and improve it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Analyze Usage:</strong> We analyze usage data to understand how
|
||||
the Service is used and to make improvements.
|
||||
• <strong>Billing:</strong> if you upgrade, payment is handled by
|
||||
Stripe. We never see or store your full card details.
|
||||
</p>
|
||||
<h2>How We Share Your Information</h2>
|
||||
|
||||
<h2>2. How we use it</h2>
|
||||
<p>
|
||||
<strong>With Your Consent:</strong> 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.
|
||||
</p>
|
||||
|
||||
<h2>3. Analytics and error reports</h2>
|
||||
<p>
|
||||
We use{' '}
|
||||
<a href='https://posthog.com' target='_blank' rel='noreferrer'>
|
||||
PostHog
|
||||
</a>{' '}
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Service Providers:</strong> 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.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Compliance with Law:</strong> 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{' '}
|
||||
<a href='https://plausible.io' target='_blank' rel='noreferrer'>
|
||||
Plausible
|
||||
</a>
|
||||
, a privacy-friendly analytics tool that counts page views without
|
||||
cookies, without cross-site tracking, and without building a profile of
|
||||
you.
|
||||
</p>
|
||||
<h2>Security</h2>
|
||||
|
||||
<h2>4. Your analytics choices</h2>
|
||||
<p>
|
||||
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{' '}
|
||||
<strong>Settings → Privacy & Analytics</strong>. 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.
|
||||
</p>
|
||||
<h2>Your Choices</h2>
|
||||
|
||||
<h2>5. Where it lives</h2>
|
||||
<p>
|
||||
<strong>Account Information:</strong> 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.
|
||||
</p>
|
||||
|
||||
<h2>6. Sharing</h2>
|
||||
<p>
|
||||
<strong>Marketing Communications:</strong> 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.
|
||||
</p>
|
||||
<h2>Children's Privacy</h2>
|
||||
|
||||
<h2>7. Your choices</h2>
|
||||
<p>
|
||||
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 <strong>Settings</strong>. 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.
|
||||
</p>
|
||||
<h2>Changes to This Privacy Policy</h2>
|
||||
|
||||
<h2>8. Retention</h2>
|
||||
<p>
|
||||
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).
|
||||
</p>
|
||||
<h2>Contact Us</h2>
|
||||
|
||||
<h2>9. Children</h2>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h2>10. Changes</h2>
|
||||
<p>
|
||||
We'll post any updates here with a new date, and flag material changes
|
||||
with a one-time notice inside the app.
|
||||
</p>
|
||||
|
||||
<h2>11. Contact</h2>
|
||||
<p>
|
||||
Privacy questions or requests? Email{' '}
|
||||
<a href='mailto:support@donetick.com'>support@donetick.com</a>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
<p>Favoro LLC</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import {
|
||||
CreateProject,
|
||||
DeleteProject,
|
||||
@@ -43,6 +45,7 @@ export const useCreateProject = () => {
|
||||
const response = await CreateProject(projectData)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
track('project_created', {})
|
||||
return data.res || data
|
||||
}
|
||||
throw new Error('Failed to create project')
|
||||
@@ -79,7 +82,7 @@ export const useUpdateProject = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ projectId, projectData }) => {
|
||||
mutationFn: async ({ projectData, projectId }) => {
|
||||
try {
|
||||
const response = await UpdateProject(projectId, projectData)
|
||||
if (response.ok) {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Refresh, Star, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
setDevForcedPrompt,
|
||||
} from '../../service/FeedbackService'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { resetPolicyUpdate } from '../../service/PolicyUpdateService'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import { RefreshToken } from '../../utils/Fetcher'
|
||||
@@ -24,7 +27,7 @@ import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PolicyUpdateModal from '../Modals/PolicyUpdateModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
@@ -32,12 +35,12 @@ const DeveloperSettings = () => {
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
lastEvent,
|
||||
error: sseError,
|
||||
getConnectionStatus,
|
||||
getDebugInfo,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
lastEvent,
|
||||
} = useSSEContext()
|
||||
|
||||
const [accessTokenExpiry, setAccessTokenExpiry] = useState(null)
|
||||
@@ -55,6 +58,7 @@ const DeveloperSettings = () => {
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||
const [isResettingSync, setIsResettingSync] = useState(false)
|
||||
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
|
||||
const [policyModalOpen, setPolicyModalOpen] = useState(false)
|
||||
const [feedbackEligibility, setFeedbackEligibility] = useState(null)
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState({
|
||||
cursor: null,
|
||||
@@ -396,6 +400,15 @@ const DeveloperSettings = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleForcePolicyUpdate = async () => {
|
||||
await resetPolicyUpdate()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message:
|
||||
'Next visit to My Chores will show the policy notice after ~1.5s',
|
||||
})
|
||||
}
|
||||
|
||||
const handleResetFeedbackState = async () => {
|
||||
await resetFeedbackState()
|
||||
await refreshFeedbackEligibility()
|
||||
@@ -856,6 +869,35 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography level='title-lg'>Policy Update Notice</Typography>
|
||||
<Divider />
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Shown once per POLICY_VERSION to accounts created before the policy
|
||||
effective date. "Force Next Prompt" clears the
|
||||
acknowledgement, then open My Chores to see the automatic trigger.
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
onClick={() => setPolicyModalOpen(true)}
|
||||
>
|
||||
Open Notice
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={handleForcePolicyUpdate}
|
||||
>
|
||||
Force Next Prompt
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{isNativePlatform && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -1212,6 +1254,13 @@ const DeveloperSettings = () => {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Preview only — deliberately does not acknowledge, so opening it here
|
||||
never suppresses the real notice. */}
|
||||
<PolicyUpdateModal
|
||||
open={policyModalOpen}
|
||||
onClose={() => setPolicyModalOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
DATE_FORMATS,
|
||||
TIME_FORMATS,
|
||||
useLocalization,
|
||||
} from '@/contexts/LocalizationContext'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -16,21 +11,29 @@ import {
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import {
|
||||
DATE_FORMATS,
|
||||
TIME_FORMATS,
|
||||
useLocalization,
|
||||
} from '@/contexts/LocalizationContext'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const LocalizationSettings = () => {
|
||||
const { t } = useTranslation('settings')
|
||||
const {
|
||||
language,
|
||||
setLanguage,
|
||||
dateFormat,
|
||||
setDateFormat,
|
||||
timeFormat,
|
||||
setTimeFormat,
|
||||
firstDayOfWeek,
|
||||
setFirstDayOfWeek,
|
||||
availableLanguages,
|
||||
dateFormat,
|
||||
firstDayOfWeek,
|
||||
isRTL,
|
||||
language,
|
||||
setDateFormat,
|
||||
setFirstDayOfWeek,
|
||||
setLanguage,
|
||||
setTimeFormat,
|
||||
timeFormat,
|
||||
} = useLocalization()
|
||||
|
||||
const sampleDate = moment('2024-01-15 14:30:00')
|
||||
@@ -56,7 +59,13 @@ const LocalizationSettings = () => {
|
||||
<FormControl>
|
||||
<Select
|
||||
value={language}
|
||||
onChange={(_, value) => setLanguage(value)}
|
||||
onChange={(_, value) => {
|
||||
setLanguage(value)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'language',
|
||||
value,
|
||||
})
|
||||
}}
|
||||
sx={{ maxWidth: '300px' }}
|
||||
>
|
||||
{availableLanguages.map(lang => (
|
||||
@@ -83,7 +92,13 @@ const LocalizationSettings = () => {
|
||||
<FormControl>
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onChange={(_, value) => setDateFormat(value)}
|
||||
onChange={(_, value) => {
|
||||
setDateFormat(value)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'date_format',
|
||||
value,
|
||||
})
|
||||
}}
|
||||
sx={{ maxWidth: '300px' }}
|
||||
>
|
||||
{dateFormatOptions.map(option => (
|
||||
@@ -119,7 +134,13 @@ const LocalizationSettings = () => {
|
||||
<FormControl>
|
||||
<Select
|
||||
value={timeFormat}
|
||||
onChange={(_, value) => setTimeFormat(value)}
|
||||
onChange={(_, value) => {
|
||||
setTimeFormat(value)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'time_format',
|
||||
value,
|
||||
})
|
||||
}}
|
||||
sx={{ maxWidth: '300px' }}
|
||||
>
|
||||
<Option value={TIME_FORMATS.HOUR_12}>
|
||||
@@ -169,19 +190,37 @@ const LocalizationSettings = () => {
|
||||
<ButtonGroup variant='outlined'>
|
||||
<Button
|
||||
variant={firstDayOfWeek === 0 ? 'solid' : 'outlined'}
|
||||
onClick={() => setFirstDayOfWeek(0)}
|
||||
onClick={() => {
|
||||
setFirstDayOfWeek(0)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'first_day_of_week',
|
||||
value: 'sunday',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('localization.sunday')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={firstDayOfWeek === 1 ? 'solid' : 'outlined'}
|
||||
onClick={() => setFirstDayOfWeek(1)}
|
||||
onClick={() => {
|
||||
setFirstDayOfWeek(1)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'first_day_of_week',
|
||||
value: 'monday',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('localization.monday')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={firstDayOfWeek === 6 ? 'solid' : 'outlined'}
|
||||
onClick={() => setFirstDayOfWeek(6)}
|
||||
onClick={() => {
|
||||
setFirstDayOfWeek(6)
|
||||
track('localization_setting_changed', {
|
||||
setting: 'first_day_of_week',
|
||||
value: 'saturday',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{t('localization.saturday')}
|
||||
</Button>
|
||||
|
||||
86
src/views/Settings/PrivacyAnalyticsSettings.jsx
Normal file
86
src/views/Settings/PrivacyAnalyticsSettings.jsx
Normal 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
|
||||
@@ -2,247 +2,241 @@ import React from 'react'
|
||||
|
||||
const TermsView = () => {
|
||||
return (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px', lineHeight: '1.6' }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
padding: '20px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
>
|
||||
<h1>Terms of Service</h1>
|
||||
<p><em>Effective Date: January 1, 2024</em></p>
|
||||
|
||||
<p>
|
||||
These Terms of Service ("Terms") govern your access to and use of the
|
||||
Donetick task management platform provided by Favoro LLC ("Donetick", "we", "us", or "our").
|
||||
By accessing or using our services, you agree to be bound by these Terms. If you do not agree to
|
||||
these Terms, you may not access or use our services.
|
||||
<strong>Last updated August 12, 2026</strong>
|
||||
</p>
|
||||
|
||||
<div style={{ backgroundColor: '#f5f5f5', padding: '15px', borderRadius: '5px', margin: '20px 0' }}>
|
||||
<h3>Important: Cloud vs. Self-Hosted Services</h3>
|
||||
<p>
|
||||
Donetick is available in two deployment models with different terms and responsibilities:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Cloud-Hosted Service:</strong> Hosted at donetick.com where we provide infrastructure and data management</li>
|
||||
<li><strong>Self-Hosted Service:</strong> Open-source software you deploy on your own infrastructure</li>
|
||||
</ul>
|
||||
<p>Please review the applicable sections carefully as they define different rights and obligations.</p>
|
||||
</div>
|
||||
|
||||
<h2>1. Definitions</h2>
|
||||
<ul>
|
||||
<li><strong>"Cloud Service"</strong> refers to the Donetick platform hosted at donetick.com and managed by Favoro LLC</li>
|
||||
<li><strong>"Self-Hosted Service"</strong> refers to the open-source Donetick software deployed on user-controlled infrastructure</li>
|
||||
<li><strong>"Content"</strong> means all data, information, tasks, files, and other materials you create, upload, or store using our services</li>
|
||||
<li><strong>"Circle"</strong> means a collaborative workspace shared between users for task management</li>
|
||||
</ul>
|
||||
|
||||
<h2>2. Eligibility and Account Registration</h2>
|
||||
<ul>
|
||||
<li>You must be at least 13 years old to use our services</li>
|
||||
<li>Users under 18 must have parental consent</li>
|
||||
<li>You are responsible for maintaining the confidentiality of your account credentials</li>
|
||||
<li>You must provide accurate and complete information when creating an account</li>
|
||||
<li>One person or legal entity may maintain only one account</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Cloud-Hosted Service Terms</h2>
|
||||
<p><em>This section applies only to users of the Cloud Service at donetick.com</em></p>
|
||||
|
||||
<h3>3.1 Service Availability and Uptime</h3>
|
||||
<ul>
|
||||
<li>We strive to maintain 99.5% uptime for the Cloud Service on a monthly basis</li>
|
||||
<li>Scheduled maintenance will be announced at least 24 hours in advance when possible</li>
|
||||
<li>We reserve the right to temporarily suspend service for emergency maintenance</li>
|
||||
<li>No SLA credits or compensation are provided for downtime unless otherwise specified in a separate agreement</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.2 Data Ownership and Responsibility</h3>
|
||||
<ul>
|
||||
<li><strong>Your Data:</strong> You retain ownership of all Content you create or upload</li>
|
||||
<li><strong>Our Responsibility:</strong> We provide secure hosting, backup, and infrastructure management</li>
|
||||
<li><strong>Data Portability:</strong> You can export your data at any time through our export features</li>
|
||||
<li><strong>Data Retention:</strong> We retain your data for 90 days after account deletion to allow recovery</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.3 Subscriptions and Billing</h3>
|
||||
<ul>
|
||||
<li>Cloud Service subscriptions are billed monthly or annually in advance</li>
|
||||
<li>Subscription fees will automatically renew unless cancelled before the next billing cycle</li>
|
||||
<li>You may cancel your subscription at any time through your account settings</li>
|
||||
<li>Upon cancellation, you retain access until the end of your current billing period</li>
|
||||
<li>We may modify subscription prices with 30 days advance notice</li>
|
||||
<li>Refunds are considered on a case-by-case basis at our discretion</li>
|
||||
</ul>
|
||||
|
||||
<h3>3.4 Cloud Service Limitations</h3>
|
||||
<ul>
|
||||
<li>Storage limits apply based on your subscription tier</li>
|
||||
<li>API rate limits may be enforced to ensure service stability</li>
|
||||
<li>We may suspend accounts that exceed reasonable usage limits</li>
|
||||
</ul>
|
||||
|
||||
<h2>4. Self-Hosted Service Terms</h2>
|
||||
<p><em>This section applies to users deploying Donetick on their own infrastructure</em></p>
|
||||
|
||||
<h3>4.1 Open Source License</h3>
|
||||
<ul>
|
||||
<li>The Self-Hosted Service is provided under the MIT License</li>
|
||||
<li>You may modify, distribute, and use the software for any purpose</li>
|
||||
<li>Attribution to Donetick must be maintained in derivative works</li>
|
||||
<li>No warranty or support is provided for self-hosted deployments</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.2 User Responsibility</h3>
|
||||
<ul>
|
||||
<li><strong>Infrastructure:</strong> You are solely responsible for hosting, maintenance, security, and backups</li>
|
||||
<li><strong>Data Protection:</strong> You are the data controller and responsible for compliance with applicable laws</li>
|
||||
<li><strong>Updates:</strong> You are responsible for applying security updates and patches</li>
|
||||
<li><strong>Support:</strong> No technical support is provided for self-hosted installations</li>
|
||||
</ul>
|
||||
|
||||
<h3>4.3 Limitation of Our Responsibility</h3>
|
||||
<ul>
|
||||
<li>We provide no guarantees about the performance or security of self-hosted deployments</li>
|
||||
<li>We are not responsible for any data loss, security breaches, or service interruptions in self-hosted environments</li>
|
||||
<li>Technical support is limited to community forums and documentation</li>
|
||||
</ul>
|
||||
|
||||
<h2>5. Acceptable Use Policy</h2>
|
||||
<h2>1. Agreement</h2>
|
||||
<p>
|
||||
<em>Applies to both Cloud and Self-Hosted Services</em>
|
||||
By creating an account or using Donetick Cloud at donetick.com (the
|
||||
"Service"), you agree to these Terms. If you don't agree, don't use the
|
||||
Service. Donetick is made by Favoro LLC ("we", "us", "our").
|
||||
</p>
|
||||
|
||||
<h3>You may not use our services to:</h3>
|
||||
<ul>
|
||||
<li>Violate any applicable laws or regulations</li>
|
||||
<li>Infringe on intellectual property rights</li>
|
||||
<li>Transmit malicious code or conduct security attacks</li>
|
||||
<li>Harass, abuse, or harm other users</li>
|
||||
<li>Distribute spam or unwanted communications</li>
|
||||
<li>Attempt to reverse engineer our proprietary systems (Cloud Service)</li>
|
||||
<li>Resell or redistribute the service without permission</li>
|
||||
</ul>
|
||||
|
||||
<h2>6. Content and Data</h2>
|
||||
|
||||
<h3>6.1 Your Content Rights</h3>
|
||||
<ul>
|
||||
<li>You retain ownership of all Content you create</li>
|
||||
<li>You grant us the necessary rights to operate the service (Cloud Service only)</li>
|
||||
<li>You are responsible for ensuring you have rights to any Content you upload</li>
|
||||
</ul>
|
||||
|
||||
<h3>6.2 Content Restrictions</h3>
|
||||
<ul>
|
||||
<li>Content must not violate any laws or third-party rights</li>
|
||||
<li>Content must not contain malicious code or harmful materials</li>
|
||||
<li>We may remove Content that violates these Terms (Cloud Service only)</li>
|
||||
</ul>
|
||||
|
||||
<h2>7. Privacy and Security</h2>
|
||||
<ul>
|
||||
<li>Your privacy is important to us - please review our Privacy Policy</li>
|
||||
<li>We implement industry-standard security measures (Cloud Service)</li>
|
||||
<li>You are responsible for security in self-hosted deployments</li>
|
||||
<li>Report security vulnerabilities to security@donetick.com</li>
|
||||
</ul>
|
||||
|
||||
<h2>8. Intellectual Property</h2>
|
||||
<ul>
|
||||
<li>The Donetick name, logo, and proprietary features are our intellectual property</li>
|
||||
<li>The open-source codebase is licensed under MIT License</li>
|
||||
<li>You may not use our trademarks without written permission</li>
|
||||
</ul>
|
||||
|
||||
<h2>9. Third-Party Integrations</h2>
|
||||
<ul>
|
||||
<li>Donetick integrates with third-party services (Telegram, Discord, webhooks, etc.)</li>
|
||||
<li>Your use of these integrations is subject to their respective terms</li>
|
||||
<li>We are not responsible for third-party service availability or functionality</li>
|
||||
</ul>
|
||||
|
||||
<h2>10. Liability and Warranties</h2>
|
||||
|
||||
<h3>10.1 Disclaimer of Warranties</h3>
|
||||
<h2>2. Cloud vs. self-hosted</h2>
|
||||
<p>Donetick comes in two flavors, and they're governed differently.</p>
|
||||
<p>
|
||||
Our services are provided "as is" and "as available" without any warranty of any kind,
|
||||
express or implied, including but not limited to merchantability, fitness for a particular purpose,
|
||||
or non-infringement.
|
||||
<strong>Donetick Cloud</strong> is the hosted service we run at
|
||||
donetick.com. These Terms cover it.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Self-hosted Donetick</strong> is the open-source software you
|
||||
run on your own server. It's licensed under the GNU Affero General
|
||||
Public License v3 (AGPLv3), and that license, not these Terms, governs
|
||||
your rights to use, modify, and share it. Nothing here is meant to
|
||||
restrict anything the AGPLv3 grants you.
|
||||
</p>
|
||||
<p>
|
||||
If you run Donetick yourself, hosting, security, backups, updates, and
|
||||
data-protection obligations are yours, not ours. We don't provide
|
||||
support guarantees for self-hosted installs unless we've agreed to that
|
||||
separately in writing.
|
||||
</p>
|
||||
|
||||
<h3>10.2 Cloud Service Liability</h3>
|
||||
<ul>
|
||||
<li>Our total liability for the Cloud Service is limited to the amount you paid in the 12 months preceding the claim</li>
|
||||
<li>We are not liable for indirect, incidental, special, or consequential damages</li>
|
||||
<li>We maintain appropriate insurance and implement security best practices</li>
|
||||
</ul>
|
||||
|
||||
<h3>10.3 Self-Hosted Service Liability</h3>
|
||||
<ul>
|
||||
<li>We provide no warranties or guarantees for self-hosted deployments</li>
|
||||
<li>Our liability is limited to the maximum extent permitted by law</li>
|
||||
<li>You assume all risks associated with self-hosting</li>
|
||||
</ul>
|
||||
|
||||
<h2>11. Indemnification</h2>
|
||||
<h2>3. What Donetick does</h2>
|
||||
<p>
|
||||
You agree to indemnify and hold us harmless from any claims, damages, or expenses
|
||||
arising from your use of our services, violation of these Terms, or infringement of any rights.
|
||||
Donetick helps you and the people in your circle track tasks, chores,
|
||||
and recurring schedules. It's a productivity tool, not professional,
|
||||
legal, financial, or medical advice.
|
||||
</p>
|
||||
|
||||
<h2>4. Your account</h2>
|
||||
<p>
|
||||
You're responsible for your account and for keeping your login secure.
|
||||
You must be at least 13 years old (or the age of digital consent where
|
||||
you live) to use the Service. Provide accurate information, and tell us
|
||||
promptly if you think someone else has gotten into your account.
|
||||
</p>
|
||||
|
||||
<h2>5. Acceptable use</h2>
|
||||
<p>
|
||||
Don't use Donetick to break the law, infringe anyone's rights, upload
|
||||
malware, harass other users, send spam, gain unauthorized access to our
|
||||
systems or anyone's account, circumvent usage limits, or overload the
|
||||
Service.
|
||||
</p>
|
||||
<p>
|
||||
Don't use Donetick Cloud to run a competing hosted service without our
|
||||
written permission. This applies only to our Cloud Service and takes
|
||||
nothing away from your AGPLv3 rights to the self-hosted software.
|
||||
</p>
|
||||
|
||||
<h2>6. Your content</h2>
|
||||
<p>
|
||||
You own everything you put into Donetick: your tasks, chores, labels,
|
||||
comments, and files. You grant us a limited license to host, store,
|
||||
process, and transmit it solely to operate, secure, troubleshoot, and
|
||||
improve the Service for you. We don't sell your content, and we don't
|
||||
use it to train AI models.
|
||||
</p>
|
||||
<p>
|
||||
You're responsible for having the right to upload what you upload, and
|
||||
for managing who you share circles and content with. We may remove
|
||||
content when reasonably necessary to enforce these Terms, comply with
|
||||
the law, or address a security issue, and we'll give you notice first
|
||||
where that's practical.
|
||||
</p>
|
||||
<p>
|
||||
How we handle personal information is covered in our{' '}
|
||||
<a href='/privacy'>Privacy Policy</a>, including what our analytics and
|
||||
error reporting do and don't collect, and how to turn them off.
|
||||
</p>
|
||||
|
||||
<h2>7. Availability</h2>
|
||||
<p>
|
||||
We aim for roughly 99% monthly availability on the Cloud Service, but
|
||||
that's a goal, not a service-level agreement, and there are no downtime
|
||||
credits. We may pause parts of the Service for maintenance, security, or
|
||||
emergencies, and we'll announce planned work ahead of time when we
|
||||
reasonably can. Storage limits, API rate limits, and fair-use limits may
|
||||
apply.
|
||||
</p>
|
||||
|
||||
<h2>8. Plans and billing</h2>
|
||||
<p>
|
||||
Donetick offers a free plan and paid plans billed monthly or annually in
|
||||
advance through Stripe. Paid plans renew automatically until you cancel.
|
||||
</p>
|
||||
<p>
|
||||
You can cancel any time from your settings. Cancelling stops future
|
||||
renewals, and you keep paid features through the end of the current
|
||||
billing period. If a payment fails we may retry it or limit paid
|
||||
features until it goes through. We'll give you reasonable notice before
|
||||
a price change applies to your subscription. Refunds are handled
|
||||
case-by-case at our discretion, except where the law requires one.
|
||||
Nothing here limits your consumer-protection rights.
|
||||
</p>
|
||||
|
||||
<h2>9. Your data and export</h2>
|
||||
<p>
|
||||
Where export tools are available, you can export your content; formats
|
||||
and availability may change over time. If we ever discontinue the Cloud
|
||||
Service, we'll give reasonable advance notice and, where possible, a
|
||||
chance to export first.
|
||||
</p>
|
||||
|
||||
<h2>10. Third-party integrations</h2>
|
||||
<p>
|
||||
Donetick connects to outside services like messaging platforms,
|
||||
authentication providers, and webhooks. Those are governed by their own
|
||||
terms and privacy policies, and we're not responsible for services we
|
||||
don't control. Make sure you're authorized to connect whatever you
|
||||
connect.
|
||||
</p>
|
||||
|
||||
<h2>11. Our brand</h2>
|
||||
<p>
|
||||
You own your content; we own the Donetick name, logo, branding, and the
|
||||
proprietary parts of the Cloud Service. The AGPLv3 covers the software,
|
||||
not our trademarks, so please don't use our name or logo in a way that
|
||||
suggests we endorse or sponsor you.
|
||||
</p>
|
||||
|
||||
<h2>12. Termination</h2>
|
||||
|
||||
<h3>12.1 Termination by You</h3>
|
||||
<ul>
|
||||
<li>You may terminate your account at any time</li>
|
||||
<li>Cloud Service: Access continues until the end of your billing period</li>
|
||||
<li>Self-Hosted Service: You may stop using the software at any time</li>
|
||||
</ul>
|
||||
|
||||
<h3>12.2 Termination by Us</h3>
|
||||
<ul>
|
||||
<li>We may terminate accounts that violate these Terms</li>
|
||||
<li>We may discontinue the Cloud Service with 90 days notice</li>
|
||||
<li>We will provide data export capabilities before termination when possible</li>
|
||||
</ul>
|
||||
|
||||
<h2>13. Changes to These Terms</h2>
|
||||
<ul>
|
||||
<li>We may update these Terms from time to time</li>
|
||||
<li>Material changes will be announced via email or in-app notification</li>
|
||||
<li>Continued use after changes constitutes acceptance of new Terms</li>
|
||||
<li>For significant changes, we may require explicit acceptance</li>
|
||||
</ul>
|
||||
|
||||
<h2>14. Dispute Resolution</h2>
|
||||
<ul>
|
||||
<li>We encourage resolving disputes informally by contacting us first</li>
|
||||
<li>These Terms are governed by the laws of Delaware, United States</li>
|
||||
<li>Any disputes will be resolved in the courts of Delaware</li>
|
||||
<li>You may pursue small claims court for eligible disputes</li>
|
||||
</ul>
|
||||
|
||||
<h2>15. Miscellaneous</h2>
|
||||
<ul>
|
||||
<li>If any provision is found unenforceable, the remainder remains in effect</li>
|
||||
<li>Our failure to enforce any right does not waive that right</li>
|
||||
<li>These Terms constitute the entire agreement between us</li>
|
||||
<li>We may assign these Terms; you may not without our consent</li>
|
||||
</ul>
|
||||
|
||||
<h2>16. Contact Information</h2>
|
||||
<p>
|
||||
If you have questions about these Terms, please contact us:
|
||||
You can stop using Donetick and delete your account at any time from
|
||||
Settings. We may suspend or end access if you violate these Terms, or
|
||||
where we need to address abuse, fraud, a security incident, a legal
|
||||
obligation, or unpaid fees. Where it's practical, we'll give you notice
|
||||
and a chance to fix the problem first. After termination we delete or
|
||||
anonymize your content per our retention practices, though limited
|
||||
records may stick around for legal, security, or accounting reasons.
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Email:</strong> legal@donetick.com</li>
|
||||
<li><strong>Support:</strong> support@donetick.com</li>
|
||||
<li><strong>Address:</strong> Favoro LLC, [Address to be provided]</li>
|
||||
</ul>
|
||||
|
||||
<hr style={{ margin: '40px 0' }} />
|
||||
<p style={{ fontSize: '14px', color: '#666' }}>
|
||||
<strong>Last Updated:</strong> January 1, 2024<br />
|
||||
These Terms of Service are effective immediately for new users and will become effective
|
||||
for existing users 30 days after posting.
|
||||
<h2>13. Security reports</h2>
|
||||
<p>
|
||||
Found a vulnerability? Please email{' '}
|
||||
<a href='mailto:support@donetick.com'>support@donetick.com</a> and give
|
||||
us a reasonable chance to fix it before disclosing it publicly.
|
||||
</p>
|
||||
|
||||
<h2>14. Disclaimers and liability</h2>
|
||||
<p>
|
||||
<strong>
|
||||
THE SERVICE IS PROVIDED "AS IS" AND "AS AVAILABLE," WITHOUT WARRANTIES
|
||||
OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE, TITLE, AND NON-INFRINGEMENT. WE DO NOT
|
||||
WARRANT THAT THE SERVICE WILL BE UNINTERRUPTED, ERROR-FREE, OR SECURE,
|
||||
OR THAT DATA WILL NEVER BE LOST OR CORRUPTED.
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
<strong>
|
||||
TO THE MAXIMUM EXTENT PERMITTED BY LAW, FAVORO LLC IS NOT LIABLE FOR
|
||||
INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, EXEMPLARY, OR PUNITIVE
|
||||
DAMAGES, OR FOR LOST PROFITS, REVENUE, BUSINESS, GOODWILL, OR DATA.
|
||||
OUR TOTAL LIABILITY IS LIMITED TO THE GREATER OF WHAT YOU PAID US IN
|
||||
THE 12 MONTHS BEFORE THE CLAIM, OR $100.
|
||||
</strong>
|
||||
</p>
|
||||
<p>
|
||||
Self-hosted installs are covered by the AGPLv3's warranty disclaimer.
|
||||
We're not responsible for problems arising from your own infrastructure,
|
||||
configuration, modifications, or hosting provider. Nothing in these
|
||||
Terms excludes liability that can't legally be excluded.
|
||||
</p>
|
||||
|
||||
<h2>15. Indemnification</h2>
|
||||
<p>
|
||||
To the extent the law allows, you agree to cover Favoro LLC against
|
||||
third-party claims arising from your violation of these Terms, your
|
||||
unlawful use of the Service, your infringement of someone else's rights,
|
||||
or content you submitted. We'll give you reasonable notice of any such
|
||||
claim and cooperate in the defense.
|
||||
</p>
|
||||
|
||||
<h2>16. Disputes and governing law</h2>
|
||||
<p>
|
||||
Before filing anything, please email{' '}
|
||||
<a href='mailto:support@donetick.com'>support@donetick.com</a> and give
|
||||
us a fair chance to sort it out. These Terms are governed by the laws of
|
||||
the State of Delaware, USA, without regard to conflict-of-laws rules,
|
||||
and disputes go to the state or federal courts in Delaware. Either of us
|
||||
can still bring an eligible claim in small claims court, and none of
|
||||
this waives rights that can't legally be waived.
|
||||
</p>
|
||||
|
||||
<h2>17. Changes</h2>
|
||||
<p>
|
||||
We may update these Terms. We'll post the new version with an updated
|
||||
date, and for material changes we'll give reasonable advance notice by
|
||||
email or in-app notice (and get your consent where the law requires it).
|
||||
Continuing to use the Service after non-material changes means you
|
||||
accept them. If you don't agree to a material change, cancel before it
|
||||
takes effect.
|
||||
</p>
|
||||
|
||||
<h2>18. The fine print</h2>
|
||||
<p>
|
||||
These Terms plus our Privacy Policy are the whole agreement between us.
|
||||
If one provision turns out to be unenforceable, it gets trimmed to the
|
||||
minimum necessary and the rest stands. Not enforcing something once
|
||||
doesn't waive it later. You can't transfer these Terms without our
|
||||
consent; we may transfer them in a merger, acquisition, or sale of
|
||||
assets. These Terms don't create a partnership, employment, or agency
|
||||
relationship. Neither of us is liable for delays caused by things
|
||||
outside our reasonable control. Provisions that should survive
|
||||
termination do, including those on content, intellectual property,
|
||||
disclaimers, liability, indemnification, and disputes.
|
||||
</p>
|
||||
|
||||
<h2>19. Contact</h2>
|
||||
<p>
|
||||
Questions about these Terms? Email{' '}
|
||||
<a href='mailto:support@donetick.com'>support@donetick.com</a>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
<p>Favoro LLC</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
|
||||
import {
|
||||
Type as ListType,
|
||||
SwipeableList,
|
||||
SwipeableListItem,
|
||||
SwipeAction,
|
||||
TrailingActions,
|
||||
Type as ListType,
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import {
|
||||
Add,
|
||||
Delete,
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { track } from '../../analytics'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
@@ -42,7 +45,7 @@ import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import CreateThingModal from '../Modals/Inputs/CreateThingModal'
|
||||
import EditThingStateModal from '../Modals/Inputs/EditThingState'
|
||||
|
||||
const ThingCardContent = ({ thing, onCardClick, onToggleActions }) => {
|
||||
const ThingCardContent = ({ onCardClick, onToggleActions, thing }) => {
|
||||
const getThingIcon = type => {
|
||||
if (type === 'text') {
|
||||
return <Flip />
|
||||
@@ -242,6 +245,7 @@ const ThingsView = () => {
|
||||
const currentThings = [...things]
|
||||
currentThings.push(data.res)
|
||||
setThings(currentThings)
|
||||
track('thing_created', {})
|
||||
}
|
||||
showNotification({
|
||||
type: 'success',
|
||||
|
||||
@@ -283,6 +283,11 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
// Identities (type + text) of the highlights from the previous parse, so
|
||||
// the appear animation only plays for tokens detected just now
|
||||
const prevHighlightKeysRef = useRef(new Set())
|
||||
// Which capture method last populated the form, for chore_created's
|
||||
// analytics `source` property. Reset to 'quick_add' whenever the modal
|
||||
// closes — this is the AddTaskModal popup, distinct from the full-page
|
||||
// create flow in ChoreEdit.jsx.
|
||||
const taskSourceRef = useRef('quick_add')
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
@@ -895,6 +900,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
dueDate: extractedDue,
|
||||
taskName,
|
||||
}) => {
|
||||
taskSourceRef.current = 'scan'
|
||||
if (attachmentImage) {
|
||||
attachScannedImage(attachmentImage)
|
||||
}
|
||||
@@ -918,6 +924,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
// reviews it with the normal pickers before creating.
|
||||
const handleVoiceSingle = (text, overrides = {}) => {
|
||||
setShowVoice(false)
|
||||
taskSourceRef.current = 'voice'
|
||||
if (Object.keys(overrides).length > 0) {
|
||||
pendingVoiceOverridesRef.current = overrides
|
||||
}
|
||||
@@ -935,6 +942,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
projectId,
|
||||
notificationTemplates,
|
||||
})
|
||||
chore.source = 'voice'
|
||||
try {
|
||||
const result = await createChoreMutation.mutateAsync(chore)
|
||||
if (result?._pendingCreate) {
|
||||
@@ -1015,6 +1023,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
setUseCustomTime(false)
|
||||
setAttachments([])
|
||||
setDraftId(generateUUID())
|
||||
taskSourceRef.current = 'quick_add'
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
@@ -1063,6 +1072,7 @@ const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: draftId,
|
||||
source: taskSourceRef.current,
|
||||
}
|
||||
|
||||
// Reminders are a Plus feature and only make sense when the user kept at
|
||||
|
||||
@@ -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'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
53
src/views/components/PolicyUpdatePrompt.jsx
Normal file
53
src/views/components/PolicyUpdatePrompt.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
acknowledgePolicyUpdate,
|
||||
shouldShowPolicyUpdate,
|
||||
} from '../../service/PolicyUpdateService'
|
||||
import PolicyUpdateModal from '../Modals/PolicyUpdateModal'
|
||||
|
||||
// Let the screen settle before interrupting, and stay out of the way of the
|
||||
// feedback prompt, which uses a longer delay from the same screen.
|
||||
const OPEN_DELAY_MS = 1500
|
||||
|
||||
/**
|
||||
* Surfaces the policy-change notice once per revision. Mount once, near the
|
||||
* main task list.
|
||||
*/
|
||||
const PolicyUpdatePrompt = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
useEffect(() => {
|
||||
if (!userProfile) return
|
||||
|
||||
let timer = null
|
||||
let cancelled = false
|
||||
|
||||
shouldShowPolicyUpdate({ userProfile }).then(eligible => {
|
||||
if (!eligible || cancelled) return
|
||||
timer = setTimeout(() => {
|
||||
if (cancelled) return
|
||||
setOpen(true)
|
||||
}, OPEN_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [userProfile])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<PolicyUpdateModal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
onAcknowledge={acknowledgePolicyUpdate}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyUpdatePrompt
|
||||
Reference in New Issue
Block a user