5 Commits

Author SHA1 Message Date
Mo Tarbin
0215a78404 Release 1.2.38
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-07 00:43:15 -04:00
Mohamad Tarbin
475806ac29 Error reporting (#196)
* implement error reporting modal and service integration

* Add session diagnostics for error reporting and enhance API failure tracking
2026-08-07 00:10:51 -04:00
Mohamad Tarbin
903d022877 New Crowdin updates (#191)
* New translations common.json (French)

[ci skip]

* New translations settings.json (French)

[ci skip]

* New translations common.json (Spanish)

[ci skip]

* New translations settings.json (Spanish)

[ci skip]

* New translations common.json (Arabic)

[ci skip]

* New translations settings.json (Arabic)

[ci skip]

* New translations settings.json (German)

[ci skip]

* New translations common.json (Japanese)

[ci skip]

* New translations settings.json (Japanese)

[ci skip]

* New translations common.json (Dutch)

[ci skip]

* New translations settings.json (Dutch)

[ci skip]

* New translations common.json (Chinese Simplified)

[ci skip]

* New translations settings.json (Chinese Simplified)

[ci skip]

* New translations common.json (Portuguese, Brazilian)

[ci skip]

* New translations settings.json (Portuguese, Brazilian)

[ci skip]
2026-08-06 23:57:23 -04:00
Mohamad Tarbin
5fb0b5b656 implement Application and MarketingApp components, enhance routing and user context management (#194) 2026-08-06 23:57:08 -04:00
Mohamad Tarbin
0b4b21bbb0 Refactor chore filtering and search performance (#195)
* Refactor assignment strategy handling in ChoreEdit component

* performance improvement : Refactor chore filtering logic and improve search functionality in MyChores and useChoreFilters hooks. reduce rerender etc...

* making sure the filter clear when it supposed to

* formating
2026-08-06 23:56:15 -04:00
40 changed files with 1695 additions and 332 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 58
versionName "1.2.37"
versionCode 59
versionName "1.2.38"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 58;
CURRENT_PROJECT_VERSION = 59;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.37;
MARKETING_VERSION = 1.2.38;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
@@ -485,12 +485,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
CURRENT_PROJECT_VERSION = 58;
CURRENT_PROJECT_VERSION = 59;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.37;
MARKETING_VERSION = 1.2.38;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
@@ -504,12 +504,12 @@
buildSettings = {
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 58;
CURRENT_PROJECT_VERSION = 59;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.37;
MARKETING_VERSION = 1.2.38;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -527,12 +527,12 @@
CODE_SIGN_IDENTITY = "Apple Distribution";
CODE_SIGN_STYLE = Manual;
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
CURRENT_PROJECT_VERSION = 58;
CURRENT_PROJECT_VERSION = 59;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = DonetickWidget/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.2.37;
MARKETING_VERSION = 1.2.38;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.37",
"version": "1.2.38",
"type": "module",
"engines": {
"node": ">=20.0.0",

View File

@@ -29,5 +29,51 @@
"activities": "الأنشطة",
"points": "النقاط",
"settings": "الإعدادات"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "إعدادات المطور",
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Entwickler-Einstellungen",
"description": "Zeige technische Informationen über Authentifizierungs-Token, SSE-Verbindungen und Debug-Daten an."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "Actividades",
"points": "Puntos",
"settings": "Configuración"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Configuración de Desarrollador",
"description": "Ver información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "Activités",
"points": "Points",
"settings": "Paramètres"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Paramètres Développeur",
"description": "Consultez les informations techniques sur les jetons d'authentification, les connexions SSE et les données de débogage."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "アクティビティ",
"points": "ポイント",
"settings": "設定"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "開発者設定",
"description": "認証トークン、SSE 接続、デバッグデータなどの技術情報を確認できます。"
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "Activiteiten",
"points": "Punten",
"settings": "Instellingen"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Ontwikkelaarsinstellingen",
"description": "Bekijk technische informatie over authenticatietokens, SSE-verbindingen en foutopsporingsgegevens."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "Atividades",
"points": "Pontos",
"settings": "Configurações"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Configurações de desenvolvedor",
"description": "Veja informações técnicas sobre tokens de autenticação, conexões SSE e dados de depuração."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

View File

@@ -29,5 +29,51 @@
"activities": "Activities",
"points": "Points",
"settings": "Settings"
},
"feedback": {
"later": "Maybe later",
"sentiment": {
"title": "How's Donetick working for you?",
"subtitle": "Your answer helps us decide what to build next.",
"options": {
"love": "Love it",
"okay": "It's okay",
"issues": "Having issues"
}
},
"categories": {
"bugs": "Bugs",
"missingFeature": "Missing feature",
"tooComplicated": "Too complicated",
"slow": "Slow",
"notifications": "Notifications",
"ai": "AI",
"other": "Other"
},
"details": {
"title": "What could we improve?",
"messageLabel": "Tell us more",
"messagePlaceholder": "What happened, or what would make this better?",
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
"submit": "Send feedback",
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
"submitSelfHosted": "Continue to GitHub"
},
"review": {
"title": "Glad you're enjoying it!",
"subtitle": "A rating or a star helps other people find Donetick.",
"github": "Star on GitHub",
"appStore": "Rate on the App Store",
"playStore": "Rate on Google Play"
},
"thanks": {
"title": "Thanks for the feedback",
"subtitle": "We read every response and it shapes what we work on next."
},
"github": {
"title": "Report it on GitHub",
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
"open": "Open the issue"
}
}
}

View File

@@ -168,6 +168,10 @@
"developer": {
"title": "Developer Settings",
"description": "View technical information about authentication tokens, SSE connections, and debug data."
},
"feedback": {
"title": "Send Feedback",
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
}
}
}

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

10
src/Application.jsx Normal file
View File

@@ -0,0 +1,10 @@
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
const Application = () => (
<Contexts>
<App />
</Contexts>
)
export default Application

66
src/MarketingApp.jsx Normal file
View File

@@ -0,0 +1,66 @@
import { useColorScheme } from '@mui/joy'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect } from 'react'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { LocalizationProvider } from './contexts/LocalizationContext'
import ThemeContext from './contexts/ThemeContext'
import Landing from './views/Landing/Landing'
import PrivacyPolicyView from './views/PrivacyPolicy/PrivacyPolicyView'
import TermsView from './views/Terms/TermsView'
const AppRedirect = () => {
useEffect(() => {
const { hash, pathname, search } = window.location
window.location.replace(
`https://app.donetick.com${pathname}${search}${hash}`,
)
}, [])
return null
}
const router = createBrowserRouter([
{ path: '/', element: <Landing /> },
{ path: '/privacy', element: <PrivacyPolicyView /> },
{ path: '/terms', element: <TermsView /> },
{ path: '*', element: <AppRedirect /> },
])
const queryClient = new QueryClient({
defaultOptions: {
queries: { enabled: false, refetchOnWindowFocus: false, retry: false },
},
})
const ThemeClass = () => {
const { mode, systemMode } = useColorScheme()
useEffect(() => {
const storedMode = JSON.parse(localStorage.getItem('themeMode') || 'null')
const selectedMode = storedMode || mode
const isDark =
selectedMode === 'dark' ||
(selectedMode === 'system' && systemMode === 'dark')
document.getElementById('root').classList.toggle('dark', isDark)
}, [mode, systemMode])
return null
}
const MarketingApp = () => (
<ThemeContext>
<ThemeClass />
<QueryClientProvider client={queryClient}>
<LocalizationProvider>
<ImpersonateUserProvider>
<RouterProvider router={router} />
</ImpersonateUserProvider>
</LocalizationProvider>
</QueryClientProvider>
</ThemeContext>
)
export default MarketingApp

View File

@@ -2,21 +2,21 @@ import { Add, Close } from '@mui/icons-material'
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
onAdd,
showAddChip = false,
resultCount,
totalCount,
maxVisible = 2,
chipSize = 'md',
chipSx,
chips = [],
clearButtonSize = 'sm',
clearButtonSx,
containerSx,
chipSx,
maxVisible = 2,
onAdd,
onClearAll,
onOpen,
overflowChipSx,
resultCount,
resultSx,
showAddChip = false,
totalCount,
}) => {
if (!chips.length) {
return null
@@ -39,7 +39,7 @@ const ActiveFilterChips = ({
...containerSx,
}}
>
{visible.map(({ key, label, onClear, color = 'primary' }) => (
{visible.map(({ color = 'primary', key, label, onClear }) => (
<Chip
key={key}
size={chipSize}
@@ -52,7 +52,10 @@ const ActiveFilterChips = ({
<ChipDelete
variant='plain'
color={color}
onDelete={() => onClear?.()}
onDelete={event => {
event.stopPropagation()
onClear?.()
}}
aria-label={`Remove ${label} filter`}
sx={{
'--Chip-deleteSize': chipSize === 'sm' ? '1.1rem' : '1.4rem',

View File

@@ -1,3 +1,5 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import Error from '@/views/Error'
@@ -10,7 +12,7 @@ import Settings from '@/views/Settings/Settings'
import SettingsOverview from '@/views/Settings/SettingsOverview'
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
import ThemeSettings from '@/views/Settings/ThemeSettings'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating'
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
import LoginSettings from '../views/Authorization/LoginSettings'
@@ -49,16 +51,6 @@ import ThingsView from '../views/Things/ThingsView'
import TimerDetails from '../views/Timer/TimerDetails'
import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints'
const getMainRoute = () => {
if (
// if domain is www.donetick.com or donetick.com then show landing page:
window.location.hostname === 'www.donetick.com' ||
window.location.hostname === 'donetick.com'
) {
return <Landing />
}
return <MyChores />
}
const Router = createBrowserRouter([
{
path: '/',
@@ -67,7 +59,7 @@ const Router = createBrowserRouter([
children: [
{
path: '/',
element: getMainRoute(),
element: <MyChores />,
},
{
path: '/settings',

View File

@@ -1,14 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './i18n/config'
import './index.css'
import React from 'react'
import ReactDOM from 'react-dom/client'
const marketingHosts = new Set(['donetick.com', 'www.donetick.com'])
const isMarketingSite =
marketingHosts.has(window.location.hostname) ||
(import.meta.env.DEV &&
new URLSearchParams(window.location.search).get('site') === 'marketing')
export const Site = React.lazy(() =>
isMarketingSite ? import('./MarketingApp.jsx') : import('./Application.jsx'),
)
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Contexts>
<App />
</Contexts>
<React.Suspense fallback={null}>
<Site />
</React.Suspense>
</React.StrictMode>,
)

View File

@@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
GetAllCircleMembers,
GetAllUsers,
@@ -54,7 +55,7 @@ export const useCircleMembers = () => {
return { data, error, isLoading, handleRefetch }
}
export const useUserProfile = () => {
export const useUserProfile = ({ enabled = true } = {}) => {
const queryClient = useQueryClient()
const token = localStorage.getItem('token')
@@ -80,7 +81,7 @@ export const useUserProfile = () => {
},
staleTime: 30 * 60 * 1000,
gcTime: 30 * 60 * 1000,
enabled: !!token,
enabled: enabled && !!token,
})
return {
data,

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

@@ -89,6 +89,8 @@ const buildActualDateGroups = chores => {
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
} else {
chores = [...chores]
}
// sort by priority then due date:

View File

@@ -37,6 +37,7 @@ import {
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import DurationInput from '../../components/common/DurationInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import NotificationTemplate from '../../components/NotificationTemplate.jsx'
@@ -60,10 +61,10 @@ import {
} from '../../utils/Fetcher'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import { getImageSrc, removeCachedImage } from '../../utils/ImageCache'
import { generateUUID } from '../../utils/UUID'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import { generateUUID } from '../../utils/UUID'
import { useProjectFilter } from '../Chores/hooks/useProjectFilter.js'
import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
@@ -84,6 +85,7 @@ const ASSIGN_STRATEGIES = [
'round_robin',
'no_assignee',
]
const DEFAULT_ASSIGN_STRATEGY = ASSIGN_STRATEGIES[3] // keep_last_assigned
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
@@ -103,7 +105,7 @@ const ChoreEdit = () => {
const [anyone, setAnyone] = useState(false)
const [assignableTo, setAssignableTo] = useState([])
const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
const [assignStrategy, setAssignStrategy] = useState(DEFAULT_ASSIGN_STRATEGY)
const [dueDate, setDueDate] = useState(null)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
@@ -151,7 +153,7 @@ const ChoreEdit = () => {
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
useProjectFilter(projects)
const [projectId, setProjectId] = useState(
@@ -170,7 +172,7 @@ const ChoreEdit = () => {
} = useChore(choreId)
const { data: membersData, isLoading: isMemberDataLoading } =
useCircleMembers()
const { showSuccess, showError } = useNotification()
const { showError, showSuccess } = useNotification()
const [userLabels, setUserLabels] = useState([])
@@ -183,20 +185,26 @@ const ChoreEdit = () => {
const Navigate = useNavigate()
const assignees = anyone ? performers : assignableTo
const hasSpecificAssignees = !anyone && assignableTo.length > 0
const canPickStrategy = hasSpecificAssignees && assignableTo.length > 1
const assignStrategyValue = !hasSpecificAssignees
? 'no_assignee'
: canPickStrategy
? assignStrategy
: DEFAULT_ASSIGN_STRATEGY
const assignedToValue =
!hasSpecificAssignees || assignStrategyValue === 'no_assignee'
? null
: assignableTo.some(a => a.userId === assignedTo)
? assignedTo
: assignableTo[0].userId
const HandleValidateChore = () => {
const errors = {}
if (name.trim() === '') {
errors.name = 'Name is required'
}
if (assignStrategy !== 'no_assignee') {
if (assignees.length === 0) {
errors.assignees = 'At least 1 assignees is required'
}
if (assignedTo === null || assignedTo < 0) {
errors.assignedTo = 'Assigned to is required'
}
}
if (frequencyType === 'interval' && !frequency > 0) {
errors.frequency = `Invalid frequency, the ${frequencyMetadata.unit} should be > 0`
}
@@ -366,8 +374,8 @@ const ChoreEdit = () => {
frequencyType: frequencyType,
frequency: Number(frequency),
frequencyMetadata: frequencyMetadata,
assignedTo: assignStrategy === 'no_assignee' ? null : assignedTo,
assignStrategy: assignStrategy,
assignedTo: assignedToValue,
assignStrategy: assignStrategyValue,
isRolling: isRolling,
isActive: isActive,
notification: isNotificable,
@@ -463,6 +471,20 @@ const ChoreEdit = () => {
}
}
}, [])
useEffect(() => {
if (choreId || !userProfile?.id) return
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
const defaultAssigneeSetting = localStorage.getItem(
'defaultAssigneeSetting',
)
if (defaultAnyoneSetting === null && defaultAssigneeSetting === null) {
setAnyone(false)
setAssignableTo([{ userId: userProfile.id }])
setAssignedTo(userProfile.id)
}
}, [choreId, userProfile?.id])
useEffect(() => {
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
@@ -549,7 +571,7 @@ const ChoreEdit = () => {
setAssignStrategy(
data.res.assignStrategy
? data.res.assignStrategy
: ASSIGN_STRATEGIES[2],
: DEFAULT_ASSIGN_STRATEGY,
)
setIsRolling(data.res.isRolling)
setIsActive(data.res.isActive)
@@ -632,21 +654,6 @@ const ChoreEdit = () => {
}
}, [frequencyType])
useEffect(() => {
if (anyone || assignableTo.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else if (assignStrategy === 'no_assignee') {
// user explicitly picked no_assignee while having assignees, keep it
// but there is nobody currently assigned
if (assignedTo !== null) {
setAssignedTo(null)
}
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
setAssignedTo(assignableTo[0].userId)
}
}, [assignStrategy, assignedTo, assignableTo, anyone])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
// setAssignees([
@@ -1260,12 +1267,13 @@ const ChoreEdit = () => {
)}
</Box>
{!anyone && assignableTo.length > 1 && (
{canPickStrategy && (
<>
<Box
mb={3}
sx={{
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
display:
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
}}
>
<Typography level='h4'>Currently Assigned To</Typography>
@@ -1279,7 +1287,7 @@ const ChoreEdit = () => {
: 'Select an assignee for this task'
}
disabled={assignees.length === 0}
value={assignedTo > -1 ? assignedTo : null}
value={assignedToValue}
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
>
{performers
@@ -1309,7 +1317,7 @@ const ChoreEdit = () => {
{ASSIGN_STRATEGIES.map((item, idx) => (
<ListItem key={item}>
<Checkbox
checked={assignStrategy === item}
checked={assignStrategyValue === item}
onClick={() => setAssignStrategy(item)}
overlay
disableIcon

View File

@@ -84,9 +84,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
.join(', ')
const timeStr = metadata.time
? formatTimeFn(metadata.time)
: '6:00 PM'
const timeStr = metadata.time ? formatTimeFn(metadata.time) : '6:00 PM'
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
return `Every ${dayNames} at ${timeStr}`
@@ -109,11 +107,11 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
}
export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,
frequencyMetadata,
frequencyType,
onFrequencyMetadataUpdate,
onFrequencyUpdate,
}) => {
const { fmt } = useLocalization()
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
@@ -527,20 +525,21 @@ export const RepeatOnSections = ({
}
const RepeatSection = ({
frequencyType,
frequency,
onFrequencyUpdate,
onFrequencyTypeUpdate,
frequencyMetadata,
onFrequencyMetadataUpdate,
frequencyError,
allUserThings,
onTriggerUpdate,
OnTriggerValidate,
allUserThings,
frequency,
frequencyError,
frequencyMetadata,
frequencyType,
isAttemptToSave,
onFrequencyMetadataUpdate,
onFrequencyTypeUpdate,
onFrequencyUpdate,
onTriggerUpdate,
selectedThing,
viewOnly = false,
}) => {
const { data: userProfile } = useUserProfile()
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
return (
<Box mt={2}>

View File

@@ -49,7 +49,7 @@ const ChoreCard = ({
sx,
viewOnly,
}) => {
const { data: userProfile } = useUserProfile()
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
const { timeFormat } = useLocalization()
const { data: pendingCmds } = usePendingCommands(chore.id)

View File

@@ -4,9 +4,9 @@ import {
CalendarMonth,
CloudOff,
EditCalendar,
SearchOff,
ExpandCircleDown,
PriorityHigh,
SearchOff,
Style,
} from '@mui/icons-material'
import {
@@ -20,44 +20,42 @@ import {
IconButton,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
ChoreFilters,
ChoresGrouper,
ChoreSorter,
filterByProject,
} from '../../utils/Chores'
import Priorities from '../../utils/Priorities'
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreModals from './components/ChoreModals'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import {
conditionsToSelections,
selectionsToConditions,
} from './components/FilterBuilderContent'
import ChoreModals from './components/ChoreModals'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import { useChoreActions } from './hooks/useChoreActions'
@@ -79,7 +77,7 @@ const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning, showUndo } = useNotification()
const { showError, showSuccess, showUndo, showWarning } = useNotification()
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const Navigate = useNavigate()
@@ -88,20 +86,19 @@ const MyChores = () => {
const { data: projects = [], isLoading: projectsLoading } = useProjects()
const {
data: choresData,
isLoading: choresLoading,
isError: choresError,
error: choresErrorDetails,
isError: choresError,
isLoading: choresLoading,
refetch: refetchChores,
} = useChores(false)
const {
data: membersData,
isLoading: membersLoading,
isError: membersError,
isLoading: membersLoading,
} = useCircleMembers()
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
// 'voice' | 'scan' | null — set by the quick-capture widget deep links
const [addTaskInitialMode, setAddTaskInitialMode] = useState(null)
@@ -118,6 +115,9 @@ const MyChores = () => {
return {}
}
})
const openSectionsInitializedRef = useRef(
localStorage.getItem('openChoreSections') !== null,
)
const [anchorEl, setAnchorEl] = useState(null)
const [viewMode, setViewMode] = useState(
localStorage.getItem('choreCardViewMode') || 'default',
@@ -126,15 +126,15 @@ const MyChores = () => {
const menuRef = useRef(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
useProjectFilter(projects, !projectsLoading)
const {
searchTerm,
selectedChoreFilter,
nonProjectFilteredChores,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
searchTerm,
selectedChoreFilter,
setSearchTerm,
setSelectedChoreFilterWithCache,
} = useChoreFilters({
@@ -145,37 +145,37 @@ const MyChores = () => {
})
const {
isMultiSelectMode,
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores,
clearSelection,
enterMultiSelectWithChore,
getSelectedChoresData,
isMultiSelectMode,
selectAllVisibleChores,
selectedChores,
toggleChoreSelection,
toggleMultiSelectMode,
} = useMultiSelect()
const { activeModal, modalChore, modalData, openModal, closeModal } =
const { activeModal, closeModal, modalChore, modalData, openModal } =
useChoreModals()
const {
savedFilters,
activeFilter,
activeFilterId,
applyCustomFilter,
applyTempFilter,
clearActiveFilter,
clearTempFilter,
createFilterFromCurrentState,
deleteFilter,
filteredChores: customFilteredChores,
hasFilterApplied,
hasProjectConditions,
pinFilter,
saveFilter,
savedFilters,
tempFilter,
tempFilterMeta,
filteredChores: customFilteredChores,
applyCustomFilter,
clearActiveFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
hasFilterApplied,
} = useCustomFilters(
nonProjectFilteredChores,
membersData?.res,
@@ -276,10 +276,10 @@ const MyChores = () => {
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
filteredData: quickFilteredChores,
hasActiveFilters: hasQuickFilters,
setFilter: setQuickFilter,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
@@ -301,7 +301,7 @@ const MyChores = () => {
return sortedChores
}, [choresData?.res, impersonatedUser])
const processedSections = useMemo(() => {
const choreSections = useMemo(() => {
if (!chores.length || !userProfile?.id) {
return []
}
@@ -372,7 +372,6 @@ const MyChores = () => {
}
processEffectAsync()
// throw new Error('Fake Error to test posthog')
}
}, [
membersLoading,
@@ -385,26 +384,16 @@ const MyChores = () => {
impersonatedUser?.userId,
])
// Auto-update sections when processedSections changes
useEffect(() => {
// Always update choreSections to match processedSections, even if empty
setChoreSections(processedSections)
if (openSectionsInitializedRef.current || choreSections.length === 0) return
// Auto-open sections if needed - only check localStorage once
if (processedSections.length > 0) {
const storedSections = localStorage.getItem('openChoreSections')
if (storedSections === null) {
const openSections = processedSections.reduce(
(acc, _section, index) => {
acc[index] = true
return acc
},
{},
)
setOpenChoreSections(openSections)
}
}
}, [processedSections])
openSectionsInitializedRef.current = true
const openSections = choreSections.reduce((acc, _section, index) => {
acc[index] = true
return acc
}, {})
setOpenChoreSections(openSections)
}, [choreSections])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
@@ -567,17 +556,17 @@ const MyChores = () => {
}, [tempFilterMeta?.id, searchParams])
const {
handleChoreAction,
handleChangeDueDate,
handleCompleteWithPastDate,
handleAssigneeChange,
handleCompleteWithNote,
handleNudge,
handleBulkComplete,
handleBulkArchive,
handleBulkComplete,
handleBulkDelete,
handleBulkSkip,
handleBulkMoveToProject,
handleBulkSkip,
handleChangeDueDate,
handleChoreAction,
handleCompleteWithNote,
handleCompleteWithPastDate,
handleNudge,
} = useChoreActions({
chores,
filteredChores,
@@ -603,24 +592,14 @@ const MyChores = () => {
return customFilteredChores
}
if (searchTerm?.length > 0) {
return searchFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
@@ -629,6 +608,7 @@ const MyChores = () => {
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchFilteredChores,
searchTerm,
])
@@ -751,29 +731,10 @@ const MyChores = () => {
)
}
const searchOptions = useMemo(
() => ({
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[],
)
const processedChoresForSearch = useMemo(
() =>
chores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
})),
[chores],
)
const fuse = useMemo(
() => new Fuse(processedChoresForSearch, searchOptions),
[processedChoresForSearch, searchOptions],
)
const clearTempFilterAndUrl = () => {
clearTempFilter()
updateFilterUrl(null, null)
}
const handleSearchChange = e => {
clearActiveFilter()
@@ -790,22 +751,6 @@ const MyChores = () => {
const term = search.toLowerCase()
setSearchTerm(term)
// Use project-filtered chores as base for search
const baseChores = selectedProject ? projectFilteredChores : chores
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
setFilteredChores(fuse.search(term).map(result => result.item))
// Clear selected calendar date when search changes
setSelectedCalendarDate(null)
}
@@ -895,19 +840,15 @@ const MyChores = () => {
// )
// }
const getChoresForDate = useCallback(
date => {
const filteredChoresData = getFilteredChores
return filteredChoresData.filter(chore => {
if (!chore.nextDueDate) return false
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
const selectedDate = date.toLocaleDateString()
return choreDate === selectedDate
})
},
[getFilteredChores],
)
const selectedDateChores = useMemo(() => {
if (!selectedCalendarDate) return []
const selectedDate = selectedCalendarDate.toLocaleDateString()
return getFilteredChores.filter(chore => {
if (!chore.nextDueDate) return false
return new Date(chore.nextDueDate).toLocaleDateString() === selectedDate
})
}, [getFilteredChores, selectedCalendarDate])
// "Narrowed" means the user actively cut the list down (search, quick
// filters, a saved filter). Picking a project is not narrowing: an empty
@@ -929,7 +870,6 @@ const MyChores = () => {
const appendChore = (prev, newChore) => {
let newChores = [...prev, newChore]
if (impersonatedUser) {
newChores = newChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
@@ -1010,7 +950,7 @@ const MyChores = () => {
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
clearTempFilter={clearTempFilterAndUrl}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
@@ -1363,7 +1303,7 @@ const MyChores = () => {
overflowY: 'auto',
}}
>
{getChoresForDate(selectedCalendarDate).length === 0 ? (
{selectedDateChores.length === 0 ? (
<EmptyState
size='sm'
icon={<EditCalendar />}
@@ -1377,7 +1317,7 @@ const MyChores = () => {
/>
) : (
<ChoreListView
chores={getChoresForDate(selectedCalendarDate)}
chores={selectedDateChores}
viewMode={'compact'}
membersData={membersData}
userLabels={userLabels}
@@ -1457,18 +1397,20 @@ const MyChores = () => {
},
}}
>
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
{openChoreSections[index] && (
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/>
)}
</AccordionDetails>
</Accordion>
)
@@ -1574,7 +1516,7 @@ const MyChores = () => {
allChores={chores}
performers={membersData?.res || []}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
clearTempFilter={clearTempFilterAndUrl}
tempFilter={tempFilter}
/>

View File

@@ -22,23 +22,36 @@ export const useChoreFilters = ({
return filterByProject(chores, selectedProject.id)
}, [chores, selectedProject])
const hasSearchTerm = searchTerm.length > 0
const searchIndex = useMemo(() => {
if (!hasSearchTerm) return null
const searchableChores = chores.map(chore => ({
...chore,
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
}))
return new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
}, [chores, hasSearchTerm])
const projectChoreIds = useMemo(
() => new Set(projectFilteredChores.map(chore => chore.id)),
[projectFilteredChores],
)
const searchFilteredChores = useMemo(() => {
let baseChores = projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
if (searchIndex) {
return searchIndex
.search(searchTerm.toLowerCase())
.map(result => result.item)
.filter(chore => projectChoreIds.has(chore.id))
}
if (impersonatedUser) {
@@ -55,6 +68,8 @@ export const useChoreFilters = ({
}, [
searchTerm,
projectFilteredChores,
searchIndex,
projectChoreIds,
impersonatedUser,
userProfile?.id,
selectedChoreFilter,
@@ -64,20 +79,10 @@ export const useChoreFilters = ({
const nonProjectFilteredChores = useMemo(() => {
let baseChores = chores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
if (searchIndex) {
return searchIndex
.search(searchTerm.toLowerCase())
.map(result => result.item)
}
if (impersonatedUser) {
@@ -94,6 +99,7 @@ export const useChoreFilters = ({
}, [
searchTerm,
chores,
searchIndex,
impersonatedUser,
userProfile?.id,
selectedChoreFilter,

View File

@@ -1,11 +1,7 @@
import { useCallback, useMemo, useState } from 'react'
import { useUserProfile } from '../../../queries/UserQueries'
import {
applyFilter,
getFilterCount,
getFilterOverdueCount,
validateFilter,
} from '../../../utils/FilterEngine'
import { applyFilter, validateFilter } from '../../../utils/FilterEngine'
import {
useCreateFilter,
useDeleteFilter,
@@ -43,12 +39,17 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
return filtersData.map(filter => {
const validation = validateFilter(filter, context)
const count = validation.isValid
? getFilterCount(chores, filter, context)
: 0
const overdueCount = validation.isValid
? getFilterOverdueCount(chores, filter, context)
: 0
const matchingChores = validation.isValid
? applyFilter(chores, filter, context)
: []
const count = matchingChores.length
const now = new Date()
const overdueCount = matchingChores.reduce((total, chore) => {
if (!chore.nextDueDate || new Date(chore.nextDueDate) >= now) {
return total
}
return total + 1
}, 0)
const result = {
...filter,

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

@@ -1,5 +1,6 @@
import { Box, Card, Grid, List, Typography } from '@mui/joy'
import moment from 'moment'
import HistoryCard from '../History/HistoryCard'
const DemoHistory = () => {
@@ -7,29 +8,32 @@ const DemoHistory = () => {
{
id: 32,
choreId: 12,
completedAt: moment().hour(4).format(),
performedAt: moment().hour(4).format(),
completedBy: 1,
assignedTo: 1,
notes: null,
dueDate: moment().format(),
status: 1,
},
{
id: 31,
choreId: 12,
completedAt: moment().day(-1).format(),
performedAt: moment().day(-1).format(),
completedBy: 1,
assignedTo: 1,
notes: 'Need to be replaced with a new one',
dueDate: moment().day(-2).format(),
status: 1,
},
{
id: 31,
choreId: 12,
completedAt: moment().day(-10).hour(1).format(),
performedAt: moment().day(-10).hour(1).format(),
completedBy: 2,
assignedTo: 1,
notes: null,
dueDate: moment().day(-10).format(),
status: 1,
},
]
const performers = [
@@ -61,6 +65,7 @@ const DemoHistory = () => {
key={index}
index={index}
performers={performers}
pendingCommands={[]}
/>
</div>
))}

View File

@@ -1,6 +1,7 @@
import { Card, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useState } from 'react'
import ChoreCard from '../Chores/ChoreCard'
const DemoMyChore = () => {
@@ -121,7 +122,12 @@ const DemoMyChore = () => {
data-aos-anchor='[data-aos-first-tasks-list]'
data-aos='fade-up'
>
<ChoreCard chore={card} performers={users} viewOnly={true} />
<ChoreCard
chore={card}
performers={users}
showActions={false}
viewOnly
/>
</div>
))}
</Grid>
@@ -141,10 +147,10 @@ const DemoMyChore = () => {
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
Main view prioritize tasks due today, followed by overdue ones, and
finally, future tasks or those without due dates. With Donetick, you
can view all the tasks you've created (whether assigned to you or
not) as well as tasks assigned to you by others. Quickly mark them
as done with just one click, ensuring a smooth and efficient task
management experience.
can view all the tasks you&apos;ve created (whether assigned to you
or not) as well as tasks assigned to you by others. Quickly mark
them as done with just one click, ensuring a smooth and efficient
task management experience.
</Typography>
</Card>
</Grid>

View File

@@ -1,5 +1,6 @@
import { Box, Card, Grid, Typography } from '@mui/joy'
import { useState } from 'react'
import RepeatSection from '../ChoreEdit/RepeatSection'
const DemoScheduler = () => {
@@ -32,6 +33,7 @@ const DemoScheduler = () => {
OnTriggerValidate={() => {}}
isAttemptToSave={false}
selectedThing={null}
viewOnly
/>
</Box>
</Grid>

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