diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index 932ca47..607863e 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -9,6 +9,7 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
+ implementation project(':capacitor-community-in-app-review')
implementation project(':capacitor-community-speech-recognition')
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app')
diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle
index a1b0775..cef2e20 100644
--- a/android/capacitor.settings.gradle
+++ b/android/capacitor.settings.gradle
@@ -2,6 +2,9 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
+include ':capacitor-community-in-app-review'
+project(':capacitor-community-in-app-review').projectDir = new File('../node_modules/@capacitor-community/in-app-review/android')
+
include ':capacitor-community-speech-recognition'
project(':capacitor-community-speech-recognition').projectDir = new File('../node_modules/@capacitor-community/speech-recognition/android')
diff --git a/ios/App/Podfile b/ios/App/Podfile
index b4e2a32..b538501 100644
--- a/ios/App/Podfile
+++ b/ios/App/Podfile
@@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
+ pod 'CapacitorCommunityInAppReview', :path => '../../node_modules/@capacitor-community/in-app-review'
pod 'CapacitorCommunitySpeechRecognition', :path => '../../node_modules/@capacitor-community/speech-recognition'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock
index 3c20ae8..84d18d4 100644
--- a/ios/App/Podfile.lock
+++ b/ios/App/Podfile.lock
@@ -18,6 +18,8 @@ PODS:
- Capacitor
- CapacitorBrowser (8.0.3):
- Capacitor
+ - CapacitorCommunityInAppReview (8.0.0):
+ - Capacitor
- CapacitorCommunitySpeechRecognition (7.0.1):
- Capacitor
- CapacitorCommunitySqlite (8.1.0):
@@ -143,6 +145,7 @@ DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
+ - "CapacitorCommunityInAppReview (from `../../node_modules/@capacitor-community/in-app-review`)"
- "CapacitorCommunitySpeechRecognition (from `../../node_modules/@capacitor-community/speech-recognition`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
@@ -195,6 +198,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/app"
CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser"
+ CapacitorCommunityInAppReview:
+ :path: "../../node_modules/@capacitor-community/in-app-review"
CapacitorCommunitySpeechRecognition:
:path: "../../node_modules/@capacitor-community/speech-recognition"
CapacitorCommunitySqlite:
@@ -239,6 +244,7 @@ SPEC CHECKSUMS:
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
+ CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
@@ -277,6 +283,6 @@ SPEC CHECKSUMS:
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
-PODFILE CHECKSUM: 1099083fe561f8852fcef1bfcff4051f45db9770
+PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
COCOAPODS: 1.16.2
diff --git a/package-lock.json b/package-lock.json
index fdc0422..cd90aa4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "1.2.33",
"hasInstallScript": true,
"dependencies": {
+ "@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",
@@ -1495,6 +1496,15 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/@capacitor-community/in-app-review": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/@capacitor-community/in-app-review/-/in-app-review-8.0.0.tgz",
+ "integrity": "sha512-AYpEkIjdv27WGS3hnlc7xAQ1BhaBvD2svgQBfHF4P2Olw1N6a7MSCOQC6O4SuxmdBdJ2fM6uFeo1jyNjKLtIPw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@capacitor/core": ">=8.0.0"
+ }
+ },
"node_modules/@capacitor-community/speech-recognition": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/@capacitor-community/speech-recognition/-/speech-recognition-7.0.1.tgz",
diff --git a/package.json b/package.json
index e2cf9af..af2388b 100644
--- a/package.json
+++ b/package.json
@@ -41,6 +41,7 @@
"postinstall": "patch-package"
},
"dependencies": {
+ "@capacitor-community/in-app-review": "^8.0.0",
"@capacitor-community/speech-recognition": "^7.0.1",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index fa493cf..8df2fcf 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -29,5 +29,51 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings"
+ },
+ "feedback": {
+ "later": "Maybe later",
+ "sentiment": {
+ "title": "How's Donetick working for you?",
+ "subtitle": "Your answer helps us decide what to build next.",
+ "options": {
+ "love": "Love it",
+ "okay": "It's okay",
+ "issues": "Having issues"
+ }
+ },
+ "categories": {
+ "bugs": "Bugs",
+ "missingFeature": "Missing feature",
+ "tooComplicated": "Too complicated",
+ "slow": "Slow",
+ "notifications": "Notifications",
+ "ai": "AI",
+ "other": "Other"
+ },
+ "details": {
+ "title": "What could we improve?",
+ "messageLabel": "Tell us more",
+ "messagePlaceholder": "What happened, or what would make this better?",
+ "contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
+ "submit": "Send feedback",
+ "contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server โ we'll open a pre-filled GitHub issue you can review and edit first.",
+ "submitSelfHosted": "Continue to GitHub"
+ },
+ "review": {
+ "title": "Glad you're enjoying it!",
+ "subtitle": "A rating or a star helps other people find Donetick.",
+ "github": "Star on GitHub",
+ "appStore": "Rate on the App Store",
+ "playStore": "Rate on Google Play"
+ },
+ "thanks": {
+ "title": "Thanks for the feedback",
+ "subtitle": "We read every response and it shapes what we work on next."
+ },
+ "github": {
+ "title": "Report it on GitHub",
+ "subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet โ review it and post when you're ready.",
+ "open": "Open the issue"
+ }
}
}
diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json
index d03ef6d..085ef01 100644
--- a/public/locales/en/settings.json
+++ b/public/locales/en/settings.json
@@ -168,6 +168,10 @@
"developer": {
"title": "Developer Settings",
"description": "View technical information about authentication tokens, SSE connections, and debug data."
+ },
+ "feedback": {
+ "title": "Send Feedback",
+ "description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}
diff --git a/src/service/FeedbackService.js b/src/service/FeedbackService.js
new file mode 100644
index 0000000..70e1af7
--- /dev/null
+++ b/src/service/FeedbackService.js
@@ -0,0 +1,475 @@
+import { InAppReview } from '@capacitor-community/in-app-review'
+import { Capacitor } from '@capacitor/core'
+import { Device } from '@capacitor/device'
+import { Preferences } from '@capacitor/preferences'
+import { isOfficialDonetickInstance } from '../utils/FeatureToggle'
+
+const STATE_KEY = 'feedbackState'
+
+// Eligibility thresholds for the automatic sentiment prompt.
+const MIN_COMPLETIONS = 10
+const MIN_DAYS_SINCE_SIGNUP = 7
+const COOLDOWN_DAYS = 120
+// A recent crash/API failure poisons the sentiment reading, so hold off.
+const ERROR_QUIET_PERIOD_MS = 10 * 60 * 1000
+
+const APP_STORE_URL =
+ 'https://apps.apple.com/app/apple-store/id6742807441?pt=127258663&ct=website&mt=8'
+const PLAY_STORE_URL =
+ 'https://play.google.com/store/apps/details?id=com.donetick.app'
+const GITHUB_URL = 'https://github.com/donetick/donetick'
+
+const DAY_MS = 24 * 60 * 60 * 1000
+
+const defaultState = {
+ completions: 0,
+ // null until the first prompt is shown/snoozed.
+ lastPromptedAt: null,
+ lastPromptedVersion: null,
+ dismissCount: 0,
+ reviewRequestedAt: null,
+ lastSentiment: null,
+ optedOut: false,
+ // 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('FeedbackService: 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) {
+ console.warn('FeedbackService: unable to persist state', error)
+ }
+ return cachedState
+}
+
+export const getFeedbackState = () => readState()
+
+/**
+ * Counts a completed task towards prompt eligibility. Called from the single
+ * network choke point for completions so offline completions are counted when
+ * they sync rather than twice.
+ */
+export const recordTaskCompleted = async () => {
+ const state = await readState()
+ // Stop writing once we are well past the threshold; nothing reads the exact
+ // number beyond reporting it as context.
+ if (state.completions > MIN_COMPLETIONS * 100) return
+ await writeState({ completions: state.completions + 1 })
+}
+
+// ---------------------------------------------------------------------------
+// Recent error breadcrumbs (in memory only, never persisted)
+// ---------------------------------------------------------------------------
+
+const recentErrors = []
+const MAX_ERRORS = 5
+
+export const recordFeedbackError = message => {
+ if (!message) return
+ recentErrors.push({ at: Date.now(), message: String(message).slice(0, 300) })
+ if (recentErrors.length > MAX_ERRORS) recentErrors.shift()
+}
+
+let errorListenersInstalled = false
+
+export const installFeedbackErrorListeners = () => {
+ if (errorListenersInstalled || typeof window === 'undefined') return
+ errorListenersInstalled = true
+ window.addEventListener('error', event => {
+ recordFeedbackError(event?.message)
+ })
+ window.addEventListener('unhandledrejection', event => {
+ recordFeedbackError(event?.reason?.message || event?.reason)
+ })
+}
+
+const hasRecentError = () =>
+ recentErrors.some(error => Date.now() - error.at < ERROR_QUIET_PERIOD_MS)
+
+// ---------------------------------------------------------------------------
+// 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.
+ */
+export const collectFeedbackContext = async ({ feature, userProfile } = {}) => {
+ const [version, device, state, isCloud] = await Promise.all([
+ getAppVersion(),
+ getDeviceContext(),
+ readState(),
+ isOfficialDonetickInstance().catch(() => false),
+ ])
+
+ const signupDate = userProfile?.created_at
+ const daysSinceSignup = signupDate
+ ? Math.floor((Date.now() - new Date(signupDate).getTime()) / DAY_MS)
+ : null
+
+ return {
+ appVersion: version,
+ platform: Capacitor.getPlatform(),
+ isNative: Capacitor.isNativePlatform(),
+ deviceModel: device.deviceModel,
+ osVersion: device.osVersion,
+ locale:
+ localStorage.getItem('i18nextLng') || navigator.language || 'unknown',
+ hosting: isCloud ? 'cloud' : 'self-hosted',
+ feature: feature || 'unknown',
+ tasksCompleted: state.completions,
+ daysSinceSignup,
+ userId: userProfile?.id ?? null,
+ subscription: userProfile?.subscription ?? null,
+ recentErrors: recentErrors.map(error => error.message),
+ submittedAt: new Date().toISOString(),
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Eligibility
+// ---------------------------------------------------------------------------
+
+/**
+ * Runs every gate and reports which ones failed, so Developer Settings can
+ * explain why the prompt is or isn't showing rather than just saying "no".
+ */
+export const evaluatePromptEligibility = async ({ userProfile } = {}) => {
+ const state = await readState()
+ const version = await getAppVersion()
+
+ if (state.devForced) {
+ return { eligible: true, forced: true, blockers: [], state, version }
+ }
+
+ const blockers = []
+
+ if (state.optedOut) {
+ blockers.push('User opted out (chose a sentiment, or dismissed 3 times)')
+ }
+ if (state.completions < MIN_COMPLETIONS) {
+ blockers.push(
+ `Only ${state.completions} completions, needs ${MIN_COMPLETIONS}`,
+ )
+ }
+ if (hasRecentError()) {
+ blockers.push('An error occurred in the last 10 minutes')
+ }
+
+ const signupDate = userProfile?.createdAt
+ if (signupDate) {
+ const days = (Date.now() - new Date(signupDate).getTime()) / DAY_MS
+ if (days < MIN_DAYS_SINCE_SIGNUP) {
+ blockers.push(
+ `Account is ${Math.floor(days)} days old, needs ${MIN_DAYS_SINCE_SIGNUP}`,
+ )
+ }
+ }
+
+ if (state.lastPromptedAt) {
+ const daysSincePrompt = (Date.now() - state.lastPromptedAt) / DAY_MS
+ if (daysSincePrompt < COOLDOWN_DAYS) {
+ blockers.push(
+ `Cooldown: ${Math.ceil(COOLDOWN_DAYS - daysSincePrompt)} days remaining`,
+ )
+ }
+ }
+
+ // Never ask twice on the same build, even after the cooldown expires.
+ if (state.lastPromptedVersion && state.lastPromptedVersion === version) {
+ blockers.push(`Already prompted on this version (${version})`)
+ }
+
+ return {
+ eligible: blockers.length === 0,
+ forced: false,
+ blockers,
+ state,
+ version,
+ }
+}
+
+export const shouldShowSentimentPrompt = async options =>
+ (await evaluatePromptEligibility(options)).eligible
+
+/** Developer Settings: bypass every gate on the next eligibility check. */
+export const setDevForcedPrompt = forced => writeState({ devForced: !!forced })
+
+/** Developer Settings: back to a never-prompted, zero-completions user. */
+export const resetFeedbackState = async () => {
+ cachedState = { ...defaultState }
+ try {
+ await Preferences.remove({ key: STATE_KEY })
+ } catch (error) {
+ console.warn('FeedbackService: unable to clear state', error)
+ }
+ return cachedState
+}
+
+export const markPromptShown = async () => {
+ const version = await getAppVersion()
+ return writeState({
+ lastPromptedAt: Date.now(),
+ lastPromptedVersion: version,
+ // A forced prompt is spent once shown, otherwise it would fire on every
+ // visit to My Chores.
+ devForced: false,
+ })
+}
+
+export const markPromptDismissed = async () => {
+ const state = await readState()
+ const dismissCount = state.dismissCount + 1
+ // Three dismissals in a row is an answer: stop asking automatically.
+ return writeState({ dismissCount, optedOut: dismissCount >= 3 })
+}
+
+export const markSentiment = async sentiment =>
+ writeState({ lastSentiment: sentiment, dismissCount: 0 })
+
+export const optOutOfFeedbackPrompts = () => writeState({ optedOut: true })
+
+// ---------------------------------------------------------------------------
+// Store review + submission
+// ---------------------------------------------------------------------------
+
+export const getStoreUrl = () => {
+ const platform = Capacitor.getPlatform()
+ if (platform === 'ios') return APP_STORE_URL
+ if (platform === 'android') return PLAY_STORE_URL
+ return GITHUB_URL
+}
+
+export const storeLinks = {
+ appStore: APP_STORE_URL,
+ playStore: PLAY_STORE_URL,
+ github: GITHUB_URL,
+}
+
+/**
+ * Asks the OS to show its native review dialog. The OS decides whether to
+ * actually display it and gives no feedback either way, so this resolves true
+ * only to mean "the request went through".
+ */
+export const requestStoreReview = async () => {
+ if (!Capacitor.isNativePlatform()) return false
+ try {
+ await InAppReview.requestReview()
+ await writeState({ reviewRequestedAt: Date.now(), optedOut: true })
+ return true
+ } catch (error) {
+ console.warn('FeedbackService: review request failed', error)
+ return false
+ }
+}
+
+const WEBHOOK_URL = import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
+
+/**
+ * A chat-provider webhook pasted straight into the env var. That can't work โ
+ * the app posts its own schema, which Discord rejects with "Cannot send an
+ * empty message" (50006) โ and it would publish the webhook to every user,
+ * since VITE_ vars are baked into the bundle. Relay through workers/feedback.
+ */
+const isRawChatWebhook = url =>
+ /^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
+ url || '',
+ )
+
+export const isFeedbackSubmissionConfigured = () =>
+ Boolean(WEBHOOK_URL) && !isRawChatWebhook(WEBHOOK_URL)
+
+/** Developer Settings: flag the misconfiguration in the UI, not just the log. */
+export const isRawChatWebhookConfigured = () => isRawChatWebhook(WEBHOOK_URL)
+
+/**
+ * Whether this app is talking to the hosted donetick.com service. Self-hosted
+ * instances route feedback to GitHub instead of the webhook, so their data
+ * never leaves infrastructure they control.
+ */
+export const isCloudInstance = () =>
+ isOfficialDonetickInstance().catch(() => false)
+
+export const SUBMIT_RESULT = {
+ SENT: 'sent',
+ FAILED: 'failed',
+ UNCONFIGURED: 'unconfigured',
+ MISCONFIGURED: 'misconfigured',
+ SELF_HOSTED: 'self-hosted',
+}
+
+/**
+ * Builds a pre-filled GitHub issue for self-hosted users. Everything the
+ * webhook would have collected goes into the issue body, where the user can
+ * see and edit it before anything is published.
+ */
+export const buildGithubIssueUrl = ({
+ sentiment,
+ category,
+ message,
+ context,
+}) => {
+ const labelFor = {
+ bugs: 'bug',
+ missingFeature: 'feature request',
+ tooComplicated: 'usability',
+ slow: 'performance',
+ notifications: 'notifications',
+ ai: 'ai',
+ other: 'feedback',
+ }
+ const title = `[${labelFor[category] || 'feedback'}] ${
+ message?.split('\n')[0]?.slice(0, 80) || 'App feedback'
+ }`
+
+ const body = [
+ message?.trim() || '_no description_',
+ '',
+ '---',
+ '',
+ 'Environment
',
+ '',
+ `- App version: ${context.appVersion}`,
+ `- Platform: ${context.platform}${context.isNative ? ' (native)' : ''}`,
+ `- Device: ${context.deviceModel}`,
+ `- OS: ${context.osVersion}`,
+ `- Locale: ${context.locale}`,
+ `- Hosting: ${context.hosting}`,
+ `- Screen: ${context.feature}`,
+ `- Sentiment: ${sentiment}`,
+ ...(context.recentErrors.length
+ ? ['', 'Recent errors:', '```', ...context.recentErrors, '```']
+ : []),
+ '',
+ ' ',
+ ].join('\n')
+
+ return `${GITHUB_URL}/issues/new?title=${encodeURIComponent(
+ title,
+ )}&body=${encodeURIComponent(body)}`
+}
+
+/**
+ * Posts the structured feedback plus the auto-collected context to the
+ * configured webhook. Never throws, so the UI can show a soft failure without
+ * losing what the user typed.
+ *
+ * Self-hosted instances are never relayed: the caller gets SELF_HOSTED plus a
+ * pre-filled GitHub issue URL to send the user to instead.
+ */
+export const submitFeedback = async ({
+ sentiment,
+ category,
+ message,
+ feature,
+ userProfile,
+}) => {
+ const context = await collectFeedbackContext({ feature, userProfile })
+ const payload = {
+ source: 'donetick-app',
+ sentiment,
+ category: category || null,
+ message: message?.trim() || null,
+ context,
+ }
+
+ // Enforced here rather than only in the UI so no future caller can leak a
+ // self-hosted user's feedback to the hosted relay.
+ if (context.hosting !== 'cloud') {
+ return {
+ result: SUBMIT_RESULT.SELF_HOSTED,
+ githubUrl: buildGithubIssueUrl({ sentiment, category, message, context }),
+ }
+ }
+
+ if (!WEBHOOK_URL) {
+ console.info('FeedbackService: no webhook configured, feedback:', payload)
+ return { result: SUBMIT_RESULT.UNCONFIGURED }
+ }
+
+ if (isRawChatWebhook(WEBHOOK_URL)) {
+ console.error(
+ 'FeedbackService: VITE_FEEDBACK_WEBHOOK_URL points directly at a ' +
+ 'Discord/Slack webhook. Discord will reject this with 50006 ' +
+ '("Cannot send an empty message") because the app posts its own ' +
+ 'schema, and the URL would ship inside the public bundle. Deploy ' +
+ 'workers/feedback and point the variable at the Worker instead.',
+ payload,
+ )
+ return { result: SUBMIT_RESULT.MISCONFIGURED }
+ }
+
+ try {
+ const response = await fetch(WEBHOOK_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ return {
+ result: response.ok ? SUBMIT_RESULT.SENT : SUBMIT_RESULT.FAILED,
+ }
+ } catch (error) {
+ console.warn('FeedbackService: submission failed', error)
+ return { result: SUBMIT_RESULT.FAILED }
+ }
+}
+
+export const FEEDBACK_CATEGORIES = [
+ 'bugs',
+ 'missingFeature',
+ 'tooComplicated',
+ 'slow',
+ 'notifications',
+ 'ai',
+ 'other',
+]
+
+export const SENTIMENTS = {
+ LOVE: 'love',
+ OKAY: 'okay',
+ ISSUES: 'issues',
+}
diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx
index 65375e7..1f3fc12 100644
--- a/src/utils/Fetcher.jsx
+++ b/src/utils/Fetcher.jsx
@@ -166,6 +166,15 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(body),
+ }).then(response => {
+ if (response?.ok) {
+ // Single choke point for completions, so queued offline completions are
+ // counted once, when they sync.
+ import('../service/FeedbackService')
+ .then(({ recordTaskCompleted }) => recordTaskCompleted())
+ .catch(() => {})
+ }
+ return response
})
}
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index 4da5ee3..0fd0185 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -47,6 +47,7 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
+import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
@@ -1479,6 +1480,7 @@ const MyChores = () => {
/>
+
{addTaskModalOpen && (
{
+ if (Capacitor.isNativePlatform()) {
+ await Browser.open({ url })
+ } else {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+}
+
+const SENTIMENT_OPTIONS = [
+ { value: SENTIMENTS.LOVE, emoji: '๐' },
+ { value: SENTIMENTS.OKAY, emoji: '๐' },
+ { value: SENTIMENTS.ISSUES, emoji: '๐' },
+]
+
+/**
+ * Sentiment-first feedback flow. "Love it" routes to the native store review
+ * dialog (or star links on web); anything else collects structured feedback
+ * and never asks for a review.
+ */
+const FeedbackModal = ({ open, onClose, onDismiss }) => {
+ const { t } = useTranslation()
+ const { ResponsiveModal } = useResponsiveModal()
+ const { data: userProfile } = useUserProfile()
+ const location = useLocation()
+
+ const [step, setStep] = useState(STEP.SENTIMENT)
+ const [sentiment, setSentiment] = useState(null)
+ const [category, setCategory] = useState(null)
+ const [message, setMessage] = useState('')
+ const [submitting, setSubmitting] = useState(false)
+ const [isCloud, setIsCloud] = useState(true)
+ const [githubUrl, setGithubUrl] = useState(null)
+
+ useEffect(() => {
+ if (open) {
+ setStep(STEP.SENTIMENT)
+ setSentiment(null)
+ setCategory(null)
+ setMessage('')
+ setSubmitting(false)
+ setGithubUrl(null)
+ isCloudInstance().then(setIsCloud)
+ }
+ }, [open])
+
+ const handleClose = () => {
+ // Backing out before answering counts as a dismissal for the cooldown.
+ if (step === STEP.SENTIMENT) onDismiss?.()
+ onClose()
+ }
+
+ const handleSentiment = async value => {
+ setSentiment(value)
+ await markSentiment(value)
+
+ if (value !== SENTIMENTS.LOVE) {
+ setStep(STEP.DETAILS)
+ return
+ }
+
+ if (Capacitor.isNativePlatform()) {
+ const requested = await requestStoreReview()
+ if (requested) {
+ onClose()
+ return
+ }
+ }
+ setStep(STEP.WEB_REVIEW)
+ }
+
+ const handleSubmit = async () => {
+ setSubmitting(true)
+ const { result, githubUrl: url } = await submitFeedback({
+ sentiment,
+ category,
+ message,
+ feature: location.pathname,
+ userProfile,
+ })
+ setSubmitting(false)
+
+ // Self-hosted feedback is never relayed; hand the user a pre-filled issue
+ // instead so they choose what gets published.
+ if (result === SUBMIT_RESULT.SELF_HOSTED) {
+ setGithubUrl(url)
+ setStep(STEP.GITHUB)
+ return
+ }
+ setStep(STEP.THANKS)
+ }
+
+ const canSubmit = Boolean(category) || message.trim().length > 0
+
+ return (
+
+
+ {step === STEP.SENTIMENT && (
+ <>
+
+ {t('feedback.sentiment.title')}
+
+
+ {t('feedback.sentiment.subtitle')}
+
+
+ {SENTIMENT_OPTIONS.map(option => (
+
+ ))}
+
+
+ >
+ )}
+
+ {step === STEP.DETAILS && (
+ <>
+
+ {t('feedback.details.title')}
+
+
+ {FEEDBACK_CATEGORIES.map(item => (
+ setCategory(category === item ? null : item)}
+ >
+ {t(`feedback.categories.${item}`)}
+
+ ))}
+
+
+
+
+ {t('feedback.details.messageLabel')}
+
+
+
+
+ {isCloud
+ ? t('feedback.details.contextNote')
+ : t('feedback.details.contextNoteSelfHosted')}
+
+
+
+
+
+ : null
+ }
+ onClick={handleSubmit}
+ >
+ {isCloud
+ ? t('feedback.details.submit')
+ : t('feedback.details.submitSelfHosted')}
+
+
+ >
+ )}
+
+ {step === STEP.WEB_REVIEW && (
+ <>
+ }
+ >
+ {t('feedback.review.title')}
+
+
+ {t('feedback.review.subtitle')}
+
+
+ }
+ onClick={() => openUrl(storeLinks.github)}
+ sx={{ justifyContent: 'flex-start' }}
+ >
+ {t('feedback.review.github')}
+
+ }
+ onClick={() => openUrl(storeLinks.appStore)}
+ sx={{ justifyContent: 'flex-start' }}
+ >
+ {t('feedback.review.appStore')}
+
+ }
+ onClick={() => openUrl(storeLinks.playStore)}
+ sx={{ justifyContent: 'flex-start' }}
+ >
+ {t('feedback.review.playStore')}
+
+
+
+ >
+ )}
+
+ {step === STEP.GITHUB && (
+ <>
+
+ {t('feedback.github.title')}
+
+
+ {t('feedback.github.subtitle')}
+
+ }
+ onClick={() => {
+ openUrl(githubUrl)
+ onClose()
+ }}
+ >
+ {t('feedback.github.open')}
+
+
+ >
+ )}
+
+ {step === STEP.THANKS && (
+ <>
+
+ {t('feedback.thanks.title')}
+
+
+ {t('feedback.thanks.subtitle')}
+
+
+ >
+ )}
+
+
+ )
+}
+
+export default FeedbackModal
diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx
index b400f88..8f5d177 100644
--- a/src/views/Settings/DeveloperSettings.jsx
+++ b/src/views/Settings/DeveloperSettings.jsx
@@ -1,11 +1,20 @@
import { LocalNotifications } from '@capacitor/local-notifications'
-import { Refresh, Token } from '@mui/icons-material'
+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 { networkManager } from '../../hooks/NetworkManager'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useSSEContext } from '../../hooks/useSSEContext'
+import { useUserProfile } from '../../queries/UserQueries'
+import {
+ evaluatePromptEligibility,
+ isFeedbackSubmissionConfigured,
+ isRawChatWebhookConfigured,
+ requestStoreReview,
+ resetFeedbackState,
+ setDevForcedPrompt,
+} from '../../service/FeedbackService'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { commandQueue } from '../../utils/CommandQueue'
@@ -13,11 +22,13 @@ import { RefreshToken } from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB'
import { syncEngine } from '../../utils/SyncEngine'
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
+import FeedbackModal from '../Modals/FeedbackModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
const DeveloperSettings = () => {
const queryClient = useQueryClient()
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
+ const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
@@ -41,6 +52,8 @@ const DeveloperSettings = () => {
const [scheduledNotifications, setScheduledNotifications] = useState([])
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
const [isResettingSync, setIsResettingSync] = useState(false)
+ const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
+ const [feedbackEligibility, setFeedbackEligibility] = useState(null)
const [syncDiagnostics, setSyncDiagnostics] = useState({
cursor: null,
lastSync: null,
@@ -362,6 +375,45 @@ const DeveloperSettings = () => {
)
}
+ const refreshFeedbackEligibility = useCallback(async () => {
+ const result = await evaluatePromptEligibility({ userProfile })
+ setFeedbackEligibility(result)
+ return result
+ }, [userProfile])
+
+ useEffect(() => {
+ refreshFeedbackEligibility()
+ }, [refreshFeedbackEligibility])
+
+ const handleForceFeedbackPrompt = async () => {
+ await setDevForcedPrompt(true)
+ await refreshFeedbackEligibility()
+ showNotification({
+ type: 'success',
+ message: 'Next visit to My Chores will show the prompt after ~4s',
+ })
+ }
+
+ const handleResetFeedbackState = async () => {
+ await resetFeedbackState()
+ await refreshFeedbackEligibility()
+ showNotification({
+ type: 'success',
+ message: 'Feedback state cleared (completions, cooldown, opt-out)',
+ })
+ }
+
+ const handleRequestStoreReview = async () => {
+ const requested = await requestStoreReview()
+ await refreshFeedbackEligibility()
+ showNotification({
+ type: requested ? 'success' : 'warning',
+ message: requested
+ ? 'Review requested. The OS decides whether to actually show it.'
+ : 'Not available โ native platform only.',
+ })
+ }
+
const getNotificationStatusColor = scheduleTime => {
if (!scheduleTime) return 'neutral'
@@ -642,6 +694,166 @@ const DeveloperSettings = () => {
+
+
+
+ Feedback & Review Prompt
+ }
+ onClick={refreshFeedbackEligibility}
+ >
+ Refresh
+
+
+
+
+
+
+ Prompt Eligibility
+
+ Would auto-show:{' '}
+
+ {feedbackEligibility?.eligible ? 'Yes' : 'No'}
+
+ {feedbackEligibility?.forced && (
+
+ Forced
+
+ )}
+
+ {feedbackEligibility?.blockers?.length > 0 && (
+
+ {feedbackEligibility.blockers.map(blocker => (
+
+ โข {blocker}
+
+ ))}
+
+ )}
+
+
+
+
+
+ State
+
+ Completions counted:{' '}
+
+ {feedbackEligibility?.state?.completions ?? 'N/A'}
+
+
+
+ Last sentiment:{' '}
+
+ {feedbackEligibility?.state?.lastSentiment ?? 'None'}
+
+
+
+ Dismissals:{' '}
+
+ {feedbackEligibility?.state?.dismissCount ?? 0}
+
+
+
+ Opted out:{' '}
+
+ {feedbackEligibility?.state?.optedOut ? 'Yes' : 'No'}
+
+
+
+ Last prompted:{' '}
+ {formatDateTime(feedbackEligibility?.state?.lastPromptedAt)}
+ {feedbackEligibility?.state?.lastPromptedVersion
+ ? ` on ${feedbackEligibility.state.lastPromptedVersion}`
+ : ''}
+
+
+ Review requested:{' '}
+ {formatDateTime(feedbackEligibility?.state?.reviewRequestedAt)}
+
+
+ Current version: {feedbackEligibility?.version ?? 'N/A'} ยท Webhook{' '}
+ {isFeedbackSubmissionConfigured()
+ ? 'configured'
+ : 'NOT configured (submissions log to console)'}
+
+ {isRawChatWebhookConfigured() && (
+
+ VITE_FEEDBACK_WEBHOOK_URL points straight at a Discord/Slack
+ webhook. Discord rejects that with 50006, and the URL ships
+ inside the public bundle โ deploy workers/feedback and point the
+ variable at the Worker.
+
+ )}
+
+
+
+
+
+ Actions
+
+ "Force next prompt" bypasses every gate, then open My
+ Chores to see the automatic trigger.
+
+
+
+
+ }
+ onClick={handleRequestStoreReview}
+ disabled={!isNativePlatform}
+ >
+ Request Store Review
+
+
+
+
+
+
+
{isNativePlatform && (
@@ -990,6 +1202,14 @@ const DeveloperSettings = () => {
+ {
+ setFeedbackModalOpen(false)
+ refreshFeedbackEligibility()
+ }}
+ />
+
)
diff --git a/src/views/Settings/SettingsOverview.jsx b/src/views/Settings/SettingsOverview.jsx
index eadb8f9..3249d3f 100644
--- a/src/views/Settings/SettingsOverview.jsx
+++ b/src/views/Settings/SettingsOverview.jsx
@@ -5,6 +5,7 @@ import {
Circle,
Code,
FamilyRestroom,
+ Feedback,
Language,
Notifications,
Palette,
@@ -31,16 +32,19 @@ import {
Stack,
Typography,
} from '@mui/joy'
+import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import { isParentUser } from '../../utils/UserHelpers'
+import FeedbackModal from '../Modals/FeedbackModal'
const SettingsOverview = () => {
const { t } = useTranslation('settings')
const navigate = useNavigate()
const { data: userProfile } = useUserProfile()
+ const [feedbackOpen, setFeedbackOpen] = useState(false)
const settingsCards = [
{
@@ -122,10 +126,21 @@ const SettingsOverview = () => {
description: t('overview.sections.developer.description'),
icon: ,
},
+ {
+ id: 'feedback',
+ title: t('overview.sections.feedback.title'),
+ description: t('overview.sections.feedback.description'),
+ icon: ,
+ onSelect: () => setFeedbackOpen(true),
+ },
]
- const handleCardClick = settingId => {
- navigate(`/settings/${settingId}`)
+ const handleCardClick = setting => {
+ if (setting.onSelect) {
+ setting.onSelect()
+ return
+ }
+ navigate(`/settings/${setting.id}`)
}
// Filter settings based on user type
@@ -303,7 +318,7 @@ const SettingsOverview = () => {
{getAvailableSettings().map((setting, index) => (
handleCardClick(setting.id)}
+ onClick={() => handleCardClick(setting)}
sx={{
'&:hover': {
backgroundColor: 'background.level1',
@@ -367,6 +382,11 @@ const SettingsOverview = () => {
))}
+
+ setFeedbackOpen(false)}
+ />
)
}
diff --git a/src/views/components/FeedbackPrompt.jsx b/src/views/components/FeedbackPrompt.jsx
new file mode 100644
index 0000000..72c51e3
--- /dev/null
+++ b/src/views/components/FeedbackPrompt.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useState } from 'react'
+import { useUserProfile } from '../../queries/UserQueries'
+import {
+ installFeedbackErrorListeners,
+ markPromptDismissed,
+ markPromptShown,
+ shouldShowSentimentPrompt,
+} from '../../service/FeedbackService'
+import FeedbackModal from '../Modals/FeedbackModal'
+
+// Let the screen settle before interrupting.
+const OPEN_DELAY_MS = 4000
+
+/**
+ * Decides whether to surface the sentiment prompt automatically. Mount once,
+ * near the main task list.
+ */
+const FeedbackPrompt = () => {
+ const [open, setOpen] = useState(false)
+ const { data: userProfile } = useUserProfile()
+
+ useEffect(() => {
+ installFeedbackErrorListeners()
+ }, [])
+
+ useEffect(() => {
+ if (!userProfile) return
+
+ let timer = null
+ let cancelled = false
+
+ shouldShowSentimentPrompt({ userProfile }).then(eligible => {
+ if (!eligible || cancelled) return
+ timer = setTimeout(() => {
+ if (cancelled) return
+ markPromptShown()
+ setOpen(true)
+ }, OPEN_DELAY_MS)
+ })
+
+ return () => {
+ cancelled = true
+ if (timer) clearTimeout(timer)
+ }
+ }, [userProfile])
+
+ if (!open) return null
+
+ return (
+ setOpen(false)}
+ onDismiss={markPromptDismissed}
+ />
+ )
+}
+
+export default FeedbackPrompt
diff --git a/vite.config.mjs b/vite.config.mjs
index c0d0369..5d6d70f 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -1,8 +1,12 @@
import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
+import pkg from './package.json'
// https://vitejs.dev/config/
export default defineConfig({
+ define: {
+ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
+ },
plugins: [
react(),
VitePWA({