10 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
Mo Tarbin
555c9d0774 Release 1.2.37
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-05 02:12:21 -04:00
Mohamad Tarbin
48dbc7a152 Fix button issues, improve layout, and enhance notification handling (#192)
* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components

* fix(sync): improve sync handling to coalesce concurrent requests and prevent lost writes

there is BUG that was very annoying cause by race condition we we update and sync happen as we update and doesn't return the latest added task. this basically fix it
2026-08-05 01:14:39 -04:00
Mohamad Tarbin
784ffbcd00 Fix action button layout and enhance notification handling (#190)
* Fix approval /declien button issue. update action buttons and improve layout

* feat(notifications): enhance notification handling and default templates across components
2026-08-04 21:22:09 -04:00
Mohamad Tarbin
412faf4ad5 Revert "feat(auth): hide password login when disable_password_auth is set (#4…" (#189)
This reverts commit a05f8dc4a4.
2026-08-03 00:39:01 -04:00
Louis-Michel Couture
a05f8dc4a4 feat(auth): hide password login when disable_password_auth is set (#438) (#121)
Thank yWhen the backend resource endpoint reports disable_password_auth, the login
view hides the username/password form, the 'or' divider and the 'Create new
account' button, leaving only the configured SSO button — so SSO-only
instances present a clean login.

Auth options render only once the resource query has settled, so the password
form never flashes before being hidden (no FOUC) on SSO-only instances.

Also drops a dead setUserProfile(user) call in the post-auth effect (the
symbol was removed earlier, leaving a no-undef that broke lint on this file);
the profile now comes from useUserProfile().

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 00:36:05 -04:00
48 changed files with 1943 additions and 416 deletions

View File

@@ -13,8 +13,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 57
versionName "1.2.36"
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 = 57;
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.36;
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 = 57;
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.36;
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 = 57;
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.36;
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 = 57;
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.36;
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.36",
"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

@@ -63,6 +63,10 @@ function getInternalValue(timing, displayValue) {
const NotificationTemplate = ({
maxNotifications = 5,
// ChoreEdit gates this editor behind its own on/off switch, so the last row
// must stay put — `notification: true` with no templates is not a valid task.
// Consumers that own an empty state themselves pass 0.
minNotifications = 1,
onChange,
value,
showTimeline = true,
@@ -259,7 +263,8 @@ const NotificationTemplate = ({
return next
})
onChange && onChange(updated)
// No direct onChange here: consumers expect { notifications }, and the
// effect below already emits that shape once the state settles.
setShowSaveDefault(true)
}
@@ -307,9 +312,6 @@ const NotificationTemplate = ({
return (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
@@ -642,7 +644,7 @@ const NotificationTemplate = ({
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
disabled={notifications.length <= minNotifications}
color={'danger'}
size={'sm'}
variant={'soft'}

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

@@ -99,7 +99,8 @@ export const useChores = (includeArchive = false) => {
queryFn: async () => {
if (isOfflineFeatureEnabled()) {
try {
// Sync from server first (no-op if already syncing or offline)
// Sync from server first (coalesced with any run already in flight,
// so a just-created chore can't be missed by a stale cursor)
if (networkManager.isOnline) {
await syncEngine.sync()
}

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

@@ -28,6 +28,10 @@ class SyncEngine {
constructor() {
this.isSyncing = false
this.listeners = []
// The run currently in flight, and the single follow-up run queued behind
// it. See sync() for why a follow-up is needed rather than just waiting.
this.inFlight = null
this.queued = null
}
// Register listener for sync state changes
@@ -42,10 +46,38 @@ class SyncEngine {
this.listeners.forEach(cb => cb(state))
}
// Main sync entry point — returns true if sync succeeded, false otherwise
// Main sync entry point — returns true if sync succeeded, false otherwise.
//
// Concurrent callers are coalesced rather than dropped. Returning early while
// another run is in flight used to lose writes: that run's /sync/changes
// request may have been issued *before* the caller's change reached the
// server, so its cursor skips past the change and the caller reads a cache
// that will never contain it until something else triggers a sync. That is
// why a task created from the modal could vanish on the refetch right after
// it was created. Waiting for the in-flight run is not enough for the same
// reason, so callers that arrive mid-run share one follow-up run instead.
async sync() {
if (!isOfflineFeatureEnabled()) return false
if (this.isSyncing) return false
if (this.inFlight) {
if (!this.queued) {
this.queued = this.inFlight
.catch(() => false)
.then(() => {
this.queued = null
return this.sync()
})
}
return this.queued
}
this.inFlight = this._runSync().finally(() => {
this.inFlight = null
})
return this.inFlight
}
async _runSync() {
this.isSyncing = true
this._notify({ syncing: true, error: null })

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

@@ -5,7 +5,7 @@ import {
Pause,
PlayArrow,
Repeat,
Schedule,
ThumbDown,
ThumbUp,
TimesOneMobiledata,
Toll,
@@ -21,6 +21,7 @@ import {
IconButton,
Typography,
} from '@mui/joy'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useLocalization } from '../../contexts/LocalizationContext'
import { usePendingCommands } from '../../hooks/usePendingCommands'
@@ -37,18 +38,18 @@ import ChoreActionMenu from '../components/ChoreActionMenu'
import PendingBadge from '../components/PendingBadge'
const ChoreCard = ({
chore,
performers,
sx,
viewOnly,
showActions = true,
onChipClick,
onAction,
// Multi-select props
isMultiSelectMode = false,
isSelected = false,
onAction,
onChipClick,
onSelectionToggle,
performers,
// Multi-select props
showActions = true,
sx,
viewOnly,
}) => {
const { data: userProfile } = useUserProfile()
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
const { timeFormat } = useLocalization()
const { data: pendingCmds } = usePendingCommands(chore.id)
@@ -359,27 +360,6 @@ const ChoreCard = ({
justifyContent: 'center',
}}
>
{chore.status === 3 && (
<Chip
variant='soft'
color='neutral'
size='sm'
sx={{
mb: 1,
px: 0.75,
py: 0.5,
minHeight: 56,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
gap: 0.25,
}}
>
<Schedule sx={{ fontSize: 16 }} />
<Typography level='body-xs'>Pending</Typography>
</Chip>
)}
{showActions && (
<Box
display='flex'
@@ -420,29 +400,31 @@ const ChoreCard = ({
>
<ThumbUp sx={{ fontSize: 18 }} />
</IconButton>
{/* <IconButton
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onAction('reject', chore)
}}
sx={{
borderRadius: '50%',
minWidth: 40,
height: 40,
zIndex: 1,
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.05)',
},
'&:active': {
transform: 'scale(0.95)',
},
}}
>
<ThumbDown sx={{ fontSize: 18 }} />
</IconButton> */}
<IconButton
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onAction('reject', chore)
}}
sx={{
borderRadius: '50%',
width: 50,
minWidth: 50,
height: 50,
flexShrink: 0,
zIndex: 1,
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.05)',
},
'&:active': {
transform: 'scale(0.95)',
},
}}
>
<ThumbDown sx={{ fontSize: 18 }} />
</IconButton>
</Box>
) : (
<IconButton
@@ -515,6 +497,7 @@ const ChoreCard = ({
</IconButton>
)}
<ChoreActionMenu
variant='plain'
chore={chore}
onCompleteWithNote={() =>
onAction('completeWithNote', chore)
@@ -530,6 +513,16 @@ const ChoreCard = ({
onWriteNFC={() => onAction('writeNFC', chore)}
onNudge={() => onAction('nudge', chore)}
onDelete={() => onAction('delete', chore)}
sx={{
width: 32,
height: 32,
color: 'text.tertiary',
flexShrink: 0,
'&:hover': {
color: 'text.secondary',
bgcolor: 'background.level1',
},
}}
/>
</Box>
)}

View File

@@ -49,6 +49,23 @@ const getTimeFromTemplate = (template, relativeTime) => {
}
return time
}
// Decide whether this device's user should be notified about a chore:
// - assignedTo set -> only that user
// - no assignedTo -> everyone listed in assignees
// - no assignees -> "Anyone" mode, notify the whole circle
const shouldNotifyUser = (chore, userId) => {
if (!userId) {
return false
}
if (chore.assignedTo > 0) {
return chore.assignedTo === userId
}
if (chore.assignees?.length > 0) {
return chore.assignees.some(assignee => assignee.userId === userId)
}
return true
}
const scheduleNotificationFromTemplate = (
chore,
userProfile,
@@ -193,7 +210,8 @@ const scheduleChoreNotification = async (
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
chore.isActive === false ||
!shouldNotifyUser(chore, userProfile?.id)
) {
continue
}

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

View File

@@ -45,22 +45,65 @@ import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask'
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel'
// Canonical reminder template shape, shared with NotificationTemplate and
// LocalNotificationScheduler: a signed value plus 'm' | 'h' | 'd'. Negative is
// before due, positive is after, zero is on due.
const DEFAULT_NOTIFICATION_TEMPLATES = [
{ value: -1, unit: 'd' },
{ value: 0, unit: 'm' },
{ value: 1, unit: 'd' },
]
const UNIT_ALIASES = {
minute: 'm',
minutes: 'm',
hour: 'h',
hours: 'h',
day: 'd',
days: 'd',
}
// Earlier builds stored {value: 1, unit: 'days', type: 'before'}. Nothing reads
// `type`, and the scheduler's unit switch falls through on 'days', so those
// entries fired at the due time (or collided on id) instead of offsetting.
const normalizeTemplate = template => {
const unit = UNIT_ALIASES[template.unit] || template.unit
const value = Number(template.value) || 0
if (!template.type) return { value, unit }
if (template.type === 'ondue') return { value: 0, unit }
return {
value: template.type === 'before' ? -Math.abs(value) : Math.abs(value),
unit,
}
}
const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
if (storedDefault) {
return JSON.parse(storedDefault)
try {
const parsed = JSON.parse(storedDefault)
if (Array.isArray(parsed)) {
// An empty list is a deliberate "no reminders by default", not a
// missing value — respect it instead of re-seeding.
const normalized = parsed.map(normalizeTemplate)
if (JSON.stringify(normalized) !== storedDefault) {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(normalized),
)
}
return normalized
}
} catch {
// fall through and reset to the defaults below
}
}
const defaultNotification = [
{ value: 1, unit: 'days', type: 'before' },
{ value: 0, unit: 'minutes', type: 'ondue' },
{ value: 1, unit: 'days', type: 'after' },
]
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(defaultNotification),
JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES),
)
return defaultNotification
return DEFAULT_NOTIFICATION_TEMPLATES
}
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
@@ -157,6 +200,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
isListening: false,
})
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
// Reminder default the voice cards start from — read once so the array
// identity stays stable across renders of the panel
const voiceDefaultNotificationTemplates = useMemo(
() => getDefaultNotification(),
[],
)
// Same arrangement for the scan panel: it reports the action for its
// current phase and the modal footer renders it
const [scanState, setScanState] = useState({
@@ -558,6 +607,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
if ('priority' in overrides) setPriority(overrides.priority || 0)
if ('frequency' in overrides) setFrequency(overrides.frequency)
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
if ('notificationMetadata' in overrides) {
setNotificationMetadata(
overrides.notificationMetadata || { templates: [] },
)
}
if ('assignees' in overrides || 'isAnyone' in overrides) {
setIsAnyoneTask(!!overrides.isAnyone)
setAssignees(overrides.assignees || [])
@@ -812,24 +866,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: subTasks?.length > 0 ? subTasks : null,
projectId: projectId === 'default' ? null : projectId,
draftId: draftId,
}
// Reminders are a Plus feature and only make sense when the user kept at
// least one template; without the flag the backend never schedules them.
const hasReminders =
isPlusAccount(userProfile) && notificationMetadata?.templates?.length > 0
if (frequency) {
chore.frequencyType = frequency.frequencyType
chore.frequencyMetadata = frequency.frequencyMetadata
chore.frequency = frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
}
if (!frequency && dueDate) {
// Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString()
}
if (hasReminders && (frequency || dueDate)) {
chore.notification = true
chore.notificationMetadata = notificationMetadata
}
@@ -1291,6 +1350,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
userLabels={voiceLabels}
members={voiceMembers}
userProfile={userProfile}
defaultNotificationTemplates={voiceDefaultNotificationTemplates}
onStateChange={setVoiceState}
/>
)}

View File

@@ -135,10 +135,11 @@ const NotificationPickerField = ({
>
<NotificationTemplate
value={value}
minNotifications={0}
onChange={({ notifications }) => {
latestTemplatesRef.current = notifications
}}
showTimeline
showTimeline={false}
/>
</ResponsiveModal>
</>

View File

@@ -6,6 +6,7 @@ import {
GraphicEq,
Lock,
Mic,
NotificationsNone,
Person,
Repeat,
Sell,
@@ -16,9 +17,11 @@ import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useRef, useState } from 'react'
import { TASK_COLOR } from '../../../utils/Colors'
import { isPlusAccount } from '../../../utils/Helpers'
import AssigneePickerField from '../AssigneePickerField'
import DueDatePickerField from '../DueDatePickerField'
import LabelsPickerField from '../LabelsPickerField'
import NotificationPickerField from '../NotificationPickerField'
import PriorityPickerField from '../PriorityPickerField'
import RepeatPickerField from '../RepeatPickerField'
import { parseVoiceTask } from './parseVoiceTask'
@@ -102,7 +105,11 @@ const describeFrequency = f => {
return names[f.frequencyType] || 'Repeats'
}
const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
const buildChips = (
effective,
frequencyLabel,
{ members, currentUserId, canRemind },
) => {
const chips = []
if (effective.dueDate) {
chips.push({
@@ -128,6 +135,21 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
label: `P${effective.priority}`,
})
}
// Mirrors buildChorePayload: reminders only reach the backend for Plus
// accounts on a task that has something to remind against
const reminderCount = effective.notificationMetadata?.templates?.length || 0
if (
canRemind &&
reminderCount > 0 &&
(effective.dueDate || effective.frequency)
) {
chips.push({
key: 'reminders',
color: 'neutral',
icon: <NotificationsNone sx={{ fontSize: 12 }} />,
label: reminderCount > 1 ? `${reminderCount} reminders` : '1 reminder',
})
}
if (effective.points != null) {
chips.push({
key: 'points',
@@ -184,9 +206,17 @@ const TaskPreviewCard = ({
[segment.text, parseCtx],
)
const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
// The parser has no notion of reminders, so the account default stands in
// until the card overrides it — same fallback buildChorePayload applies
const effective = useMemo(
() => ({ ...parsed, ...overrides }),
[parsed, overrides],
() => ({
...parsed,
notificationMetadata: {
templates: parseCtx.defaultNotificationTemplates || [],
},
...overrides,
}),
[parsed, overrides, parseCtx.defaultNotificationTemplates],
)
const frequencyLabel =
@@ -329,6 +359,7 @@ const TaskPreviewCard = ({
{expanded && (
<Box
sx={{
mt: 1,
display: 'flex',
flexDirection: 'row',
gap: 1,
@@ -392,6 +423,16 @@ const TaskPreviewCard = ({
onClear={() => onPatch({ labelIds: [] })}
labels={parseCtx.userLabels}
/>
{parseCtx.canRemind && (
<NotificationPickerField
emptyDisplay='icon'
value={effective.notificationMetadata}
onChange={metadata => onPatch({ notificationMetadata: metadata })}
onClear={() =>
onPatch({ notificationMetadata: { templates: [] } })
}
/>
)}
</Box>
)}
</Box>
@@ -412,6 +453,7 @@ const VoicePanel = ({
userLabels = [],
members = [],
userProfile,
defaultNotificationTemplates = [],
onStateChange,
}) => {
const {
@@ -430,8 +472,14 @@ const VoicePanel = ({
const segmentsScrollRef = useRef(null)
const parseCtx = useMemo(
() => ({ userLabels, members, currentUserId: userProfile?.id }),
[userLabels, members, userProfile?.id],
() => ({
userLabels,
members,
currentUserId: userProfile?.id,
canRemind: isPlusAccount(userProfile),
defaultNotificationTemplates,
}),
[userLabels, members, userProfile, defaultNotificationTemplates],
)
const partialParsed = useMemo(

View File

@@ -177,24 +177,33 @@ export const buildChorePayload = (
status: 0,
frequencyType: 'once',
frequencyMetadata: {},
notification: false,
notificationMetadata: {},
subTasks: null,
projectId: projectId === 'default' ? null : projectId,
draftId: generateUUID(),
}
// A per-task override from the voice card wins over the account default;
// an override of [] is a deliberate "no reminders", not a missing value.
const templates =
parsed.notificationMetadata?.templates ?? notificationTemplates
// Reminders are a Plus feature; the flag is what makes the backend schedule
// them, so metadata alone is not enough.
const hasReminders = isPlusAccount(userProfile) && templates?.length > 0
if (parsed.frequency) {
chore.frequencyType = parsed.frequency.frequencyType
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
chore.frequency = parsed.frequency.frequency
if (isPlusAccount(userProfile)) {
chore.notification = true
chore.notificationMetadata = { templates: notificationTemplates }
}
}
if (!parsed.frequency && parsed.dueDate) {
chore.nextDueDate = new Date(parsed.dueDate).toISOString()
chore.notificationMetadata = { templates: notificationTemplates }
}
if (hasReminders && (parsed.frequency || parsed.dueDate)) {
chore.notification = true
chore.notificationMetadata = { templates }
}
return chore