Error reporting (#196)

* implement error reporting modal and service integration

* Add session diagnostics for error reporting and enhance API failure tracking
This commit is contained in:
Mohamad Tarbin
2026-08-07 00:10:51 -04:00
committed by GitHub
parent 903d022877
commit 475806ac29
7 changed files with 995 additions and 39 deletions

View File

@@ -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()

View File

@@ -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,
}
}

View File

@@ -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 }),
}
}

View File

@@ -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

View File

@@ -372,7 +372,6 @@ const MyChores = () => {
}
processEffectAsync()
// throw new Error('Fake Error to test posthog')
}
}, [
membersLoading,

View File

@@ -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={<RefreshRounded />}
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 2 }}
sx={{ width: '100%', mb: 1.5 }}
>
Try again
</Button>
{/* Reporting is one tap from the failure, where the context is still
fresh — asking people to find it in Settings afterwards never works. */}
<Button
variant='outlined'
color='neutral'
size='lg'
startDecorator={<BugReportRounded />}
onClick={() => setReportOpen(true)}
sx={{ width: '100%', mb: 2 }}
>
Report this problem
</Button>
{/* Secondary actions */}
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
<Button
@@ -251,16 +273,7 @@ const Error = () => {
textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }}
>
If this keeps happening,{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
target='_blank'
rel='noopener noreferrer'
style={{ textDecoration: 'underline' }}
>
open an issue
</a>{' '}
and include the error details below.
If this keeps happening, send us a report please consider sending us report so we can take a look.
</Typography>
{(error?.stack || message) && (
@@ -313,7 +326,7 @@ const Error = () => {
color: 'text.secondary',
}}
>
{buildErrorText(error, url)}
{reportText || 'Collecting error details…'}
</Typography>
</Box>
)}
@@ -321,6 +334,12 @@ const Error = () => {
)}
</Box>
<ErrorReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
error={error}
/>
<Snackbar
open={copied}
autoHideDuration={2500}

View File

