diff --git a/src/App.jsx b/src/App.jsx
index ba2bc65..ec3684a 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -1,22 +1,23 @@
-import NavBar from '@/views/components/NavBar'
+import './styles/safe-area.css'
+
import { Button, Typography, useColorScheme } from '@mui/joy'
import { useCallback, useEffect } from 'react'
-import { Outlet, useNavigate } from 'react-router-dom'
+import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
+
+import NavBar from '@/views/components/NavBar'
+
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
+import SSEProvider from './contexts/SSEContext'
import { AuthProvider } from './hooks/useAuth.jsx'
-
import useOnboardingGate from './hooks/useOnboardingGate'
import useStatusBar from './hooks/useStatusBar'
-import { useResource } from './queries/ResourceQueries'
-import './styles/safe-area.css'
-
-import SSEProvider from './contexts/SSEContext'
-import { useNotification } from './service/NotificationProvider'
-
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
+import { useResource } from './queries/ResourceQueries'
+import { recordRoute } from './service/DiagnosticsSession'
+import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
@@ -32,8 +33,15 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
const AppContent = () => {
const { showNotification } = useNotification()
+ const location = useLocation()
useSyncOnReconnect()
+ // Every route renders through this Outlet, so one listener here gives crash
+ // reports the trail that led to the failure.
+ useEffect(() => {
+ recordRoute(location.pathname)
+ }, [location.pathname])
+
// // First-launch native users see the onboarding flow before anything else.
useOnboardingGate()
@@ -41,8 +49,8 @@ const AppContent = () => {
useStatusBar()
const {
- offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
needRefresh: [needRefresh, setNeedRefresh],
+ offlineReady: [offlineReady, setOfflineReady],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
@@ -100,7 +108,7 @@ const AppContent = () => {
}
function App() {
- const resource = useResource() // eslint-disable-line no-unused-vars
+ const resource = useResource()
const { mode, systemMode } = useColorScheme()
const navigate = useNavigate()
diff --git a/src/service/DiagnosticsSession.js b/src/service/DiagnosticsSession.js
new file mode 100644
index 0000000..1592f5b
--- /dev/null
+++ b/src/service/DiagnosticsSession.js
@@ -0,0 +1,189 @@
+/**
+ * Ambient session state worth having the moment something breaks: how long the
+ * user has been in the app, how they got to the screen that failed, which
+ * server they were talking to and what it had already refused.
+ *
+ * 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.
+ */
+
+const SESSION_STARTED_AT = Date.now()
+const SESSION_ID = `${SESSION_STARTED_AT.toString(36)}-${Math.random()
+ .toString(36)
+ .slice(2, 8)}`
+
+// A cold start (vs. a reload of an already-running app) changes what a crash
+// means: a reload loop looks very different from a first-launch failure.
+const NAVIGATION_TYPE =
+ performance.getEntriesByType?.('navigation')?.[0]?.type ?? 'unknown'
+
+const MAX_ROUTES = 10
+const MAX_API_FAILURES = 8
+
+const routeTrail = []
+const apiFailures = []
+let backgroundedCount = 0
+let serverVersion = null
+
+// ---------------------------------------------------------------------------
+// Route trail
+// ---------------------------------------------------------------------------
+
+/**
+ * Records a navigation and closes out the dwell time on the previous screen.
+ * "The crash happened 400ms after landing on /chores/12 from /chores" is a
+ * far better bug report than "the crash happened on /chores/12".
+ */
+export const recordRoute = path => {
+ if (!path) return
+ const now = Date.now()
+ const previous = routeTrail[routeTrail.length - 1]
+ if (previous) {
+ if (previous.path === path) return
+ previous.dwellMs = now - previous.at
+ }
+ routeTrail.push({ path, at: now })
+ if (routeTrail.length > MAX_ROUTES) routeTrail.shift()
+}
+
+export const getRouteTrail = () => {
+ const now = Date.now()
+ return routeTrail.map((entry, index) => ({
+ path: entry.path,
+ // The current screen has no closing dwell yet; measure it up to now.
+ dwellMs:
+ entry.dwellMs ??
+ (index === routeTrail.length - 1 ? now - entry.at : null),
+ msAgo: now - entry.at,
+ }))
+}
+
+/** The screen the user came from, which is usually where the bug was planted. */
+export const getPreviousRoute = () =>
+ routeTrail.length > 1 ? routeTrail[routeTrail.length - 2].path : null
+
+// ---------------------------------------------------------------------------
+// Server identity
+// ---------------------------------------------------------------------------
+
+/**
+ * Picks the server build out of response headers. Costs nothing when the
+ * server doesn't send them — the field simply stays null.
+ */
+export const recordServerVersionFromResponse = response => {
+ if (serverVersion) return
+ try {
+ serverVersion =
+ response?.headers?.get?.('x-donetick-version') ||
+ 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
+}
+
+export const getServerVersion = () => serverVersion
+
+// ---------------------------------------------------------------------------
+// API failures
+// ---------------------------------------------------------------------------
+
+/** Strips ids and query strings so failures group by endpoint, not by row. */
+const normalizeEndpoint = endpoint =>
+ String(endpoint || '')
+ .split('?')[0]
+ .replace(/\/\d+/g, '/:id')
+
+export const recordApiFailure = ({ endpoint, method, status }) => {
+ apiFailures.push({
+ at: Date.now(),
+ method: method || 'GET',
+ endpoint: normalizeEndpoint(endpoint),
+ status: status ?? 'network',
+ })
+ if (apiFailures.length > MAX_API_FAILURES) apiFailures.shift()
+}
+
+export const getApiFailures = () => {
+ const now = Date.now()
+ return apiFailures.map(failure => ({
+ method: failure.method,
+ endpoint: failure.endpoint,
+ status: failure.status,
+ msAgo: now - failure.at,
+ }))
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') backgroundedCount += 1
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Snapshot
+// ---------------------------------------------------------------------------
+
+const getServiceWorkerState = async () => {
+ if (!('serviceWorker' in navigator)) return { supported: false }
+ try {
+ const registration = await navigator.serviceWorker.getRegistration()
+ return {
+ supported: true,
+ controlled: Boolean(navigator.serviceWorker.controller),
+ // A waiting worker means the user is running a stale bundle against a
+ // newer deploy — the usual cause of chunk-load failures after a release.
+ updateWaiting: Boolean(registration?.waiting),
+ }
+ } catch {
+ return { supported: true, controlled: null, updateWaiting: null }
+ }
+}
+
+const getStorageState = async () => {
+ try {
+ const { quota, usage } = await navigator.storage.estimate()
+ return {
+ usageMb: Math.round((usage / 1048576) * 10) / 10,
+ quotaMb: Math.round(quota / 1048576),
+ // Storage pressure produces failures that look like anything but.
+ pressure: quota ? Math.round((usage / quota) * 100) : null,
+ }
+ } catch {
+ return null
+ }
+}
+
+export const getSessionDiagnostics = async () => {
+ const [serviceWorker, storage] = await Promise.all([
+ getServiceWorkerState(),
+ getStorageState(),
+ ])
+
+ return {
+ sessionId: SESSION_ID,
+ sessionStartedAt: new Date(SESSION_STARTED_AT).toISOString(),
+ sessionDurationMs: Date.now() - SESSION_STARTED_AT,
+ navigationType: NAVIGATION_TYPE,
+ backgroundedCount,
+ serverVersion,
+ previousRoute: getPreviousRoute(),
+ routeTrail: getRouteTrail(),
+ apiFailures: getApiFailures(),
+ // Chromium only; absent elsewhere rather than faked.
+ heapUsedMb: performance.memory
+ ? Math.round(performance.memory.usedJSHeapSize / 1048576)
+ : null,
+ storage,
+ serviceWorker,
+ }
+}
diff --git a/src/service/ErrorReportService.js b/src/service/ErrorReportService.js
new file mode 100644
index 0000000..c290c52
--- /dev/null
+++ b/src/service/ErrorReportService.js
@@ -0,0 +1,295 @@
+import { getSessionDiagnostics } from './DiagnosticsSession'
+import { collectFeedbackContext } from './FeedbackService'
+
+const GITHUB_URL = 'https://github.com/donetick/donetick'
+
+// Reports go to the same relay as feedback unless a dedicated one is set, so
+// self-hosters who point at their own Worker get both for the price of one.
+const REPORT_URL =
+ import.meta.env.VITE_ERROR_REPORT_WEBHOOK_URL ||
+ import.meta.env.VITE_FEEDBACK_WEBHOOK_URL
+
+// Same trap as feedback: a chat webhook pasted straight in would reject our
+// schema and ship inside the public bundle. Relay through the Worker instead.
+const isRawChatWebhook = url =>
+ /^https:\/\/(discord(app)?\.com\/api\/webhooks|hooks\.slack\.com)/i.test(
+ url || '',
+ )
+
+export const SUBMIT_RESULT = {
+ SENT: 'sent',
+ FAILED: 'failed',
+ UNCONFIGURED: 'unconfigured',
+ MISCONFIGURED: 'misconfigured',
+ SELF_HOSTED: 'self-hosted',
+}
+
+const MAX_STACK = 4000
+
+const clamp = (value, max) =>
+ typeof value === 'string' && value.length > max
+ ? `${value.slice(0, max)}\n… truncated`
+ : value
+
+/** Short, human-readable handle the user can quote back to support. */
+export const newReportId = () =>
+ `DT-${Date.now().toString(36).toUpperCase().slice(-5)}-${Math.random()
+ .toString(36)
+ .toUpperCase()
+ .slice(2, 6)}`
+
+const safeMessage = error => {
+ const message = error?.message ?? error?.statusText
+ if (!message || message === '[object Object]') return null
+ return message
+}
+
+/** Everything about the failure itself, normalised across throw shapes. */
+const describeError = (error, errorInfo) => {
+ if (!error) return { name: 'Unknown', message: null }
+ return {
+ name: error.name ?? error.constructor?.name ?? typeof error,
+ message: safeMessage(error) ?? String(error).slice(0, 500),
+ // react-router route errors carry an HTTP shape instead of a stack.
+ status: error.status ?? error.response?.status ?? null,
+ statusText: error.statusText ?? null,
+ // Vite/react-router attach a digest to server-side thrown responses.
+ digest: error.digest ?? null,
+ stack: clamp(error.stack ?? null, MAX_STACK),
+ componentStack: clamp(errorInfo?.componentStack ?? null, MAX_STACK),
+ }
+}
+
+/** Everything about the environment the failure happened in. */
+const describeRuntime = () => {
+ const connection =
+ navigator.connection ||
+ navigator.mozConnection ||
+ navigator.webkitConnection
+
+ return {
+ url: window.location.href,
+ route: window.location.pathname + window.location.search,
+ referrer: document.referrer || null,
+ viewport: `${window.innerWidth}×${window.innerHeight}`,
+ screen: `${window.screen?.width}×${window.screen?.height}`,
+ devicePixelRatio: window.devicePixelRatio,
+ online: navigator.onLine,
+ connectionType: connection?.effectiveType ?? null,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ colorScheme: window.matchMedia?.('(prefers-color-scheme: dark)').matches
+ ? 'dark'
+ : 'light',
+ standalone: window.matchMedia?.('(display-mode: standalone)').matches,
+ userAgent: navigator.userAgent,
+ }
+}
+
+const cachedUser = () => {
+ try {
+ return JSON.parse(localStorage.getItem('user') || 'null')
+ } catch {
+ return null
+ }
+}
+
+/**
+ * The full diagnostic bundle. Deliberately assembled in one place so the copy
+ * button, the GitHub fallback and the webhook all describe the same crash.
+ *
+ * Reads the signed-in user from cache rather than the network: by the time
+ * this runs the app has already failed, and a fetch may be exactly what broke.
+ */
+export const collectErrorReport = async ({ error, errorInfo, reportId }) => {
+ const user = cachedUser()
+ const [context, session] = await Promise.all([
+ collectFeedbackContext({
+ feature: window.location.pathname,
+ userProfile: user,
+ }).catch(() => ({})),
+ getSessionDiagnostics().catch(() => ({})),
+ ])
+
+ return {
+ reportId: reportId ?? newReportId(),
+ occurredAt: new Date().toISOString(),
+ error: describeError(error, errorInfo),
+ runtime: describeRuntime(),
+ app: context,
+ session,
+ }
+}
+
+/** The plain-text rendering used by the copy button and the details panel. */
+const formatDuration = ms => {
+ if (ms == null) return 'unknown'
+ if (ms < 1000) return `${ms}ms`
+ if (ms < 60_000) return `${Math.round(ms / 1000)}s`
+ const minutes = Math.floor(ms / 60_000)
+ if (minutes < 60) return `${minutes}m ${Math.round((ms % 60_000) / 1000)}s`
+ return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
+}
+
+export const formatErrorReport = report => {
+ if (!report) return ''
+ const { app, error, runtime, session = {} } = report
+ const lines = [
+ `Report ID: ${report.reportId}`,
+ `Time: ${report.occurredAt}`,
+ '',
+ `Error: ${error.name}${error.message ? `: ${error.message}` : ''}`,
+ error.status
+ ? `HTTP: ${error.status} ${error.statusText ?? ''}`.trim()
+ : null,
+ '',
+ `URL: ${runtime.url}`,
+ session.previousRoute ? `Came from: ${session.previousRoute}` : null,
+ '',
+ `App: ${app.appVersion} · ${app.platform}${app.isNative ? ' (native)' : ''}`,
+ `Server: ${session.serverVersion ?? 'not reported'}`,
+ `Session: ${formatDuration(session.sessionDurationMs)} active · ${
+ session.navigationType ?? 'unknown'
+ } start · backgrounded ${session.backgroundedCount ?? 0}×`,
+ `Device: ${app.deviceModel} · ${app.osVersion}`,
+ `Viewport: ${runtime.viewport} @${runtime.devicePixelRatio}x · ${runtime.colorScheme}`,
+ `Locale: ${app.locale} · ${runtime.timezone}`,
+ `Network: ${runtime.online ? 'online' : 'offline'}${
+ runtime.connectionType ? ` (${runtime.connectionType})` : ''
+ }`,
+ `Hosting: ${app.hosting}`,
+ app.userId ? `User: ${app.userId}` : null,
+ session.storage
+ ? `Storage: ${session.storage.usageMb}MB / ${session.storage.quotaMb}MB (${session.storage.pressure}%)`
+ : null,
+ session.heapUsedMb ? `Heap: ${session.heapUsedMb}MB` : null,
+ session.serviceWorker?.supported
+ ? `Service worker: ${
+ session.serviceWorker.controlled ? 'controlling' : 'not controlling'
+ }${session.serviceWorker.updateWaiting ? ' · UPDATE WAITING' : ''}`
+ : null,
+ ].filter(Boolean)
+
+ if (session.routeTrail?.length) {
+ lines.push(
+ '',
+ 'Route trail (oldest first):',
+ ...session.routeTrail.map(
+ entry =>
+ `- ${entry.path} · ${formatDuration(entry.dwellMs)} · ${formatDuration(
+ entry.msAgo,
+ )} ago`,
+ ),
+ )
+ }
+ if (session.apiFailures?.length) {
+ lines.push(
+ '',
+ 'Recent API failures:',
+ ...session.apiFailures.map(
+ failure =>
+ `- ${failure.method} ${failure.endpoint} → ${
+ failure.status
+ } (${formatDuration(failure.msAgo)} ago)`,
+ ),
+ )
+ }
+ if (app.recentErrors?.length) {
+ lines.push('', 'Recent errors:', ...app.recentErrors.map(e => `- ${e}`))
+ }
+ if (error.componentStack) {
+ lines.push('', 'Component stack:', error.componentStack.trim())
+ }
+ if (error.stack) {
+ lines.push('', 'Stack:', error.stack)
+ }
+ return lines.join('\n')
+}
+
+/**
+ * Pre-filled GitHub issue for self-hosted instances — their crash data never
+ * leaves infrastructure they control, and they see it before it is published.
+ */
+export const buildErrorIssueUrl = ({ description, report }) => {
+ const title = `[crash] ${
+ report.error.message?.slice(0, 80) ||
+ report.error.name ||
+ 'Unexpected error'
+ }`
+ const body = [
+ '### What happened',
+ description?.trim() || '_no description provided_',
+ '',
+ '### Diagnostics',
+ '```',
+ formatErrorReport(report),
+ '```',
+ ].join('\n')
+
+ return `${GITHUB_URL}/issues/new?labels=bug&title=${encodeURIComponent(
+ title,
+ )}&body=${encodeURIComponent(body)}`
+}
+
+/**
+ * Posts the report to the relay. Never throws — a failed crash report must not
+ * produce a second crash, so every path resolves to a result the UI can show.
+ */
+export const submitErrorReport = async ({
+ contactEmail,
+ description,
+ report,
+}) => {
+ const payload = {
+ source: 'donetick-app',
+ kind: 'error-report',
+ reportId: report.reportId,
+ description: description?.trim() || null,
+ contactEmail: contactEmail?.trim() || null,
+ report,
+ }
+
+ // Enforced here, not only in the UI, so no future caller can relay a
+ // self-hosted instance's stack traces to the hosted endpoint.
+ if (report.app?.hosting !== 'cloud') {
+ return {
+ result: SUBMIT_RESULT.SELF_HOSTED,
+ githubUrl: buildErrorIssueUrl({ description, report }),
+ }
+ }
+
+ if (!REPORT_URL) {
+ console.info('ErrorReportService: no endpoint configured, report:', payload)
+ return {
+ result: SUBMIT_RESULT.UNCONFIGURED,
+ githubUrl: buildErrorIssueUrl({ description, report }),
+ }
+ }
+
+ if (isRawChatWebhook(REPORT_URL)) {
+ console.error(
+ 'ErrorReportService: the report URL points directly at a Discord/Slack ' +
+ 'webhook. Deploy the relay Worker and point the variable at it.',
+ payload,
+ )
+ return {
+ result: SUBMIT_RESULT.MISCONFIGURED,
+ githubUrl: buildErrorIssueUrl({ description, report }),
+ }
+ }
+
+ try {
+ const response = await fetch(REPORT_URL, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ })
+ if (response.ok) return { result: SUBMIT_RESULT.SENT }
+ } catch (submitError) {
+ console.warn('ErrorReportService: submission failed', submitError)
+ }
+
+ return {
+ result: SUBMIT_RESULT.FAILED,
+ githubUrl: buildErrorIssueUrl({ description, report }),
+ }
+}
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 25542d2..3510ae1 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -1,6 +1,11 @@
import { Preferences } from '@capacitor/preferences'
+
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
+import {
+ recordApiFailure,
+ recordServerVersionFromResponse,
+} from '../service/DiagnosticsSession'
import { logout, RefreshToken } from './Fetcher'
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
import { offlineDB } from './OfflineDB'
@@ -129,7 +134,7 @@ class ApiClient {
// Process queued requests after refresh attempt
processQueue(error, token = null) {
- this.failedQueue.forEach(({ resolve, reject }) => {
+ this.failedQueue.forEach(({ reject, resolve }) => {
if (error) {
reject(error)
} else {
@@ -199,6 +204,17 @@ class ApiClient {
let response = await fetch(url, config)
clearTimeout(timeoutId)
+ // Passive diagnostics: learn the server build from whatever it already
+ // answers, and keep the last few refusals for crash reports.
+ recordServerVersionFromResponse(response)
+ if (!response.ok) {
+ recordApiFailure({
+ endpoint,
+ method: config.method,
+ status: response.status,
+ })
+ }
+
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
// Always queue this request first
@@ -280,6 +296,7 @@ class ApiClient {
error?.name === 'AbortError' && options.signal?.aborted
if (!externalAbort) {
networkManager.setServerUnreachable()
+ recordApiFailure({ endpoint, method: config.method, status: 'network' })
}
console.error('Request failed', error)
throw error
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index d453449..bf6ec29 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -372,7 +372,6 @@ const MyChores = () => {
}
processEffectAsync()
- // throw new Error('Fake Error to test posthog')
}
}, [
membersLoading,
diff --git a/src/views/Error.jsx b/src/views/Error.jsx
index 10a06e1..f06a2b1 100644
--- a/src/views/Error.jsx
+++ b/src/views/Error.jsx
@@ -10,9 +10,15 @@ import {
SearchOffRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { Link, useRouteError } from 'react-router-dom'
+import {
+ collectErrorReport,
+ formatErrorReport,
+} from '../service/ErrorReportService'
+import ErrorReportModal from './Modals/ErrorReportModal'
+
const getErrorKind = error => {
if (!error)
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
@@ -48,27 +54,30 @@ const safeMessage = error => {
return msg
}
-const buildErrorText = (error, url) => {
- const lines = [
- `URL: ${url}`,
- `Time: ${new Date().toISOString()}`,
- `Error: ${safeMessage(error) ?? String(error)}`,
- ]
- if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
- return lines.join('\n')
-}
-
const Error = () => {
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
+ const [reportOpen, setReportOpen] = useState(false)
+ const [reportText, setReportText] = useState('')
- const { color, Icon } = getErrorKind(error)
+ const { Icon, color } = getErrorKind(error)
const message = safeMessage(error)
- const url = window.location.href
+
+ // The same bundle the report modal sends, so what the user copies and what
+ // we receive can never disagree.
+ useEffect(() => {
+ let active = true
+ collectErrorReport({ error }).then(report => {
+ if (active) setReportText(formatErrorReport(report))
+ })
+ return () => {
+ active = false
+ }
+ }, [error])
const handleCopy = () => {
- navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
+ navigator.clipboard.writeText(reportText).then(() => {
setCopied(true)
})
}
@@ -209,11 +218,24 @@ const Error = () => {
size='lg'
startDecorator={}
onClick={() => window.location.reload()}
- sx={{ width: '100%', mb: 2 }}
+ sx={{ width: '100%', mb: 1.5 }}
>
Try again
+ {/* Reporting is one tap from the failure, where the context is still
+ fresh — asking people to find it in Settings afterwards never works. */}
+ }
+ onClick={() => setReportOpen(true)}
+ sx={{ width: '100%', mb: 2 }}
+ >
+ Report this problem
+
+
{/* Secondary actions */}
)}
@@ -321,6 +334,12 @@ const Error = () => {
)}
+ setReportOpen(false)}
+ error={error}
+ />
+
({
+ animation: `errorReportIn 420ms ${EASE} ${delay}ms both`,
+ '@keyframes errorReportIn': {
+ from: { opacity: 0, transform: 'translateY(10px)' },
+ to: { opacity: 1, transform: 'none' },
+ },
+ '@media (prefers-reduced-motion: reduce)': { animation: 'none' },
+})
+
+const STEP = { FORM: 'form', SENT: 'sent', FALLBACK: 'fallback' }
+
+// Native webviews swallow target="_blank"; route through the system browser.
+const openUrl = async url => {
+ if (Capacitor.isNativePlatform()) {
+ await Browser.open({ url })
+ } else {
+ window.open(url, '_blank', 'noopener,noreferrer')
+ }
+}
+
+/**
+ * The same halo treatment the onboarding views use, so the crash path doesn't
+ * introduce a third visual language for "here's the thing this screen is about".
+ */
+const IconHalo = ({ color = 'primary', icon }) => (
+ *': { position: 'relative' },
+ }}
+ >
+
+ {icon}
+
+
+)
+
+/**
+ * Collects a crash report from the error screen: one short answer from the
+ * user, everything else gathered automatically. The diagnostics are shown
+ * before sending rather than after — people are more willing to send a report
+ * they can see, and this is the one moment they already distrust the app.
+ */
+const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
+ const { ResponsiveModal } = useResponsiveModal()
+
+ const [report, setReport] = useState(null)
+ const [description, setDescription] = useState('')
+ const [email, setEmail] = useState('')
+ const [showDetails, setShowDetails] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const [submitting, setSubmitting] = useState(false)
+ const [githubUrl, setGithubUrl] = useState(null)
+ const [step, setStep] = useState(STEP.FORM)
+
+ useEffect(() => {
+ if (!open) return
+ setStep(STEP.FORM)
+ setDescription('')
+ setShowDetails(false)
+ setCopied(false)
+ setSubmitting(false)
+ setGithubUrl(null)
+ // Snapshot the environment at open time so it reflects the crash, not
+ // whatever the app looks like after the user has poked at it.
+ collectErrorReport({ error, errorInfo }).then(setReport)
+ }, [open, error, errorInfo])
+
+ const reportText = report ? formatErrorReport(report) : ''
+
+ const copyDetails = () => {
+ navigator.clipboard.writeText(reportText).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 2000)
+ })
+ }
+
+ const handleSubmit = async () => {
+ if (!report) return
+ setSubmitting(true)
+ const { githubUrl: url, result } = await submitErrorReport({
+ description,
+ contactEmail: email,
+ report,
+ })
+ setSubmitting(false)
+
+ if (result === SUBMIT_RESULT.SENT) {
+ setStep(STEP.SENT)
+ return
+ }
+ // Everything else — self-hosted, unconfigured, offline — hands the user a
+ // pre-filled issue so the report isn't simply lost.
+ setGithubUrl(url)
+ setStep(STEP.FALLBACK)
+ }
+
+ return (
+
+ {step === STEP.FORM && (
+
+
+ } color='danger' />
+
+
+
+
+ Report this problem
+
+
+ A sentence about what you were doing turns this into something we
+ can actually fix.
+
+
+
+
+ What were you doing?
+
+
+
+
+ Email{' '}
+
+ (optional — only if you want a reply)
+
+
+ setEmail(e.target.value)}
+ placeholder='you@example.com'
+ />
+
+
+
+
+
+ {showDetails && (
+
+
+
+ {reportText || 'Collecting diagnostics…'}
+
+
+ )}
+
+ No chore names, notes or attachments are included.
+
+
+
+
+
+
+
+ Not now
+
+
+
+
+ )}
+
+ {step === STEP.SENT && (
+
+
+ } color='success' />
+
+
+
+ Report sent
+
+
+ Thanks — this goes straight to the people who can fix it.
+
+
+
+
+
+
+ Reference
+
+
+ {report?.reportId}
+
+
+
+
+
+
+
+ )}
+
+ {step === STEP.FALLBACK && (
+
+
+ } color='neutral' />
+
+
+
+ Finish on GitHub
+
+
+ Nothing has been sent. We've filled in an issue with your
+ notes and the diagnostics — review it and post when you're
+ happy with it.
+
+
+
+ }
+ onClick={() => {
+ openUrl(githubUrl)
+ onClose()
+ }}
+ >
+ Open pre-filled issue
+
+ :
+ }
+ onClick={copyDetails}
+ >
+ {copied ? 'Copied' : 'Copy details instead'}
+
+
+
+ Close
+
+
+
+
+ )}
+
+ )
+}
+
+export default ErrorReportModal