Merge pull request #236 from donetick/0815-fixes

0815 fixes
This commit is contained in:
Mohamad Tarbin
2026-08-17 01:03:36 -04:00
committed by GitHub
19 changed files with 380 additions and 233 deletions

View File

@@ -13,3 +13,4 @@ files:
- source: /public/locales/en/*.json - source: /public/locales/en/*.json
translation: /public/locales/%two_letters_code%/%original_file_name% translation: /public/locales/%two_letters_code%/%original_file_name%
update_option: update_as_unapproved update_option: update_as_unapproved
skip_untranslated_strings: true

68
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"@mui/joy": "5.0.0-beta.52", "@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2", "@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4", "@openreplay/tracker": "^14.0.4",
"@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0", "@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0", "@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
@@ -4596,6 +4597,49 @@
"@posthog/types": "^1.402.2" "@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": { "node_modules/@posthog/core": {
"version": "1.47.0", "version": "1.47.0",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.47.0.tgz",
@@ -4605,6 +4649,29 @@
"@posthog/types": "^1.402.2" "@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": { "node_modules/@posthog/types": {
"version": "1.402.3", "version": "1.402.3",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.402.3.tgz",
@@ -8010,7 +8077,6 @@
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.0.4", "version": "2.0.4",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"

View File

@@ -74,6 +74,7 @@
"@mui/joy": "5.0.0-beta.52", "@mui/joy": "5.0.0-beta.52",
"@mui/material": "^5.15.2", "@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4", "@openreplay/tracker": "^14.0.4",
"@posthog/rollup-plugin": "^1.4.9",
"@revenuecat/purchases-capacitor": "^12.0.0", "@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0", "@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",

View File

@@ -30,7 +30,7 @@
"activities": "Activities", "activities": "Activities",
"points": "Points", "points": "Points",
"settings": "Settings", "settings": "Settings",
"reportBug": "Report a Bug" "reportBug": "Report an Issue"
}, },
"search": { "search": {
"title": "Search", "title": "Search",
@@ -41,7 +41,8 @@
"recent": "Recent", "recent": "Recent",
"empty": { "empty": {
"title": "No direct matches", "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": { "groups": {
"tasks": "Tasks", "tasks": "Tasks",
@@ -76,7 +77,14 @@
"open": "Open", "open": "Open",
"results_one": "{{count}} result", "results_one": "{{count}} result",
"results_other": "{{count}} results", "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": { "policyUpdate": {

View File

@@ -91,8 +91,8 @@
"description": "Tell us how Donetick is working for you or request a feature." "description": "Tell us how Donetick is working for you or request a feature."
}, },
"bugReport": { "bugReport": {
"title": "Report a Bug", "title": "Report an Issue",
"description": "Something not working right? Send us the details along with a technical snapshot." "description": "Tell us what's not working and we'll attach the technical details for you."
} }
} }
}, },

View File

@@ -2,7 +2,8 @@ import { Preferences } from '@capacitor/preferences'
// Two independent consent axes, matching the existing onboarding UI // Two independent consent axes, matching the existing onboarding UI
// (HeardAboutView's PrivacyPreferences): "analytics" gates track(), "crash" // (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 = { const CONSENT_KEYS = {
analytics: 'analytics_consent', analytics: 'analytics_consent',
crash: 'analytics_crash_consent', crash: 'analytics_crash_consent',

View File

@@ -79,12 +79,6 @@ export const EVENT_SCHEMAS = {
} }
export const ERROR_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 // No message/stack field here by design — those come from the real Error
// object passed to posthog.captureException() itself, not from this // object passed to posthog.captureException() itself, not from this
// sanitized properties bag. This schema only classifies how it was caught. // sanitized properties bag. This schema only classifies how it was caught.

View File

@@ -142,20 +142,6 @@ export const track = (eventName, properties = {}) => {
posthog.capture(eventName, sanitized) 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 * Frontend crashes only. Uses captureException (not capture) so these land
* on PostHog's Error Tracking page with a genuine message + stack trace — * on PostHog's Error Tracking page with a genuine message + stack trace —
@@ -176,7 +162,7 @@ export const captureException = (error, properties = {}) => {
let globalHandlersInstalled = false let globalHandlersInstalled = false
/** Reports uncaught exceptions and unhandled promise rejections to /** 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 * Complements, doesn't overlap with, src/views/Error.jsx: that's a React
* Router error-boundary screen for render/loader errors, which React catches * Router error-boundary screen for render/loader errors, which React catches
* before they ever reach window.onerror — a different class of failure, with * 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 * kind: 'analytics' | 'crash'. Enabling analytics (re-)initializes PostHog
* if needed and sends analytics_enabled; enabling crash-only never talks to * 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. * Disabling never sends an event and clears identity/queued data.
*/ */
export const setConsent = async (kind, value, { source } = {}) => { export const setConsent = async (kind, value, { source } = {}) => {
@@ -236,7 +222,7 @@ export const setConsent = async (kind, value, { source } = {}) => {
} else if (kind === 'crash') { } else if (kind === 'crash') {
// Crash reporting alone doesn't need PostHog started with the analytics // Crash reporting alone doesn't need PostHog started with the analytics
// super-properties path, but it does need a live client + identity to // super-properties path, but it does need a live client + identity to
// send captureError() calls through. // send captureException() calls through.
if (isConfigured() && !getClientSync()) { if (isConfigured() && !getClientSync()) {
await startPosthog() await startPosthog()
} }

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { setServerVersion } from '../service/DiagnosticsSession'
import { GetResource } from '../utils/Fetcher' import { GetResource } from '../utils/Fetcher'
// Helper to check if we have a valid token // Helper to check if we have a valid token
@@ -18,6 +19,9 @@ export const useResource = () => {
queryKey: ['resource'], queryKey: ['resource'],
queryFn: async () => { queryFn: async () => {
const response = await GetResource() 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 return response
}, },
staleTime: 6 * 60 * 60 * 1000, // 6 hours in milliseconds staleTime: 6 * 60 * 60 * 1000, // 6 hours in milliseconds

View File

@@ -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 = () => { const readRecents = () => {
try { try {
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || [] return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
@@ -268,7 +281,8 @@ const GlobalSearchPalette = ({
const focusInputRef = useCallback(node => { const focusInputRef = useCallback(node => {
if (node) requestAnimationFrame(() => node.focus()) 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 [selectedIndex, setSelectedIndex] = useState(0)
const [recents] = useState(readRecents) const [recents] = useState(readRecents)
const selectedResultRef = useRef(null) const selectedResultRef = useRef(null)
@@ -317,8 +331,34 @@ const GlobalSearchPalette = ({
[documents], [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 results = useMemo(() => {
const normalized = query.trim().toLocaleLowerCase() 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) { if (!normalized) {
const currentById = new Map(documents.map(item => [item.id, item])) const currentById = new Map(documents.map(item => [item.id, item]))
const recentResults = recents const recentResults = recents
@@ -347,15 +387,9 @@ const GlobalSearchPalette = ({
}) })
.sort((a, b) => a.score - b.score), .sort((a, b) => a.score - b.score),
) )
const actionMatches = ( // Keyword-only action matches stay below the real content they share words
quickActionIndex.search(normalized, { limit: 4 }) || [] // with; title-prefix matches lead.
) const actionMatches = searchActions(normalized).slice(0, 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.
const leadingActions = actionMatches.filter(action => const leadingActions = actionMatches.filter(action =>
action.title.toLocaleLowerCase().startsWith(normalized), action.title.toLocaleLowerCase().startsWith(normalized),
) )
@@ -375,7 +409,16 @@ const GlobalSearchPalette = ({
route: `/chores?search=${encodeURIComponent(query.trim())}`, 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. // Everything except the always-present "filter the task list" fallback.
const matchCount = results.filter( const matchCount = results.filter(
@@ -395,8 +438,25 @@ const GlobalSearchPalette = ({
if (presentation === 'modal') onClose() 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 => { 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() event.preventDefault()
setSelectedIndex(index => Math.min(index + 1, results.length - 1)) setSelectedIndex(index => Math.min(index + 1, results.length - 1))
} else if (event.key === 'ArrowUp') { } else if (event.key === 'ArrowUp') {
@@ -423,13 +483,30 @@ const GlobalSearchPalette = ({
}, },
}} }}
value={query} value={query}
onChange={event => { onChange={event => onQueryChange(event.target.value)}
setQuery(event.target.value)
setSelectedIndex(0)
}}
onKeyDown={onInputKeyDown} onKeyDown={onInputKeyDown}
placeholder={t('search.placeholder')} placeholder={
startDecorator={<SearchRounded />} mode
? t('search.modes.actions.placeholder')
: t('search.placeholder')
}
startDecorator={
mode ? (
<Chip
size='sm'
variant='soft'
color='primary'
onClick={() => {
setMode(null)
setSelectedIndex(0)
}}
>
{t('search.modes.actions.label')}
</Chip>
) : (
<SearchRounded />
)
}
endDecorator={ endDecorator={
isLoading ? ( isLoading ? (
<CircularProgress size='sm' /> <CircularProgress size='sm' />
@@ -468,7 +545,9 @@ const GlobalSearchPalette = ({
/> />
<Typography level='title-md'>{t('search.empty.title')}</Typography> <Typography level='title-md'>{t('search.empty.title')}</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}> <Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{t('search.empty.subtitle')} {mode
? t('search.empty.actionsSubtitle')
: t('search.empty.subtitle')}
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -554,8 +633,13 @@ const GlobalSearchPalette = ({
{t('search.footer.navigate')} {t('search.footer.navigate')}
</Typography> </Typography>
<Typography level='body-xs'> {t('search.footer.open')}</Typography> <Typography level='body-xs'> {t('search.footer.open')}</Typography>
{!mode && (
<Typography level='body-xs'>
{t('search.footer.slashHint')}
</Typography>
)}
<Typography level='body-xs' sx={{ ml: 'auto' }}> <Typography level='body-xs' sx={{ ml: 'auto' }}>
{query.trim() {query.trim() || mode
? t('search.footer.results', { count: matchCount }) ? t('search.footer.results', { count: matchCount })
: t('search.footer.typeToSearch')} : t('search.footer.typeToSearch')}
</Typography> </Typography>

View File

@@ -5,7 +5,8 @@
* *
* Deliberately dependency-free — ApiClient imports it on the request path, so * Deliberately dependency-free — ApiClient imports it on the request path, so
* anything imported here would risk a module cycle. Everything is in memory * 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() const SESSION_STARTED_AT = Date.now()
@@ -25,6 +26,7 @@ const routeTrail = []
const apiFailures = [] const apiFailures = []
let backgroundedCount = 0 let backgroundedCount = 0
let serverVersion = null let serverVersion = null
let serverCommit = null
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Route trail // Route trail
@@ -67,6 +69,33 @@ export const getPreviousRoute = () =>
// Server identity // 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 * Picks the server build out of response headers. Costs nothing when the
* server doesn't send them — the field simply stays null. * server doesn't send them — the field simply stays null.
@@ -74,21 +103,24 @@ export const getPreviousRoute = () =>
export const recordServerVersionFromResponse = response => { export const recordServerVersionFromResponse = response => {
if (serverVersion) return if (serverVersion) return
try { try {
serverVersion = rememberServerBuild(
response?.headers?.get?.('x-donetick-version') || response?.headers?.get?.('x-donetick-version') ||
response?.headers?.get?.('x-api-version') || response?.headers?.get?.('x-api-version'),
null null,
)
} catch { } catch {
// headers may be inaccessible on opaque responses; not worth reporting // headers may be inaccessible on opaque responses; not worth reporting
} }
} }
export const setServerVersion = version => { /** Authoritative source: what /resource reports about the backend build. */
if (version) serverVersion = version export const setServerVersion = (version, commit) =>
} rememberServerBuild(version, commit)
export const getServerVersion = () => serverVersion export const getServerVersion = () => serverVersion
export const getServerCommit = () => serverCommit
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// API failures // API failures
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -176,6 +208,7 @@ export const getSessionDiagnostics = async () => {
navigationType: NAVIGATION_TYPE, navigationType: NAVIGATION_TYPE,
backgroundedCount, backgroundedCount,
serverVersion, serverVersion,
serverCommit,
previousRoute: getPreviousRoute(), previousRoute: getPreviousRoute(),
routeTrail: getRouteTrail(), routeTrail: getRouteTrail(),
apiFailures: getApiFailures(), apiFailures: getApiFailures(),

View File

@@ -153,7 +153,9 @@ export const formatErrorReport = report => {
session.previousRoute ? `Came from: ${session.previousRoute}` : null, session.previousRoute ? `Came from: ${session.previousRoute}` : null,
'', '',
`App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`, `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: ${formatDuration(session.sessionDurationMs)} active · ${
session.navigationType ?? 'unknown' session.navigationType ?? 'unknown'
} start · backgrounded ${session.backgroundedCount ?? 0}×`, } start · backgrounded ${session.backgroundedCount ?? 0}×`,

View File

@@ -1,10 +1,8 @@
import { Preferences } from '@capacitor/preferences' import { Preferences } from '@capacitor/preferences'
import { captureError } from '../analytics'
import { API_URL } from '../Config' import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager' import { networkManager } from '../hooks/NetworkManager'
import { import {
normalizeEndpoint,
recordApiFailure, recordApiFailure,
recordServerVersionFromResponse, recordServerVersionFromResponse,
} from '../service/DiagnosticsSession' } from '../service/DiagnosticsSession'
@@ -230,11 +228,6 @@ class ApiClient {
method: config.method, method: config.method,
status: response.status, status: response.status,
}) })
captureError('api_error', {
http_status: String(response.status),
method: config.method || 'GET',
operation: normalizeEndpoint(endpoint),
})
} }
// 2. Check for 401 (Unauthorized) // 2. Check for 401 (Unauthorized)
@@ -319,11 +312,6 @@ class ApiClient {
if (!externalAbort) { if (!externalAbort) {
networkManager.setServerUnreachable() networkManager.setServerUnreachable()
recordApiFailure({ endpoint, method: config.method, status: 'network' }) 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) console.error('Request failed', error)
throw error throw error

View File

@@ -11,8 +11,6 @@ import {
SearchOff, SearchOff,
SelectAll, SelectAll,
Unarchive, Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Badge, Badge,
@@ -48,9 +46,7 @@ import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
import Priorities from '../../utils/Priorities' import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import ChoreListView from './ChoreListView.jsx' import ChoreListView from './ChoreListView.jsx'
import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp' import MultiSelectHelp from './MultiSelectHelp'
const sortByUpdatedAtDesc = chores => const sortByUpdatedAtDesc = chores =>
@@ -108,9 +104,6 @@ const ArchivedTasks = () => {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([]) const [performers, setPerformers] = useState([])
const navigate = useNavigate() const navigate = useNavigate()
const [viewMode, setViewMode] = useState(
localStorage.getItem('archivedChoreCardViewMode') || 'default',
)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const searchInputRef = useRef(null) const searchInputRef = useRef(null)
@@ -373,15 +366,6 @@ const ArchivedTasks = () => {
} }
}, [isMultiSelectMode, selectedChores.size]) }, [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 = { const searchOptions = {
keys: ['name', 'raw_label'], keys: ['name', 'raw_label'],
includeScore: true, 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 (
<CardComponent
key={key || chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
viewOnly={false}
showActions={false}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
if (isUserProfileLoading || performers.length === 0 || isLoading) { if (isUserProfileLoading || performers.length === 0 || isLoading) {
return <LoadingComponent /> return <LoadingComponent />
} }
@@ -822,26 +786,6 @@ const ArchivedTasks = () => {
isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'} isActive={sortBy !== 'archivedAt' || sortDirection !== 'desc'}
/> />
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
viewMode === 'default'
? t('archived.switchToCompact')
: t('archived.switchToCard')
}
>
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
</IconButton>
{/* Multi-select Toggle Button */} {/* Multi-select Toggle Button */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}> <Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton <IconButton
@@ -1137,12 +1081,12 @@ const ArchivedTasks = () => {
{searchTerm && t('archived.matching', { term: searchTerm })} {searchTerm && t('archived.matching', { term: searchTerm })}
</Typography> </Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}> <List sx={{ gap: 0 }}>
<ChoreListView <ChoreListView
chores={finalChores} chores={finalChores}
// viewOnly={true} // viewOnly={true}
showActions={false} showActions={false}
viewMode={viewMode} viewMode='compact'
membersData={membersData} membersData={membersData}
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores} selectedChores={selectedChores}

View File

@@ -109,6 +109,8 @@ const CompactChoreCard = ({
return parts.join(' • ') return parts.join(' • ')
} }
const showLeadingSlot = showActions || isMultiSelectMode
const showTrailingSlot = showActions && !isMultiSelectMode
return ( return (
<Box <Box
@@ -145,7 +147,7 @@ const CompactChoreCard = ({
}} }}
> >
{/* Priority bar clickable area */} {/* Priority bar clickable area */}
{chore.priority > 0 && ( {chore.priority > 0 && onChipClick && (
<Box <Box
sx={{ sx={{
position: 'absolute', position: 'absolute',
@@ -170,10 +172,12 @@ const CompactChoreCard = ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
width: 40, width: showLeadingSlot ? 40 : 0,
height: 40, height: 40,
mr: 1.5, mr: showLeadingSlot ? 1.5 : 0,
flexShrink: 0, flexShrink: 0,
overflow: 'hidden',
transition: 'width 0.3s ease-in-out, margin 0.3s ease-in-out',
}} }}
> >
{/* Complete Button */} {/* Complete Button */}
@@ -434,17 +438,18 @@ const CompactChoreCard = ({
role='none' role='none'
tabIndex={0} tabIndex={0}
onClick={e => { onClick={e => {
if (!onChipClick) return
e.stopPropagation() e.stopPropagation()
onChipClick({ label: l }) onChipClick({ label: l })
}} }}
onKeyDown={e => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { if (onChipClick && (e.key === 'Enter' || e.key === ' ')) {
e.stopPropagation() e.stopPropagation()
onChipClick({ label: l }) onChipClick({ label: l })
} }
}} }}
style={{ style={{
cursor: 'pointer', cursor: onChipClick ? 'pointer' : 'inherit',
padding: 0, padding: 0,
margin: 0, margin: 0,
display: 'flex', display: 'flex',
@@ -477,14 +482,14 @@ const CompactChoreCard = ({
sx={{ sx={{
transition: 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 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, opacity: showTrailingSlot ? 1 : 0,
transform: isMultiSelectMode transform: showTrailingSlot
? 'translateX(20px) scale(0.8)' ? 'translateX(0) scale(1)'
: 'translateX(0) scale(1)', : 'translateX(20px) scale(0.8)',
width: isMultiSelectMode ? 0 : 32, width: showTrailingSlot ? 32 : 0,
marginRight: isMultiSelectMode ? 0 : undefined, marginRight: showTrailingSlot ? undefined : 0,
overflow: 'hidden', overflow: 'hidden',
pointerEvents: isMultiSelectMode ? 'none' : 'auto', pointerEvents: showTrailingSlot ? 'auto' : 'none',
}} }}
> >
{showActions && ( {showActions && (

View File

@@ -1,11 +1,11 @@
import { Browser } from '@capacitor/browser' import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import { import {
BugReportRounded,
CheckRounded, CheckRounded,
ContentCopyRounded, ContentCopyRounded,
ExpandMoreRounded, ExpandMoreRounded,
GitHub, GitHub,
ReportProblemRounded,
} from '@mui/icons-material' } from '@mui/icons-material'
import { import {
Box, Box,
@@ -165,7 +165,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
<Stack spacing={2}> <Stack spacing={2}>
<Box sx={{ ...enter(0) }}> <Box sx={{ ...enter(0) }}>
<IconHalo <IconHalo
icon={<BugReportRounded />} icon={<ReportProblemRounded />}
color={isBugReport ? 'warning' : 'danger'} color={isBugReport ? 'warning' : 'danger'}
/> />
</Box> </Box>
@@ -175,7 +175,7 @@ const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
level='h4' level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }} sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
> >
{isBugReport ? 'Report a bug' : 'Report this problem'} {isBugReport ? 'Report an issue' : 'Report this problem'}
</Typography> </Typography>
<Typography <Typography
level='body-sm' level='body-sm'

View File

@@ -1,4 +1,9 @@
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material' import {
ChevronRight,
Feedback,
ReportProblem,
Star,
} from '@mui/icons-material'
import { import {
Avatar, Avatar,
Box, Box,
@@ -52,7 +57,7 @@ const SettingsOverview = () => {
id: 'bugreport', id: 'bugreport',
title: t('overview.sections.bugReport.title'), title: t('overview.sections.bugReport.title'),
description: t('overview.sections.bugReport.description'), description: t('overview.sections.bugReport.description'),
icon: <BugReport />, icon: <ReportProblem />,
onSelect: () => setBugReportOpen(true), onSelect: () => setBugReportOpen(true),
}, },
] ]

View File

@@ -2,7 +2,6 @@ import { Capacitor } from '@capacitor/core'
import { import {
Archive, Archive,
ArrowBack, ArrowBack,
BugReport,
FilterAlt, FilterAlt,
FolderOpen, FolderOpen,
History, History,
@@ -10,6 +9,7 @@ import {
ListAlt, ListAlt,
Logout, Logout,
MenuRounded, MenuRounded,
ReportProblem,
SearchRounded, SearchRounded,
SettingsOutlined, SettingsOutlined,
Toll, Toll,
@@ -305,7 +305,7 @@ const NavBar = () => {
}} }}
> >
<ListItemDecorator> <ListItemDecorator>
<BugReport /> <ReportProblem />
</ListItemDecorator> </ListItemDecorator>
<ListItemContent>{t('navigation.reportBug')}</ListItemContent> <ListItemContent>{t('navigation.reportBug')}</ListItemContent>
</ListItemButton> </ListItemButton>

View File

@@ -1,93 +1,118 @@
import posthog from '@posthog/rollup-plugin'
import react from '@vitejs/plugin-react-swc' import react from '@vitejs/plugin-react-swc'
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import pkg from './package.json' import pkg from './package.json'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ command }) => ({ export default ({ command, mode }) => {
define: { const env = loadEnv(mode, process.cwd(), '')
'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version), const personalApiKey = process.env.POSTHOG_API_KEY || env.POSTHOG_API_KEY
}, const projectId = process.env.POSTHOG_PROJECT_ID || env.POSTHOG_PROJECT_ID
// Strip console.* / debugger from production bundles only, so dev logging const host = process.env.POSTHOG_HOST || env.POSTHOG_HOST
// is untouched. `command` is 'build' for `vite build`, 'serve' for the dev server. const uploadPosthogSourcemaps =
esbuild: { command === 'build' && Boolean(personalApiKey && projectId)
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
},
}),
],
resolve: { return defineConfig({
alias: [ define: {
{ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
find: '@', },
replacement: '/src', // 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',
},
],
},
})
}