@@ -0,0 +1,429 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import {
BugReportRounded,
CheckRounded,
ContentCopyRounded,
ExpandMoreRounded,
GitHub,
} from '@mui/icons-material'
import {
Box,
Button,
FormControl,
FormLabel,
Input,
Link,
Stack,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
import {
collectErrorReport,
formatErrorReport,
SUBMIT_RESULT,
submitErrorReport,
} from '../../service/ErrorReportService'
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
const enter = (delay = 0) => ({
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 }) => (
<Box
sx={{
position: 'relative',
display: 'grid',
placeItems: 'center',
width: 64,
height: 64,
mx: 'auto',
'&::before': {
content: '""',
position: 'absolute',
width: 110,
height: 110,
borderRadius: '50%',
bgcolor: `${color}.softBg`,
opacity: 0.6,
filter: 'blur(24px)',
},
'& > *': { position: 'relative' },
}}
>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
display: 'grid',
placeItems: 'center',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
color: `${color}.plainColor`,
'& svg': { fontSize: '1.6rem' },
}}
>
{icon}
</Box>
</Box>
)
/**
* 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 (
<ResponsiveModal open={open} onClose={onClose} size='md'>
{step === STEP.FORM && (
<Stack spacing={2}>
<Box sx={{ ...enter(0) }}>
<IconHalo icon={<BugReportRounded />} color='danger' />
</Box>
<Box sx={{ textAlign: 'center', ...enter(50) }}>
<Typography
level='h4'
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
>
Report this problem
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
>
A sentence about what you were doing turns this into something we
can actually fix.
</Typography>
</Box>
<FormControl sx={{ ...enter(100) }}>
<FormLabel sx={{ fontWeight: 600 }}>What were you doing?</FormLabel>
<Textarea
minRows={3}
maxRows={6}
autoFocus
value={description}
onChange={e => setDescription(e.target.value)}
placeholder='e.g. I tapped a chore in My Chores and the screen went blank'
/>
</FormControl>
<FormControl sx={{ ...enter(140) }}>
<FormLabel sx={{ fontWeight: 600 }}>
Email{' '}
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
(optional only if you want a reply)
</Typography>
</FormLabel>
<Input
type='email'
value={email}
onChange={e => setEmail(e.target.value)}
placeholder='you@example.com'
/>
</FormControl>
<Box sx={{ ...enter(180) }}>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={() => setShowDetails(v => !v)}
endDecorator={
<ExpandMoreRounded
sx={{
transition: 'transform 0.2s',
transform: showDetails ? 'rotate(180deg)' : 'none',
}}
/>
}
sx={{ px: 0 }}
>
{showDetails ? 'Hide' : 'Show'} what gets sent
</Button>
{showDetails && (
<Box
sx={{
position: 'relative',
mt: 1,
p: 1.5,
pr: 5,
borderRadius: '12px',
bgcolor: 'background.level2',
maxHeight: 180,
overflow: 'auto',
}}
>
<Button
size='sm'
variant='soft'
color={copied ? 'success' : 'neutral'}
onClick={copyDetails}
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
aria-label='Copy diagnostics'
>
{copied ? (
<CheckRounded fontSize='small' />
) : (
<ContentCopyRounded fontSize='small' />
)}
</Button>
<Typography
level='body-xs'
sx={{
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: 'text.secondary',
}}
>
{reportText || 'Collecting diagnostics…'}
</Typography>
</Box>
)}
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
>
No chore names, notes or attachments are included.
</Typography>
</Box>
<Stack spacing={1} sx={{ ...enter(220) }}>
<Button
size='lg'
fullWidth
loading={submitting}
disabled={!report}
onClick={handleSubmit}
>
Send report
</Button>
<Box sx={{ textAlign: 'center' }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={onClose}
>
Not now
</Link>
</Box>
</Stack>
</Stack>
)}
{step === STEP.SENT && (
<Stack spacing={2} sx={{ textAlign: 'center' }}>
<Box sx={{ ...enter(0) }}>
<IconHalo icon={<CheckRounded />} color='success' />
</Box>
<Box sx={{ ...enter(50) }}>
<Typography level='h4' sx={{ fontWeight: 700 }}>
Report sent
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5 }}
>
Thanks this goes straight to the people who can fix it.
</Typography>
</Box>
<Box
sx={{
...enter(100),
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1.5,
px: 2,
py: 1.25,
borderRadius: '16px',
bgcolor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<Box sx={{ textAlign: 'left' }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Reference
</Typography>
<Typography
sx={{
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontWeight: 700,
letterSpacing: '0.06em',
}}
>
{report?.reportId}
</Typography>
</Box>
<Button
variant='soft'
color={copied ? 'success' : 'primary'}
onClick={() => {
navigator.clipboard.writeText(report?.reportId ?? '')
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}}
sx={{ minWidth: 0 }}
aria-label='Copy reference'
>
{copied ? <CheckRounded /> : <ContentCopyRounded />}
</Button>
</Box>
<Button size='lg' fullWidth onClick={onClose} sx={{ ...enter(150) }}>
Done
</Button>
</Stack>
)}
{step === STEP.FALLBACK && (
<Stack spacing={2} sx={{ textAlign: 'center' }}>
<Box sx={{ ...enter(0) }}>
<IconHalo icon={<GitHub />} color='neutral' />
</Box>
<Box sx={{ ...enter(50) }}>
<Typography level='h4' sx={{ fontWeight: 700 }}>
Finish on GitHub
</Typography>
<Typography
level='body-sm'
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
>
Nothing has been sent. We&apos;ve filled in an issue with your
notes and the diagnostics review it and post when you&apos;re
happy with it.
</Typography>
</Box>
<Stack spacing={1} sx={{ ...enter(100) }}>
<Button
size='lg'
fullWidth
startDecorator={<GitHub />}
onClick={() => {
openUrl(githubUrl)
onClose()
}}
>
Open pre-filled issue
</Button>
<Button
variant='plain'
color='neutral'
startDecorator={
copied ? <CheckRounded /> : <ContentCopyRounded />
}
onClick={copyDetails}
>
{copied ? 'Copied' : 'Copy details instead'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={onClose}
>
Close
</Link>
</Box>
</Stack>
</Stack>
)}
</ResponsiveModal>
)
}
export default ErrorReportModal