Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0215a78404 | ||
|
|
475806ac29 | ||
|
|
903d022877 | ||
|
|
5fb0b5b656 | ||
|
|
0b4b21bbb0 |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 58
|
||||
versionName "1.2.37"
|
||||
versionCode 59
|
||||
versionName "1.2.38"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -462,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 58;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.37;
|
||||
MARKETING_VERSION = 1.2.38;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -485,12 +485,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 58;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.37;
|
||||
MARKETING_VERSION = 1.2.38;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -504,12 +504,12 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 58;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.37;
|
||||
MARKETING_VERSION = 1.2.38;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -527,12 +527,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 58;
|
||||
CURRENT_PROJECT_VERSION = 59;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.37;
|
||||
MARKETING_VERSION = 1.2.38;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.37",
|
||||
"version": "1.2.38",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
src/App.jsx
30
src/App.jsx
@@ -1,22 +1,23 @@
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
|
||||
import useOnboardingGate from './hooks/useOnboardingGate'
|
||||
import useStatusBar from './hooks/useStatusBar'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { recordRoute } from './service/DiagnosticsSession'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -32,8 +33,15 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
const location = useLocation()
|
||||
useSyncOnReconnect()
|
||||
|
||||
// Every route renders through this Outlet, so one listener here gives crash
|
||||
// reports the trail that led to the failure.
|
||||
useEffect(() => {
|
||||
recordRoute(location.pathname)
|
||||
}, [location.pathname])
|
||||
|
||||
// // First-launch native users see the onboarding flow before anything else.
|
||||
useOnboardingGate()
|
||||
|
||||
@@ -41,8 +49,8 @@ const AppContent = () => {
|
||||
useStatusBar()
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
offlineReady: [offlineReady, setOfflineReady],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(r) {
|
||||
@@ -100,7 +108,7 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const resource = useResource()
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
||||
10
src/Application.jsx
Normal file
10
src/Application.jsx
Normal 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
66
src/MarketingApp.jsx
Normal 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
23
src/main.jsx
23
src/main.jsx
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
189
src/service/DiagnosticsSession.js
Normal file
189
src/service/DiagnosticsSession.js
Normal 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,
|
||||
}
|
||||
}
|
||||
295
src/service/ErrorReportService.js
Normal file
295
src/service/ErrorReportService.js
Normal 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 }),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -49,7 +49,7 @@ const ChoreCard = ({
|
||||
sx,
|
||||
viewOnly,
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
429
src/views/Modals/ErrorReportModal.jsx
Normal file
429
src/views/Modals/ErrorReportModal.jsx
Normal 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've filled in an issue with your
|
||||
notes and the diagnostics — review it and post when you'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
|
||||
Reference in New Issue
Block a user