diff --git a/crowdin.yml b/crowdin.yml
index 5665ddc..a3c2fc1 100644
--- a/crowdin.yml
+++ b/crowdin.yml
@@ -13,3 +13,4 @@ files:
- source: /public/locales/en/*.json
translation: /public/locales/%two_letters_code%/%original_file_name%
update_option: update_as_unapproved
+ skip_untranslated_strings: true
diff --git a/package-lock.json b/package-lock.json
index 5e083e2..84fc72d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -42,6 +42,7 @@
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
+ "@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0",
@@ -4596,6 +4597,49 @@
"@posthog/types": "^1.402.2"
}
},
+ "node_modules/@posthog/cli": {
+ "version": "0.11.2",
+ "resolved": "https://registry.npmjs.org/@posthog/cli/-/cli-0.11.2.tgz",
+ "integrity": "sha512-iqYIl/Q2FfE/+vftlHqGYFz4DRcDon7t28PU6kIefFFUMr0Z4bZrypnxU/Wl4xW/0uUw4fiAKs2tkWmwh3lSDA==",
+ "hasInstallScript": true,
+ "hasShrinkwrap": true,
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.1.2"
+ },
+ "bin": {
+ "posthog-cli": "run-posthog-cli.js"
+ },
+ "engines": {
+ "node": ">=14.14",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/@posthog/cli/node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@posthog/cli/node_modules/prettier": {
+ "version": "3.8.3",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
+ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
+ "extraneous": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/@posthog/core": {
"version": "1.47.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
@@ -4605,6 +4649,29 @@
"@posthog/types": "^1.402.2"
}
},
+ "node_modules/@posthog/plugin-utils": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/@posthog/plugin-utils/-/plugin-utils-1.1.3.tgz",
+ "integrity": "sha512-bJ7llB5We3NRB6VZfPLAn71Ps8+Q/bEP3T4/Q61CDE8tbkSMiVvWp9cyGRBdBmPnX4X5rT+rEtUZreVPwTvw3g==",
+ "license": "MIT",
+ "dependencies": {
+ "cross-spawn": "^7.0.6"
+ }
+ },
+ "node_modules/@posthog/rollup-plugin": {
+ "version": "1.4.9",
+ "resolved": "https://registry.npmjs.org/@posthog/rollup-plugin/-/rollup-plugin-1.4.9.tgz",
+ "integrity": "sha512-OzmX630yoyp5+cIeqlr0lLRW4P/ppabK9QC/pe+lqArCohYKp5gjl5fKgYlQ3ruc6Hm+JyF0KuS160O1ZHAvzw==",
+ "license": "MIT",
+ "dependencies": {
+ "@posthog/cli": "~0.11.1",
+ "@posthog/plugin-utils": "^1.1.3",
+ "magic-string": "^0.30.17"
+ },
+ "peerDependencies": {
+ "rollup": ">= 4.0.0"
+ }
+ },
"node_modules/@posthog/types": {
"version": "1.402.3",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz",
@@ -8010,7 +8077,6 @@
},
"node_modules/detect-libc": {
"version": "2.0.4",
- "devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
diff --git a/package.json b/package.json
index eafc57f..b8056ae 100644
--- a/package.json
+++ b/package.json
@@ -74,6 +74,7 @@
"@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
+ "@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0",
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index d95c1d1..6801e6f 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -30,7 +30,7 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings",
- "reportBug": "Report a Bug"
+ "reportBug": "Report an Issue"
},
"search": {
"title": "Search",
@@ -41,7 +41,8 @@
"recent": "Recent",
"empty": {
"title": "No direct matches",
- "subtitle": "You can still filter the task list with this search."
+ "subtitle": "You can still filter the task list with this search.",
+ "actionsSubtitle": "No view or action matches. Press backspace to search everything."
},
"groups": {
"tasks": "Tasks",
@@ -76,7 +77,14 @@
"open": "Open",
"results_one": "{{count}} result",
"results_other": "{{count}} results",
- "typeToSearch": "Type to search"
+ "typeToSearch": "Type to search",
+ "slashHint": "/ to jump to a view"
+ },
+ "modes": {
+ "actions": {
+ "label": "Go to",
+ "placeholder": "Jump to a view or action"
+ }
}
},
"policyUpdate": {
diff --git a/public/locales/en/settings.json b/public/locales/en/settings.json
index ead40df..ba1e81c 100644
--- a/public/locales/en/settings.json
+++ b/public/locales/en/settings.json
@@ -91,8 +91,8 @@
"description": "Tell us how Donetick is working for you or request a feature."
},
"bugReport": {
- "title": "Report a Bug",
- "description": "Something not working right? Send us the details along with a technical snapshot."
+ "title": "Report an Issue",
+ "description": "Tell us what's not working and we'll attach the technical details for you."
}
}
},
diff --git a/src/analytics/consent.js b/src/analytics/consent.js
index b9862f0..68ebbf5 100644
--- a/src/analytics/consent.js
+++ b/src/analytics/consent.js
@@ -2,7 +2,8 @@ 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.
+// gates captureException(). A self-hosted user can opt into one without the
+// other.
const CONSENT_KEYS = {
analytics: 'analytics_consent',
crash: 'analytics_crash_consent',
diff --git a/src/analytics/eventSchemas.js b/src/analytics/eventSchemas.js
index 4d164aa..3145f7e 100644
--- a/src/analytics/eventSchemas.js
+++ b/src/analytics/eventSchemas.js
@@ -79,12 +79,6 @@ export const EVENT_SCHEMAS = {
}
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.
diff --git a/src/analytics/index.js b/src/analytics/index.js
index b16d7e1..a276283 100644
--- a/src/analytics/index.js
+++ b/src/analytics/index.js
@@ -142,20 +142,6 @@ export const track = (eventName, properties = {}) => {
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 —
@@ -176,7 +162,7 @@ export const captureException = (error, properties = {}) => {
let globalHandlersInstalled = false
/** Reports uncaught exceptions and unhandled promise rejections to
- * PostHog's Error Tracking, gated by the same crash consent as api_error.
+ * PostHog's Error Tracking, gated by crash consent.
* 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
@@ -206,7 +192,7 @@ export const installGlobalErrorHandlers = () => {
/**
* 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).
+ * PostHog by itself (it only unlocks captureException once something crashes).
* Disabling never sends an event and clears identity/queued data.
*/
export const setConsent = async (kind, value, { source } = {}) => {
@@ -236,7 +222,7 @@ export const setConsent = async (kind, value, { source } = {}) => {
} 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.
+ // send captureException() calls through.
if (isConfigured() && !getClientSync()) {
await startPosthog()
}
diff --git a/src/queries/ResourceQueries.jsx b/src/queries/ResourceQueries.jsx
index e12bbea..be2a111 100644
--- a/src/queries/ResourceQueries.jsx
+++ b/src/queries/ResourceQueries.jsx
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query'
+import { setServerVersion } from '../service/DiagnosticsSession'
import { GetResource } from '../utils/Fetcher'
// Helper to check if we have a valid token
@@ -18,6 +19,9 @@ export const useResource = () => {
queryKey: ['resource'],
queryFn: async () => {
const response = await GetResource()
+ // The backend only names its build here, so this is also where crash
+ // reports learn which server version the user was talking to.
+ setServerVersion(response?.api_version, response?.api_commit)
return response
},
staleTime: 6 * 60 * 60 * 1000, // 6 hours in milliseconds
diff --git a/src/search/GlobalSearchPalette.jsx b/src/search/GlobalSearchPalette.jsx
index 1a96625..ef62720 100644
--- a/src/search/GlobalSearchPalette.jsx
+++ b/src/search/GlobalSearchPalette.jsx
@@ -162,6 +162,19 @@ const buildQuickActions = t => [
})),
]
+// Typing "/" as the first character switches the palette into navigation mode,
+// borrowing the slash-menu reflex from Notion and Slack. The sigil itself is
+// stripped from the visible query so the mode reads as state (the chip in the
+// input) rather than as syntax the person has to keep typing around.
+const MODE_SIGIL = '/'
+
+const parseMode = value => {
+ const text = value || ''
+ return text.startsWith(MODE_SIGIL)
+ ? { mode: 'actions', term: text.slice(MODE_SIGIL.length).trimStart() }
+ : { mode: null, term: text }
+}
+
const readRecents = () => {
try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
@@ -268,7 +281,8 @@ const GlobalSearchPalette = ({
const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus())
}, [])
- const [query, setQuery] = useState(initialQuery || '')
+ const [query, setQuery] = useState(() => parseMode(initialQuery).term)
+ const [mode, setMode] = useState(() => parseMode(initialQuery).mode)
const [selectedIndex, setSelectedIndex] = useState(0)
const [recents] = useState(readRecents)
const selectedResultRef = useRef(null)
@@ -317,8 +331,34 @@ const GlobalSearchPalette = ({
[documents],
)
+ const searchActions = useCallback(
+ normalized => {
+ const matches = (quickActionIndex.search(normalized, { limit: 8 }) || [])
+ .map(match => ({ ...match.item, score: match.score ?? 1 }))
+ .sort((a, b) => a.score - b.score)
+ // An action whose title the query starts spelling out ("create la…") is
+ // what the person is after, so it leads. Anything matched only through
+ // its keywords stays below.
+ const leading = matches.filter(action =>
+ action.title.toLocaleLowerCase().startsWith(normalized),
+ )
+ return [
+ ...leading,
+ ...matches.filter(action => !leading.includes(action)),
+ ]
+ },
+ [quickActionIndex],
+ )
+
const results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase()
+
+ // Navigation mode answers only with actions — an empty term lists them all,
+ // which is how the sigil teaches itself the first time someone hits "/".
+ if (mode === 'actions') {
+ return normalized ? searchActions(normalized) : quickActions
+ }
+
if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item]))
const recentResults = recents
@@ -347,15 +387,9 @@ const GlobalSearchPalette = ({
})
.sort((a, b) => a.score - b.score),
)
- const actionMatches = (
- quickActionIndex.search(normalized, { limit: 4 }) || []
- )
- .map(match => ({ ...match.item, score: match.score ?? 1 }))
- .sort((a, b) => a.score - b.score)
-
- // An action whose title the query starts spelling out ("create la…") is
- // what the person is after, so it leads. Anything matched only through its
- // keywords stays below the real content it shares words with.
+ // Keyword-only action matches stay below the real content they share words
+ // with; title-prefix matches lead.
+ const actionMatches = searchActions(normalized).slice(0, 4)
const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized),
)
@@ -375,7 +409,16 @@ const GlobalSearchPalette = ({
route: `/chores?search=${encodeURIComponent(query.trim())}`,
},
]
- }, [documents, query, quickActionIndex, recents, searchIndexes, t])
+ }, [
+ documents,
+ mode,
+ query,
+ quickActions,
+ recents,
+ searchActions,
+ searchIndexes,
+ t,
+ ])
// Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter(
@@ -395,8 +438,25 @@ const GlobalSearchPalette = ({
if (presentation === 'modal') onClose()
}
+ const onQueryChange = value => {
+ setSelectedIndex(0)
+ if (!mode) {
+ const parsed = parseMode(value)
+ setMode(parsed.mode)
+ setQuery(parsed.term)
+ return
+ }
+ setQuery(value)
+ }
+
const onInputKeyDown = event => {
- if (event.key === 'ArrowDown') {
+ // Backspace on an empty term steps back out of the mode, so the sigil is
+ // one keystroke to enter and one to leave.
+ if (event.key === 'Backspace' && mode && !query) {
+ event.preventDefault()
+ setMode(null)
+ setSelectedIndex(0)
+ } else if (event.key === 'ArrowDown') {
event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') {
@@ -423,13 +483,30 @@ const GlobalSearchPalette = ({
},
}}
value={query}
- onChange={event => {
- setQuery(event.target.value)
- setSelectedIndex(0)
- }}
+ onChange={event => onQueryChange(event.target.value)}
onKeyDown={onInputKeyDown}
- placeholder={t('search.placeholder')}
- startDecorator={}
+ placeholder={
+ mode
+ ? t('search.modes.actions.placeholder')
+ : t('search.placeholder')
+ }
+ startDecorator={
+ mode ? (
+ {
+ setMode(null)
+ setSelectedIndex(0)
+ }}
+ >
+ {t('search.modes.actions.label')}
+
+ ) : (
+
+ )
+ }
endDecorator={
isLoading ? (
@@ -468,7 +545,9 @@ const GlobalSearchPalette = ({
/>
{t('search.empty.title')}
- {t('search.empty.subtitle')}
+ {mode
+ ? t('search.empty.actionsSubtitle')
+ : t('search.empty.subtitle')}
)}
@@ -554,8 +633,13 @@ const GlobalSearchPalette = ({
↑↓ {t('search.footer.navigate')}
↵ {t('search.footer.open')}
+ {!mode && (
+
+ {t('search.footer.slashHint')}
+
+ )}
- {query.trim()
+ {query.trim() || mode
? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')}
diff --git a/src/service/DiagnosticsSession.js b/src/service/DiagnosticsSession.js
index 7d2d3a4..0042419 100644
--- a/src/service/DiagnosticsSession.js
+++ b/src/service/DiagnosticsSession.js
@@ -5,7 +5,8 @@
*
* Deliberately dependency-free — ApiClient imports it on the request path, so
* anything imported here would risk a module cycle. Everything is in memory
- * and dies with the tab; nothing is persisted.
+ * and dies with the tab, except the server build, which is remembered across
+ * launches so a crash before the first API answer still names the backend.
*/
const SESSION_STARTED_AT = Date.now()
@@ -25,6 +26,7 @@ const routeTrail = []
const apiFailures = []
let backgroundedCount = 0
let serverVersion = null
+let serverCommit = null
// ---------------------------------------------------------------------------
// Route trail
@@ -67,6 +69,33 @@ export const getPreviousRoute = () =>
// Server identity
// ---------------------------------------------------------------------------
+const SERVER_BUILD_KEY = 'diagnostics_server_build'
+
+// A crash on cold start happens before /resource has answered, and that is
+// exactly when knowing which backend the user is on matters most. Carrying the
+// last known build across launches keeps the report from saying "not reported".
+try {
+ const cached = JSON.parse(localStorage.getItem(SERVER_BUILD_KEY) || 'null')
+ serverVersion = cached?.version ?? null
+ serverCommit = cached?.commit ?? null
+} catch {
+ // corrupt or unavailable storage just means we start without a known build
+}
+
+const rememberServerBuild = (version, commit) => {
+ if (!version && !commit) return
+ serverVersion = version || serverVersion
+ serverCommit = commit || serverCommit
+ try {
+ localStorage.setItem(
+ SERVER_BUILD_KEY,
+ JSON.stringify({ version: serverVersion, commit: serverCommit }),
+ )
+ } catch {
+ // storage full or blocked; the in-memory copy still serves this session
+ }
+}
+
/**
* Picks the server build out of response headers. Costs nothing when the
* server doesn't send them — the field simply stays null.
@@ -74,21 +103,24 @@ export const getPreviousRoute = () =>
export const recordServerVersionFromResponse = response => {
if (serverVersion) return
try {
- serverVersion =
+ rememberServerBuild(
response?.headers?.get?.('x-donetick-version') ||
- response?.headers?.get?.('x-api-version') ||
- null
+ response?.headers?.get?.('x-api-version'),
+ null,
+ )
} catch {
// headers may be inaccessible on opaque responses; not worth reporting
}
}
-export const setServerVersion = version => {
- if (version) serverVersion = version
-}
+/** Authoritative source: what /resource reports about the backend build. */
+export const setServerVersion = (version, commit) =>
+ rememberServerBuild(version, commit)
export const getServerVersion = () => serverVersion
+export const getServerCommit = () => serverCommit
+
// ---------------------------------------------------------------------------
// API failures
// ---------------------------------------------------------------------------
@@ -176,6 +208,7 @@ export const getSessionDiagnostics = async () => {
navigationType: NAVIGATION_TYPE,
backgroundedCount,
serverVersion,
+ serverCommit,
previousRoute: getPreviousRoute(),
routeTrail: getRouteTrail(),
apiFailures: getApiFailures(),
diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js
index 07c44c9..86ccbda 100644
--- a/src/service/ErrorReportService.js
+++ b/src/service/ErrorReportService.js
@@ -153,7 +153,9 @@ export const formatErrorReport = report => {
session.previousRoute ? `Came from: ${session.previousRoute}` : null,
'',
`App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`,
- `Server: ${session.serverVersion ?? 'not reported'}`,
+ `Server: ${session.serverVersion ?? 'not reported'}${
+ session.serverCommit ? ` (${session.serverCommit.slice(0, 8)})` : ''
+ }`,
`Session: ${formatDuration(session.sessionDurationMs)} active · ${
session.navigationType ?? 'unknown'
} start · backgrounded ${session.backgroundedCount ?? 0}×`,
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 6560c5e..1d78e60 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -1,10 +1,8 @@
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'
@@ -230,11 +228,6 @@ 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)
@@ -319,11 +312,6 @@ class ApiClient {
if (!externalAbort) {
networkManager.setServerUnreachable()
recordApiFailure({ endpoint, method: config.method, status: 'network' })
- captureError('api_error', {
- http_status: 'network',
- method: config.method || 'GET',
- operation: normalizeEndpoint(endpoint),
- })
}
console.error('Request failed', error)
throw error
diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx
index bfe02fc..655eb34 100644
--- a/src/views/Chores/ArchivedTasks.jsx
+++ b/src/views/Chores/ArchivedTasks.jsx
@@ -11,8 +11,6 @@ import {
SearchOff,
SelectAll,
Unarchive,
- ViewAgenda,
- ViewModule,
} from '@mui/icons-material'
import {
Badge,
@@ -48,9 +46,7 @@ import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
-import ChoreCard from './ChoreCard'
import ChoreListView from './ChoreListView.jsx'
-import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp'
const sortByUpdatedAtDesc = chores =>
@@ -108,9 +104,6 @@ const ArchivedTasks = () => {
const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([])
const navigate = useNavigate()
- const [viewMode, setViewMode] = useState(
- localStorage.getItem('archivedChoreCardViewMode') || 'default',
- )
const [isLoading, setIsLoading] = useState(true)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const searchInputRef = useRef(null)
@@ -373,15 +366,6 @@ const ArchivedTasks = () => {
}
}, [isMultiSelectMode, selectedChores.size])
- const toggleViewMode = () => {
- const modes = ['default', 'compact']
- const currentIndex = modes.indexOf(viewMode)
- const nextIndex = (currentIndex + 1) % modes.length
- const newMode = modes[nextIndex]
- setViewMode(newMode)
- localStorage.setItem('archivedChoreCardViewMode', newMode)
- }
-
const searchOptions = {
keys: ['name', 'raw_label'],
includeScore: true,
@@ -672,26 +656,6 @@ const ArchivedTasks = () => {
})
}
- // Helper function to render the appropriate card component
- const renderChoreCard = (chore, key) => {
- const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
- return (
- toggleChoreSelection(chore.id)}
- />
- )
- }
-
if (isUserProfileLoading || performers.length === 0 || isLoading) {
return
}
@@ -822,26 +786,6 @@ const ArchivedTasks = () => {
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
/>
- {/* View Mode Toggle Button */}
-
- {viewMode === 'default' ? : }
-
-
{/* Multi-select Toggle Button */}
{
{searchTerm && t('archived.matching', { term: searchTerm })}
-
+
{/* Priority bar clickable area */}
- {chore.priority > 0 && (
+ {chore.priority > 0 && onChipClick && (
{/* Complete Button */}
@@ -434,17 +438,18 @@ const CompactChoreCard = ({
role='none'
tabIndex={0}
onClick={e => {
+ if (!onChipClick) return
e.stopPropagation()
onChipClick({ label: l })
}}
onKeyDown={e => {
- if (e.key === 'Enter' || e.key === ' ') {
+ if (onChipClick && (e.key === 'Enter' || e.key === ' ')) {
e.stopPropagation()
onChipClick({ label: l })
}
}}
style={{
- cursor: 'pointer',
+ cursor: onChipClick ? 'pointer' : 'inherit',
padding: 0,
margin: 0,
display: 'flex',
@@ -477,14 +482,14 @@ const CompactChoreCard = ({
sx={{
transition:
'opacity 0.3s ease-in-out, transform 0.3s ease-in-out, width 0.3s ease-in-out, margin 0.3s ease-in-out',
- opacity: isMultiSelectMode ? 0 : 1,
- transform: isMultiSelectMode
- ? 'translateX(20px) scale(0.8)'
- : 'translateX(0) scale(1)',
- width: isMultiSelectMode ? 0 : 32,
- marginRight: isMultiSelectMode ? 0 : undefined,
+ opacity: showTrailingSlot ? 1 : 0,
+ transform: showTrailingSlot
+ ? 'translateX(0) scale(1)'
+ : 'translateX(20px) scale(0.8)',
+ width: showTrailingSlot ? 32 : 0,
+ marginRight: showTrailingSlot ? undefined : 0,
overflow: 'hidden',
- pointerEvents: isMultiSelectMode ? 'none' : 'auto',
+ pointerEvents: showTrailingSlot ? 'auto' : 'none',
}}
>
{showActions && (
diff --git a/src/views/Modals/ErrorReportModal.jsx b/src/views/Modals/ErrorReportModal.jsx
index 5c8333e..ca8caba 100644
--- a/src/views/Modals/ErrorReportModal.jsx
+++ b/src/views/Modals/ErrorReportModal.jsx
@@ -1,11 +1,11 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import {
- BugReportRounded,
CheckRounded,
ContentCopyRounded,
ExpandMoreRounded,
GitHub,
+ ReportProblemRounded,
} from '@mui/icons-material'
import {
Box,
@@ -165,7 +165,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
}
+ icon={}
color={isBugReport ? 'warning' : 'danger'}
/>
@@ -175,7 +175,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
>
- {isBugReport ? 'Report a bug' : 'Report this problem'}
+ {isBugReport ? 'Report an issue' : 'Report this problem'}
{
id: 'bugreport',
title: t('overview.sections.bugReport.title'),
description: t('overview.sections.bugReport.description'),
- icon: ,
+ icon: ,
onSelect: () => setBugReportOpen(true),
},
]
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index 9b16cc4..f38b6d0 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -2,7 +2,6 @@ import { Capacitor } from '@capacitor/core'
import {
Archive,
ArrowBack,
- BugReport,
FilterAlt,
FolderOpen,
History,
@@ -10,6 +9,7 @@ import {
ListAlt,
Logout,
MenuRounded,
+ ReportProblem,
SearchRounded,
SettingsOutlined,
Toll,
@@ -305,7 +305,7 @@ const NavBar = () => {
}}
>
-
+
{t('navigation.reportBug')}
diff --git a/vite.config.mjs b/vite.config.mjs
index de43190..22d022e 100644
--- a/vite.config.mjs
+++ b/vite.config.mjs
@@ -1,93 +1,118 @@
+import posthog from '@posthog/rollup-plugin'
import react from '@vitejs/plugin-react-swc'
-import { defineConfig } from 'vite'
+import { defineConfig, loadEnv } from 'vite'
import { VitePWA } from 'vite-plugin-pwa'
import pkg from './package.json'
// https://vitejs.dev/config/
-export default defineConfig(({ command }) => ({
- define: {
- 'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
- },
- // Strip console.* / debugger from production bundles only, so dev logging
- // is untouched. `command` is 'build' for `vite build`, 'serve' for the dev server.
- esbuild: {
- drop: command === 'build' ? ['console', 'debugger'] : [],
- },
- plugins: [
- react(),
- VitePWA({
- includeAssets: [
- 'favicon.ico',
- 'robots.txt',
- 'apple-touch-icon.png',
- 'safari-pinned-tab.svg',
- 'mstile-150x150.png',
- ],
- injectManifest: {
- globIgnores: ['index.html'],
- globPatterns: ['**/*.{js,css,html,png,svg}'],
- },
- manifest: {
- background_color: '#ffffff',
- display: 'standalone',
- icons: [
- {
- sizes: '192x192',
- src: '/android-chrome-192x192.png',
- type: 'image/png',
- },
- {
- sizes: '512x512',
- src: '/android-chrome-512x512.png',
- type: 'image/png',
- },
- {
- sizes: '64x64',
- src: 'pwa-64x64.png',
- type: 'image/png',
- },
- {
- sizes: '192x192',
- src: 'pwa-192x192.png',
- type: 'image/png',
- },
- {
- sizes: '512x512',
- src: 'pwa-512x512.png',
- type: 'image/png',
- },
- {
- purpose: 'maskable',
- sizes: '512x512',
- src: 'maskable-icon-512x512.png',
- type: 'image/png',
- },
- ],
- name: 'Donetick: Simplify Tasks & Chores, Together.',
- short_name: 'Donetick',
- theme_color: '#ffffff',
- },
- registerType: 'prompt',
- workbox: {
- clientsClaim: true, // Take control of uncontrolled clients as soon as the service worker becomes active
- maximumFileSizeToCacheInBytes: 6000000, // 6MB
- //Exclude API and Swagger routes from service worker navigation fallback
- navigateFallback: '/index.html',
- navigateFallbackDenylist: [
- /^\/api\//, // Exclude all API routes
- /^\/swagger/, // Exclude all Swagger routes
- ],
- skipWaiting: true, // Force the waiting service worker to become the active service worker
- },
- }),
- ],
+export default ({ command, mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const personalApiKey = process.env.POSTHOG_API_KEY || env.POSTHOG_API_KEY
+ const projectId = process.env.POSTHOG_PROJECT_ID || env.POSTHOG_PROJECT_ID
+ const host = process.env.POSTHOG_HOST || env.POSTHOG_HOST
+ const uploadPosthogSourcemaps =
+ command === 'build' && Boolean(personalApiKey && projectId)
- resolve: {
- alias: [
- {
- find: '@',
- replacement: '/src',
- },
+ return defineConfig({
+ define: {
+ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
+ },
+ // Strip console.* / debugger from production bundles only, so dev logging
+ // is untouched. `command` is 'build' for `vite build`, 'serve' for the dev server.
+ esbuild: {
+ drop: command === 'build' ? ['console', 'debugger'] : [],
+ },
+ plugins: [
+ react(),
+ ...(uploadPosthogSourcemaps
+ ? [
+ posthog({
+ personalApiKey,
+ projectId,
+ host,
+ sourcemaps: {
+ deleteAfterUpload: true,
+ },
+ }),
+ ]
+ : []),
+ VitePWA({
+ includeAssets: [
+ 'favicon.ico',
+ 'robots.txt',
+ 'apple-touch-icon.png',
+ 'safari-pinned-tab.svg',
+ 'mstile-150x150.png',
+ ],
+ injectManifest: {
+ globIgnores: ['index.html'],
+ globPatterns: ['**/*.{js,css,html,png,svg}'],
+ },
+ manifest: {
+ background_color: '#ffffff',
+ display: 'standalone',
+ icons: [
+ {
+ sizes: '192x192',
+ src: '/android-chrome-192x192.png',
+ type: 'image/png',
+ },
+ {
+ sizes: '512x512',
+ src: '/android-chrome-512x512.png',
+ type: 'image/png',
+ },
+ {
+ sizes: '64x64',
+ src: 'pwa-64x64.png',
+ type: 'image/png',
+ },
+ {
+ sizes: '192x192',
+ src: 'pwa-192x192.png',
+ type: 'image/png',
+ },
+ {
+ sizes: '512x512',
+ src: 'pwa-512x512.png',
+ type: 'image/png',
+ },
+ {
+ purpose: 'maskable',
+ sizes: '512x512',
+ src: 'maskable-icon-512x512.png',
+ type: 'image/png',
+ },
+ ],
+ name: 'Donetick: Simplify Tasks & Chores, Together.',
+ short_name: 'Donetick',
+ theme_color: '#ffffff',
+ },
+ registerType: 'prompt',
+ workbox: {
+ // The PWA plugin runs after PostHog's upload/cleanup stage, so prevent
+ // it from leaving separate service-worker source maps in dist.
+ sourcemap: false,
+ clientsClaim: true, // Take control of uncontrolled clients as soon as the service worker becomes active
+ maximumFileSizeToCacheInBytes: 6000000, // 6MB
+ //Exclude API and Swagger routes from service worker navigation fallback
+ navigateFallback: '/index.html',
+ navigateFallbackDenylist: [
+ /^\/api\//, // Exclude all API routes
+ /^\/swagger/, // Exclude all Swagger routes
+ ],
+ skipWaiting: true, // Force the waiting service worker to become the active service worker
+ },
+ }),
],
- },
-}))
+
+ resolve: {
+ alias: [
+ {
+ find: '@',
+ replacement: '/src',
+ },
+ ],
+ },
+ })
+}