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