@@ -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
|
||||
|
||||
68
package-lock.json
generated
68
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
onChange={event => onQueryChange(event.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={
|
||||
mode
|
||||
? t('search.modes.actions.placeholder')
|
||||
: t('search.placeholder')
|
||||
}
|
||||
startDecorator={
|
||||
mode ? (
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
setMode(null)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder={t('search.placeholder')}
|
||||
startDecorator={<SearchRounded />}
|
||||
>
|
||||
{t('search.modes.actions.label')}
|
||||
</Chip>
|
||||
) : (
|
||||
<SearchRounded />
|
||||
)
|
||||
}
|
||||
endDecorator={
|
||||
isLoading ? (
|
||||
<CircularProgress size='sm' />
|
||||
@@ -468,7 +545,9 @@ const GlobalSearchPalette = ({
|
||||
/>
|
||||
<Typography level='title-md'>{t('search.empty.title')}</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
{t('search.empty.subtitle')}
|
||||
{mode
|
||||
? t('search.empty.actionsSubtitle')
|
||||
: t('search.empty.subtitle')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -554,8 +633,13 @@ const GlobalSearchPalette = ({
|
||||
↑↓ {t('search.footer.navigate')}
|
||||
</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' }}>
|
||||
{query.trim()
|
||||
{query.trim() || mode
|
||||
? t('search.footer.results', { count: matchCount })
|
||||
: t('search.footer.typeToSearch')}
|
||||
</Typography>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}×`,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<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) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
@@ -822,26 +786,6 @@ const ArchivedTasks = () => {
|
||||
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 */}
|
||||
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
@@ -1137,12 +1081,12 @@ const ArchivedTasks = () => {
|
||||
{searchTerm && t('archived.matching', { term: searchTerm })}
|
||||
</Typography>
|
||||
|
||||
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
|
||||
<List sx={{ gap: 0 }}>
|
||||
<ChoreListView
|
||||
chores={finalChores}
|
||||
// viewOnly={true}
|
||||
showActions={false}
|
||||
viewMode={viewMode}
|
||||
viewMode='compact'
|
||||
membersData={membersData}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
|
||||
@@ -109,6 +109,8 @@ const CompactChoreCard = ({
|
||||
|
||||
return parts.join(' • ')
|
||||
}
|
||||
const showLeadingSlot = showActions || isMultiSelectMode
|
||||
const showTrailingSlot = showActions && !isMultiSelectMode
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -145,7 +147,7 @@ const CompactChoreCard = ({
|
||||
}}
|
||||
>
|
||||
{/* Priority bar clickable area */}
|
||||
{chore.priority > 0 && (
|
||||
{chore.priority > 0 && onChipClick && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
@@ -170,10 +172,12 @@ const CompactChoreCard = ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 40,
|
||||
width: showLeadingSlot ? 40 : 0,
|
||||
height: 40,
|
||||
mr: 1.5,
|
||||
mr: showLeadingSlot ? 1.5 : 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
transition: 'width 0.3s ease-in-out, margin 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{/* 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 && (
|
||||
|
||||
@@ -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 }) => {
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ ...enter(0) }}>
|
||||
<IconHalo
|
||||
icon={<BugReportRounded />}
|
||||
icon={<ReportProblemRounded />}
|
||||
color={isBugReport ? 'warning' : 'danger'}
|
||||
/>
|
||||
</Box>
|
||||
@@ -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'}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { BugReport, ChevronRight, Feedback, Star } from '@mui/icons-material'
|
||||
import {
|
||||
ChevronRight,
|
||||
Feedback,
|
||||
ReportProblem,
|
||||
Star,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -52,7 +57,7 @@ const SettingsOverview = () => {
|
||||
id: 'bugreport',
|
||||
title: t('overview.sections.bugReport.title'),
|
||||
description: t('overview.sections.bugReport.description'),
|
||||
icon: <BugReport />,
|
||||
icon: <ReportProblem />,
|
||||
onSelect: () => setBugReportOpen(true),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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 = () => {
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<BugReport />
|
||||
<ReportProblem />
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>{t('navigation.reportBug')}</ListItemContent>
|
||||
</ListItemButton>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
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 }) => ({
|
||||
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)
|
||||
|
||||
return defineConfig({
|
||||
define: {
|
||||
'import.meta.env.VITE_APP_VERSION': JSON.stringify(pkg.version),
|
||||
},
|
||||
@@ -15,6 +24,18 @@ export default defineConfig(({ command }) => ({
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
...(uploadPosthogSourcemaps
|
||||
? [
|
||||
posthog({
|
||||
personalApiKey,
|
||||
projectId,
|
||||
host,
|
||||
sourcemaps: {
|
||||
deleteAfterUpload: true,
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
VitePWA({
|
||||
includeAssets: [
|
||||
'favicon.ico',
|
||||
@@ -69,6 +90,9 @@ export default defineConfig(({ command }) => ({
|
||||
},
|
||||
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
|
||||
@@ -90,4 +114,5 @@ export default defineConfig(({ command }) => ({
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user