Compare commits
3 Commits
v1.2.45
...
backup-in-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c832e6af6c | ||
|
|
fc7f46618f | ||
|
|
b2d4d90c9c |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 66
|
||||
versionName "1.2.45"
|
||||
versionCode 55
|
||||
versionName "1.2.34"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -21,7 +21,6 @@ dependencies {
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-share')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-document-scanner')
|
||||
implementation project(':capgo-capacitor-nfc')
|
||||
|
||||
@@ -30,17 +30,6 @@
|
||||
<data android:scheme="donetick" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Verified App Link for Circle invites hosted by Donetick Cloud. -->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="app.donetick.com"
|
||||
android:pathPrefix="/circle/join" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- NFC NDEF dispatch: open app directly when a donetick:// tag is scanned -->
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||
|
||||
@@ -38,9 +38,6 @@ project(':capacitor-preferences').projectDir = new File('../node_modules/@capaci
|
||||
include ':capacitor-push-notifications'
|
||||
project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android')
|
||||
|
||||
include ':capacitor-share'
|
||||
project(':capacitor-share').projectDir = new File('../node_modules/@capacitor/share/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
|
||||
@@ -462,12 +462,12 @@
|
||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
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.45;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
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 = 66;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
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.45;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
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 = 66;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
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.45;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
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 = 66;
|
||||
CURRENT_PROJECT_VERSION = 55;
|
||||
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.45;
|
||||
MARKETING_VERSION = 1.2.34;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array>
|
||||
<string>applinks:app.donetick.com</string>
|
||||
</array>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.donetick.app</string>
|
||||
|
||||
@@ -23,7 +23,6 @@ def capacitor_pods
|
||||
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
|
||||
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
|
||||
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
|
||||
pod 'CapacitorShare', :path => '../../node_modules/@capacitor/share'
|
||||
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
|
||||
pod 'CapgoCapacitorDocumentScanner', :path => '../../node_modules/@capgo/capacitor-document-scanner'
|
||||
pod 'CapgoCapacitorNfc', :path => '../../node_modules/@capgo/capacitor-nfc'
|
||||
|
||||
@@ -12,11 +12,11 @@ PODS:
|
||||
- PromisesObjC (~> 2.4)
|
||||
- PromisesSwift (~> 2.4)
|
||||
- RecaptchaInterop (~> 101.0)
|
||||
- Capacitor (8.4.2):
|
||||
- Capacitor (8.4.1):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (8.1.1):
|
||||
- CapacitorApp (8.1.0):
|
||||
- Capacitor
|
||||
- CapacitorBrowser (8.0.4):
|
||||
- CapacitorBrowser (8.0.3):
|
||||
- Capacitor
|
||||
- CapacitorCommunityInAppReview (8.0.0):
|
||||
- Capacitor
|
||||
@@ -26,14 +26,14 @@ PODS:
|
||||
- Capacitor
|
||||
- SQLCipher
|
||||
- ZIPFoundation
|
||||
- CapacitorCordova (8.4.2)
|
||||
- CapacitorDevice (8.0.3):
|
||||
- CapacitorCordova (8.4.1)
|
||||
- CapacitorDevice (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorHaptics (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorLocalLlm (1.0.0):
|
||||
- Capacitor
|
||||
- CapacitorLocalNotifications (8.2.1):
|
||||
- CapacitorLocalNotifications (8.2.0):
|
||||
- Capacitor
|
||||
- CapacitorNetwork (8.0.1):
|
||||
- Capacitor
|
||||
@@ -41,17 +41,15 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorPreferences (8.0.1):
|
||||
- Capacitor
|
||||
- CapacitorPushNotifications (8.1.2):
|
||||
- CapacitorPushNotifications (8.1.1):
|
||||
- Capacitor
|
||||
- CapacitorShare (8.0.1):
|
||||
- CapacitorStatusBar (8.0.2):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (8.0.3):
|
||||
- Capacitor
|
||||
- CapgoCapacitorDocumentScanner (8.4.2):
|
||||
- CapgoCapacitorDocumentScanner (8.4.0):
|
||||
- Capacitor
|
||||
- CapgoCapacitorNfc (8.2.2):
|
||||
- Capacitor
|
||||
- CapgoCapacitorSocialLogin (8.3.39):
|
||||
- CapgoCapacitorSocialLogin (8.3.38):
|
||||
- Alamofire (~> 5.10.2)
|
||||
- Capacitor
|
||||
- GoogleSignIn (~> 9.0.0)
|
||||
@@ -159,7 +157,6 @@ DEPENDENCIES:
|
||||
- CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`)
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||
- "CapacitorShare (from `../../node_modules/@capacitor/share`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapgoCapacitorDocumentScanner (from `../../node_modules/@capgo/capacitor-document-scanner`)"
|
||||
- "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)"
|
||||
@@ -225,8 +222,6 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/preferences"
|
||||
CapacitorPushNotifications:
|
||||
:path: "../../node_modules/@capacitor/push-notifications"
|
||||
CapacitorShare:
|
||||
:path: "../../node_modules/@capacitor/share"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CapgoCapacitorDocumentScanner:
|
||||
@@ -246,26 +241,25 @@ SPEC CHECKSUMS:
|
||||
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
|
||||
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
|
||||
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
|
||||
Capacitor: 52f999235b8bd6a7d01694753f4f4d54d182e3a3
|
||||
CapacitorApp: 305bd13c44f6f9d164e2ee66a4faa956ed8c0e06
|
||||
CapacitorBrowser: 752e0208aa07c7aa63d1f7cde895a3e9519b2656
|
||||
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
|
||||
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
|
||||
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
|
||||
CapacitorCommunityInAppReview: 4492bdd34aad4d27ed87949376022cc93b294ea1
|
||||
CapacitorCommunitySpeechRecognition: 3e03566c44c2bb3b52391a33d4518b1adbdeb38f
|
||||
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
|
||||
CapacitorCordova: 345eacdc4c8282415446aea4acd8c77d82480bd4
|
||||
CapacitorDevice: 708e742b60a61572cd593e44c809592e8f31da14
|
||||
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
|
||||
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
|
||||
CapacitorHaptics: 296f771ecd89c7a1bd92a7b6826a7d268e2e70f5
|
||||
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
|
||||
CapacitorLocalNotifications: 908a69a3cf7ae345426de3223d4980befb0103c6
|
||||
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
|
||||
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
|
||||
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
|
||||
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
||||
CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237
|
||||
CapacitorShare: 0c58305114538568059bfc07111f22dcb9cb2a82
|
||||
CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af
|
||||
CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942
|
||||
CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e
|
||||
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
|
||||
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
|
||||
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
||||
CapgoCapacitorSocialLogin: 5ea6b14b8e9b1bd74533e580d4cf0e2b85d80793
|
||||
CapgoCapacitorSocialLogin: 5de0c9295188cbd9116d13e4581494f3bbaa3414
|
||||
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
|
||||
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
|
||||
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
|
||||
@@ -289,6 +283,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 0170e6b548e03117ef7d6814596b8b140a6acce4
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.38",
|
||||
"version": "1.2.33",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.38",
|
||||
"version": "1.2.33",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
@@ -24,7 +24,6 @@
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
@@ -2175,15 +2174,6 @@
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/share": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/share/-/share-8.0.1.tgz",
|
||||
"integrity": "sha512-3cSBKBCJVon54rKDROP2rqGyeGks4pBh9TbaEk9S375Kbek/ZHe72N50zIa0Vn9Eac/SuhwgehO/mmA4CsUOiw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/status-bar": {
|
||||
"version": "8.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.3.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.45",
|
||||
"version": "1.2.34",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -56,7 +56,6 @@
|
||||
"@capacitor/network": "^8.0.0",
|
||||
"@capacitor/preferences": "^8.0.0",
|
||||
"@capacitor/push-notifications": "^8.0.0",
|
||||
"@capacitor/share": "^8.0.1",
|
||||
"@capacitor/status-bar": "^8.0.0",
|
||||
"@capgo/capacitor-document-scanner": "^8.4.0",
|
||||
"@capgo/capacitor-nfc": "^8.0.0",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.donetick.app",
|
||||
"sha256_cert_fingerprints": [
|
||||
"EF:45:27:40:A2:D2:11:E4:27:AB:9A:7A:C6:E1:3B:CA:D4:DE:6A:0A:C3:81:05:58:D8:89:F1:FA:4E:CB:44:F3",
|
||||
"67:0B:E5:30:FB:8A:7F:E6:9A:54:51:7F:06:AA:B0:1D:1A:26:61:5B:2A:60:53:4A:31:75:72:DA:F6:FA:EC:5A"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,8 +0,0 @@
|
||||
/.well-known/apple-app-site-association
|
||||
Content-Type: application/json
|
||||
|
||||
/.well-known/apple-app-site-association.json
|
||||
Content-Type: application/json
|
||||
|
||||
/.well-known/assetlinks.json
|
||||
Content-Type: application/json
|
||||
@@ -1 +0,0 @@
|
||||
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"title": "المهام",
|
||||
"myChores": "مهامي",
|
||||
"allChores": "جميع المهام",
|
||||
"addChore": "إضافة مهمة",
|
||||
"editChore": "تعديل مهمة",
|
||||
"deleteChore": "حذف مهمة",
|
||||
"completeChore": "إكمال مهمة",
|
||||
"dueDate": "تاريخ الاستحقاق",
|
||||
"assignedTo": "مُسند إلى",
|
||||
"priority": "الأولوية",
|
||||
"status": "الحالة",
|
||||
"description": "الوصف",
|
||||
"choreView": {
|
||||
"assignment": "التعيين",
|
||||
"assigned": "المعين",
|
||||
"last": "الأخير",
|
||||
"schedule": "الجدول",
|
||||
"due": "الاستحقاق",
|
||||
"statistics": "الإحصائيات",
|
||||
"completed": "مكتمل",
|
||||
"times": "مرات",
|
||||
"details": "التفاصيل",
|
||||
"createdBy": "أنشئ بواسطة",
|
||||
"na": "غير متوفر",
|
||||
"taskCompleted": "المهمة مكتملة",
|
||||
"taskCompletedMessage": "تم وضع علامة مكتملة على مهمتك",
|
||||
"taskCompletionUndone": "تم التراجع عن إكمال المهمة.",
|
||||
"taskSkipUndone": "تم التراجع عن تخطي المهمة.",
|
||||
"undoSuccessful": "التراجع ناجح",
|
||||
"undoFailed": "فشل التراجع",
|
||||
"undoFailedMessage": "تعذر التراجع عن الإجراء. يرجى المحاولة مرة أخرى.",
|
||||
"resetTimer": "إعادة تعيين المؤقت",
|
||||
"resetTimerConfirmation": "هل أنت متأكد من أنك تريد إعادة تعيين المؤقت؟ سيؤدي هذا إلى مسح جميع سجلات الوقت منذ بدء المهمة.",
|
||||
"clearAllTimeRecords": "مسح جميع سجلات الوقت",
|
||||
"clearAllTimeConfirmation": "سيؤدي هذا إلى حذف جميع المؤقتات لهذه المهمة بشكل دائم وإعادتها إلى حالة \"لم تبدأ\".",
|
||||
"descriptionTitle": "الوصف",
|
||||
"description": "الوصف :",
|
||||
"previousNote": "الملاحظة السابقة",
|
||||
"previousNoteLabel": "الملاحظة السابقة:",
|
||||
"subtasksLabel": "المهام الفرعية :",
|
||||
"taskActions": "إجراءات المهمة",
|
||||
"addNote": "أضف ملاحظة",
|
||||
"additionalNotes": "ملاحظات إضافية:",
|
||||
"notePlaceholder": "أضف ملاحظة حول الإكمال...",
|
||||
"setCustomCompletionTime": "تعيين وقت إكمال مخصص",
|
||||
"skipTask": "تخطي المهمة",
|
||||
"skipTaskConfirmation": "هل أنت متأكد من أنك تريد تخطي هذه المهمة؟",
|
||||
"markComplete": "وضع علامة مكتمل",
|
||||
"markAsDone": "وضع علامة كمنجز",
|
||||
"edit": "تعديل",
|
||||
"archive": "أرشفة",
|
||||
"unarchive": "إلغاء الأرشفة",
|
||||
"viewHistory": "عرض السجل",
|
||||
"history": "السجل",
|
||||
"startTimer": "بدء المؤقت",
|
||||
"start": "بدء",
|
||||
"pauseTimer": "إيقاف المؤقت مؤقتاً",
|
||||
"approve": "موافقة",
|
||||
"reject": "رفض",
|
||||
"pendingApproval": "في انتظار الموافقة",
|
||||
"undo": "تراجع",
|
||||
"skip": "تخطي",
|
||||
"cancel": "إلغاء",
|
||||
"noPriority": "بدون أولوية",
|
||||
"subtasks": "المهام الفرعية",
|
||||
"noDescription": "لا يوجد وصف متاح",
|
||||
"timer": {
|
||||
"active": "المؤقت نشط",
|
||||
"paused": "المؤقت متوقف مؤقتاً",
|
||||
"reset": "إعادة تعيين المؤقت",
|
||||
"delete": "حذف الجلسة"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء",
|
||||
"delete": "حذف",
|
||||
"edit": "تعديل",
|
||||
"close": "إغلاق",
|
||||
"confirm": "تأكيد",
|
||||
"loading": "جارٍ التحميل...",
|
||||
"error": "خطأ",
|
||||
"success": "نجح",
|
||||
"warning": "تحذير",
|
||||
"refresh": "تحديث",
|
||||
"copy": "نسخ",
|
||||
"copied": "تم النسخ!",
|
||||
"settings": "الإعدادات",
|
||||
"yes": "نعم",
|
||||
"no": "لا",
|
||||
"back": "رجوع",
|
||||
"backToCalendar": "العودة إلى التقويم",
|
||||
"logout": "تسجيل الخروج",
|
||||
"version": "النسخة",
|
||||
"navigation": {
|
||||
"allTasks": "جميع المهام",
|
||||
"archived": "المؤرشفة",
|
||||
"things": "الأشياء",
|
||||
"labels": "التسميات",
|
||||
"projects": "المشاريع",
|
||||
"filters": "الفلاتر",
|
||||
"activities": "الأنشطة",
|
||||
"points": "النقاط",
|
||||
"settings": "الإعدادات"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"title": "الإعدادات",
|
||||
"circleSettings": {
|
||||
"title": "إعدادات الدائرة",
|
||||
"description": "يتم ربط حسابك تلقائيًا بدائرة عند إنشاء واحدة أو الانضمام إليها. ادعُ الأصدقاء بسهولة من خلال مشاركة رمز الدائرة الفريد أو الرابط أدناه. ستتلقى إشعارًا أدناه عندما يطلب شخص ما الانضمام إلى دائرتك. إذا كنت ترغب في المغادرة، فما عليك سوى الضغط على زر 'مغادرة الدائرة'.",
|
||||
"circleCode": "رمز الدائرة",
|
||||
"copyCode": "نسخ الرمز",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"codeCopied": "تم نسخ رمز الدائرة!",
|
||||
"linkCopied": "تم نسخ الرابط!",
|
||||
"joinCircle": "الانضمام إلى دائرة",
|
||||
"joinCirclePlaceholder": "أدخل رمز الدائرة",
|
||||
"join": "انضمام",
|
||||
"leave": "مغادرة الدائرة",
|
||||
"leaveConfirmTitle": "مغادرة الدائرة",
|
||||
"leaveConfirmMessage": "هل أنت متأكد من أنك تريد مغادرة هذه الدائرة؟",
|
||||
"circleMembers": "أعضاء الدائرة",
|
||||
"circleMemberRequests": "طلبات انضمام الأعضاء",
|
||||
"admin": "مشرف",
|
||||
"member": "عضو",
|
||||
"pending": "قيد الانتظار",
|
||||
"accept": "قبول",
|
||||
"reject": "رفض",
|
||||
"makeAdmin": "جعله مشرف",
|
||||
"makeMember": "جعله عضو",
|
||||
"remove": "إزالة",
|
||||
"webhookURL": "رابط Webhook",
|
||||
"webhookDescription": "أدخل رابط webhook لتلقي إشعارات أحداث الدائرة",
|
||||
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "إعدادات الحساب",
|
||||
"subscription": "الاشتراك",
|
||||
"subscriptionStatus": "الخطة الحالية",
|
||||
"free": "مجاني",
|
||||
"plus": "بلس",
|
||||
"upgrade": "ترقية",
|
||||
"cancel": "إلغاء",
|
||||
"changePassword": "تغيير كلمة المرور",
|
||||
"password": "كلمة المرور",
|
||||
"dangerZone": "منطقة الخطر",
|
||||
"dangerZoneDescription": "بمجرد حذف حسابك، لا يمكن التراجع. يرجى التأكد.",
|
||||
"deleteAccount": "حذف الحساب"
|
||||
},
|
||||
"localization": {
|
||||
"title": "التوطين",
|
||||
"description": "تخصيص اللغة وتنسيق التاريخ والتفضيلات الإقليمية لحسابك.",
|
||||
"language": "اللغة",
|
||||
"languageDescription": "اختر لغتك المفضلة",
|
||||
"dateFormat": "تنسيق التاريخ",
|
||||
"dateFormatDescription": "اختر كيفية عرض التواريخ في التطبيق",
|
||||
"timeFormat": "تنسيق الوقت",
|
||||
"timeFormatDescription": "اختر تنسيق 12 أو 24 ساعة",
|
||||
"12hour": "12 ساعة (ص/م)",
|
||||
"24hour": "24 ساعة",
|
||||
"firstDayOfWeek": "أول يوم في الأسبوع",
|
||||
"firstDayOfWeekDescription": "اختر اليوم الذي يبدأ به أسبوعك",
|
||||
"sunday": "الأحد",
|
||||
"monday": "الاثنين",
|
||||
"saturday": "السبت",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (الولايات المتحدة)",
|
||||
"dmy": "DD/MM/YYYY (أوروبا)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "تنسيق طويل (مثل 1 يناير 2024)",
|
||||
"short": "تنسيق قصير (مثل 1 يناير 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "تخصيص اللوحة الجانبية",
|
||||
"description": "قم بتخصيص تخطيط ورؤية البطاقات في اللوحة الجانبية. هذا القسم متاح فقط على أجهزة الشاشة الكبيرة مثل الأجهزة اللوحية وأجهزة سطح المكتب."
|
||||
},
|
||||
"theme": {
|
||||
"title": "تفضيلات المظهر",
|
||||
"description": "اختر كيف يبدو الموقع لك. حدد مظهرًا واحدًا أو قم بالمزامنة مع نظامك والتبديل تلقائيًا بين مظاهر النهار والليل.",
|
||||
"themeMode": "وضع المظهر",
|
||||
"light": "فاتح",
|
||||
"dark": "داكن",
|
||||
"system": "النظام"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "تم حفظ الإعدادات بنجاح",
|
||||
"settingsSaveFailed": "فشل حفظ الإعدادات",
|
||||
"invalidWebhook": "رابط webhook غير صالح"
|
||||
},
|
||||
"profile": {
|
||||
"title": "إعدادات الملف الشخصي",
|
||||
"description": "تحديث اسم العرض وصورة الملف الشخصي.",
|
||||
"photoUpdated": "تم تحديث الصورة",
|
||||
"photoUpdatedMessage": "تم تحديث صورة ملفك الشخصي بنجاح!",
|
||||
"uploadFailed": "فشل التحميل",
|
||||
"uploadFailedMessage": "فشل تحميل صورتك. يرجى المحاولة مرة أخرى.",
|
||||
"profileUpdated": "تم تحديث الملف الشخصي",
|
||||
"profileUpdatedMessage": "تم حفظ معلومات ملفك الشخصي بنجاح!",
|
||||
"updateFailed": "فشل التحديث",
|
||||
"updateFailedMessage": "تعذر تحديث ملفك الشخصي. يرجى التحقق من اتصالك والمحاولة مرة أخرى.",
|
||||
"changePhoto": "تغيير الصورة",
|
||||
"displayName": "اسم العرض",
|
||||
"displayNamePlaceholder": "أدخل اسم العرض الخاص بك",
|
||||
"timezone": "المنطقة الزمنية",
|
||||
"timezonePlaceholder": "اختر منطقتك الزمنية",
|
||||
"save": "حفظ",
|
||||
"cancel": "إلغاء"
|
||||
},
|
||||
"overview": {
|
||||
"title": "الإعدادات",
|
||||
"subtitle": "قم بتخصيص تجربتك وإدارة تفضيلات حسابك",
|
||||
"upgrade": {
|
||||
"title": "الترقية إلى بلس",
|
||||
"description": "افتح ميزات قوية لتعزيز إنتاجيتك",
|
||||
"button": "الترقية الآن",
|
||||
"features": {
|
||||
"richText": "أوصاف نصية منسقة",
|
||||
"notifications": "إشعارات المهام",
|
||||
"apiIntegrations": "تكاملات API",
|
||||
"advancedAutomation": "أتمتة متقدمة"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "إعدادات الملف الشخصي",
|
||||
"description": "تحديث معلومات ملفك الشخصي والصورة واسم العرض وتفضيلات المنطقة الزمنية."
|
||||
},
|
||||
"circle": {
|
||||
"title": "إعدادات الدائرة",
|
||||
"description": "إدارة دائرتك ودعوة الأعضاء والتعامل مع طلبات الانضمام."
|
||||
},
|
||||
"account": {
|
||||
"title": "إعدادات الحساب",
|
||||
"description": "إدارة اشتراكك وتغيير كلمة المرور وخيارات حذف الحساب."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "الحسابات المُدارة",
|
||||
"description": "إنشاء وإدارة حسابات فرعية لتسجيل الدخول وإكمال المهام المعينة."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "الإشعارات",
|
||||
"description": "تكوين الإشعارات الفورية وتنبيهات البريد الإلكتروني ووجهات الإشعارات للمهام."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "المصادقة متعددة العوامل",
|
||||
"description": "إضافة طبقة إضافية من الأمان باستخدام MFA مع تطبيقات المصادقة."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "رموز API",
|
||||
"description": "إنشاء وإدارة رموز الوصول لتكاملات الطرف الثالث والوصول إلى API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "إعدادات التخزين",
|
||||
"description": "نسخ احتياطي واستعادة بياناتك وإدارة التخزين المحلي وتفضيلات المزامنة."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "تخصيص اللوحة الجانبية",
|
||||
"description": "قم بتخصيص تخطيط ورؤية البطاقات في واجهة اللوحة الجانبية."
|
||||
},
|
||||
"theme": {
|
||||
"title": "تفضيلات المظهر",
|
||||
"description": "اختر مظهرك المفضل وقم بتكوين إعدادات الوضع الداكن/الفاتح."
|
||||
},
|
||||
"localization": {
|
||||
"title": "التوطين",
|
||||
"description": "تخصيص اللغة وتنسيق التاريخ وتنسيق الوقت والتفضيلات الإقليمية."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "الإعدادات المتقدمة",
|
||||
"description": "تكوين webhooks والتحديثات في الوقت الفعلي وميزات متقدمة أخرى لتعزيز الإنتاجية."
|
||||
},
|
||||
"developer": {
|
||||
"title": "إعدادات المطور",
|
||||
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,51 +29,5 @@
|
||||
"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,10 +168,6 @@
|
||||
"developer": {
|
||||
"title": "إعدادات المطور",
|
||||
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"resetTimer": "Timer zurücksetzen",
|
||||
"resetTimerConfirmation": "Bist du sicher, dass du den Timer zurücksetzen möchtest? Dies löscht alle Zeitaufzeichnungen seit du die Aufgabe gestartet hast.",
|
||||
"clearAllTimeRecords": "Alle Zeitaufzeichnungen löschen",
|
||||
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf „nicht gestartet“.",
|
||||
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf \"nicht gestartet\".",
|
||||
"descriptionTitle": "Beschreibung",
|
||||
"description": "Beschreibung:",
|
||||
"previousNote": "Vorherige Notiz",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"navigation": {
|
||||
"allTasks": "Alle Aufgaben",
|
||||
"archived": "Archiviert",
|
||||
"things": "Dinge",
|
||||
"things": "Things",
|
||||
"labels": "Beschriftungen",
|
||||
"projects": "Projekte",
|
||||
"filters": "Filter",
|
||||
|
||||
@@ -168,11 +168,7 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Search",
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
|
||||
@@ -171,11 +171,7 @@
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you or request a feature."
|
||||
},
|
||||
"bugReport": {
|
||||
"title": "Report a Bug",
|
||||
"description": "Something not working right? Send us the details along with a technical snapshot."
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"title": "Tareas",
|
||||
"myChores": "Mis Tareas",
|
||||
"allChores": "Todas las Tareas",
|
||||
"addChore": "Agregar Tarea",
|
||||
"editChore": "Editar Tarea",
|
||||
"deleteChore": "Eliminar Tarea",
|
||||
"completeChore": "Completar Tarea",
|
||||
"dueDate": "Fecha de Vencimiento",
|
||||
"assignedTo": "Asignado a",
|
||||
"priority": "Prioridad",
|
||||
"status": "Estado",
|
||||
"description": "Descripción",
|
||||
"choreView": {
|
||||
"assignment": "Asignación",
|
||||
"assigned": "Asignado",
|
||||
"last": "Último",
|
||||
"schedule": "Horario",
|
||||
"due": "Vencimiento",
|
||||
"statistics": "Estadísticas",
|
||||
"completed": "Completado",
|
||||
"times": "veces",
|
||||
"details": "Detalles",
|
||||
"createdBy": "Creado por",
|
||||
"na": "N/D",
|
||||
"taskCompleted": "Tarea Completada",
|
||||
"taskCompletedMessage": "Tu tarea ha sido marcada como completada",
|
||||
"taskCompletionUndone": "La finalización de la tarea ha sido deshecha.",
|
||||
"taskSkipUndone": "El salto de tarea ha sido deshecho.",
|
||||
"undoSuccessful": "Deshacer Exitoso",
|
||||
"undoFailed": "Deshacer Fallido",
|
||||
"undoFailedMessage": "No se pudo deshacer la acción. Por favor, inténtalo de nuevo.",
|
||||
"resetTimer": "Reiniciar Temporizador",
|
||||
"resetTimerConfirmation": "¿Estás seguro de que quieres reiniciar el temporizador? Esto borrará todos los registros de tiempo desde que iniciaste la tarea.",
|
||||
"clearAllTimeRecords": "Borrar Todos los Registros de Tiempo",
|
||||
"clearAllTimeConfirmation": "Esto eliminará permanentemente todos los temporizadores de esta tarea y la volverá al estado \"no iniciada\".",
|
||||
"descriptionTitle": "Descripción",
|
||||
"description": "Descripción :",
|
||||
"previousNote": "Nota Anterior",
|
||||
"previousNoteLabel": "Nota anterior:",
|
||||
"subtasksLabel": "Subtareas :",
|
||||
"taskActions": "Acciones de Tarea",
|
||||
"addNote": "Agregar una nota",
|
||||
"additionalNotes": "Notas Adicionales:",
|
||||
"notePlaceholder": "Agregar una nota sobre la finalización...",
|
||||
"setCustomCompletionTime": "Establecer hora de finalización personalizada",
|
||||
"skipTask": "Saltar Tarea",
|
||||
"skipTaskConfirmation": "¿Estás seguro de que quieres saltar esta tarea?",
|
||||
"markComplete": "Marcar como Completada",
|
||||
"markAsDone": "Marcar como hecha",
|
||||
"edit": "Editar",
|
||||
"archive": "Archivar",
|
||||
"unarchive": "Desarchivar",
|
||||
"viewHistory": "Ver Historial",
|
||||
"history": "Historial",
|
||||
"startTimer": "Iniciar Temporizador",
|
||||
"start": "Iniciar",
|
||||
"pauseTimer": "Pausar Temporizador",
|
||||
"approve": "Aprobar",
|
||||
"reject": "Rechazar",
|
||||
"pendingApproval": "Pendiente de Aprobación",
|
||||
"undo": "Deshacer",
|
||||
"skip": "Saltar",
|
||||
"cancel": "Cancelar",
|
||||
"noPriority": "Sin Prioridad",
|
||||
"subtasks": "Subtareas",
|
||||
"noDescription": "No hay descripción disponible",
|
||||
"timer": {
|
||||
"active": "Temporizador Activo",
|
||||
"paused": "Temporizador Pausado",
|
||||
"reset": "Reiniciar Temporizador",
|
||||
"delete": "Eliminar Sesión"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"close": "Cerrar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "Cargando...",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"warning": "Advertencia",
|
||||
"refresh": "Actualizar",
|
||||
"copy": "Copiar",
|
||||
"copied": "¡Copiado!",
|
||||
"settings": "Configuración",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"back": "Atrás",
|
||||
"backToCalendar": "Volver al Calendario",
|
||||
"logout": "Cerrar Sesión",
|
||||
"version": "Versión",
|
||||
"navigation": {
|
||||
"allTasks": "Todas las Tareas",
|
||||
"archived": "Archivadas",
|
||||
"things": "Cosas",
|
||||
"labels": "Etiquetas",
|
||||
"projects": "Proyectos",
|
||||
"filters": "Filtros",
|
||||
"activities": "Actividades",
|
||||
"points": "Puntos",
|
||||
"settings": "Configuración"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"title": "Configuración",
|
||||
"circleSettings": {
|
||||
"title": "Configuración del círculo",
|
||||
"description": "Tu cuenta se conecta automáticamente a un Círculo cuando creas o te unes a uno. Invita fácilmente a amigos compartiendo el código único del Círculo o el enlace a continuación.",
|
||||
"circleCode": "Código del Círculo",
|
||||
"copyCode": "Copiar Código",
|
||||
"copyLink": "Copiar Enlace",
|
||||
"codeCopied": "¡Código del círculo copiado!",
|
||||
"linkCopied": "¡Enlace copiado!",
|
||||
"joinCircle": "Unirse a un Círculo",
|
||||
"joinCirclePlaceholder": "Ingresa el Código del Círculo",
|
||||
"join": "Unirse",
|
||||
"leave": "Salir del Círculo",
|
||||
"leaveConfirmTitle": "Salir del Círculo",
|
||||
"leaveConfirmMessage": "¿Estás seguro de que quieres salir de este círculo?",
|
||||
"circleMembers": "Miembros del Círculo",
|
||||
"circleMemberRequests": "Solicitudes de Miembros del Círculo",
|
||||
"admin": "Administrador",
|
||||
"member": "Miembro",
|
||||
"pending": "Pendiente",
|
||||
"accept": "Aceptar",
|
||||
"reject": "Rechazar",
|
||||
"makeAdmin": "Hacer Administrador",
|
||||
"makeMember": "Hacer Miembro",
|
||||
"remove": "Eliminar",
|
||||
"webhookURL": "URL del Webhook",
|
||||
"webhookDescription": "Ingresa una URL de webhook para recibir notificaciones de eventos del círculo",
|
||||
"webhookPlaceholder": "https://tu-url-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Configuración de la Cuenta",
|
||||
"subscription": "Suscripción",
|
||||
"subscriptionStatus": "Plan Actual",
|
||||
"free": "Gratis",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Actualizar",
|
||||
"cancel": "Cancelar",
|
||||
"changePassword": "Cambiar Contraseña",
|
||||
"password": "Contraseña",
|
||||
"dangerZone": "Zona de Peligro",
|
||||
"dangerZoneDescription": "Una vez que elimines tu cuenta, no hay vuelta atrás. Por favor, está seguro.",
|
||||
"deleteAccount": "Eliminar Cuenta"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localización",
|
||||
"description": "Personaliza el idioma, formato de fecha y preferencias regionales para tu cuenta.",
|
||||
"language": "Idioma",
|
||||
"languageDescription": "Selecciona tu idioma preferido",
|
||||
"dateFormat": "Formato de Fecha",
|
||||
"dateFormatDescription": "Elige cómo se deben mostrar las fechas en toda la aplicación",
|
||||
"timeFormat": "Formato de Hora",
|
||||
"timeFormatDescription": "Selecciona formato de 12 o 24 horas",
|
||||
"12hour": "12 horas (AM/PM)",
|
||||
"24hour": "24 horas",
|
||||
"firstDayOfWeek": "Primer Día de la Semana",
|
||||
"firstDayOfWeekDescription": "Selecciona qué día comienza tu semana",
|
||||
"sunday": "Domingo",
|
||||
"monday": "Lunes",
|
||||
"saturday": "Sábado",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/AAAA (EE.UU.)",
|
||||
"dmy": "DD/MM/AAAA (Europa)",
|
||||
"ymd": "AAAA-MM-DD (ISO)",
|
||||
"long": "Formato largo (ej., 1 de enero de 2024)",
|
||||
"short": "Formato corto (ej., 1 ene 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalización del Panel Lateral",
|
||||
"description": "Personaliza el diseño y la visibilidad de las tarjetas en el panel lateral. Esta sección solo está disponible en dispositivos de pantalla grande como tabletas y computadoras de escritorio."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferencias de tema",
|
||||
"description": "Elige cómo se ve el sitio para ti. Selecciona un solo tema o sincronízalo con tu sistema y cambia automáticamente entre temas de día y noche.",
|
||||
"themeMode": "Modo de tema",
|
||||
"light": "Claro",
|
||||
"dark": "Oscuro",
|
||||
"system": "Sistema"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Configuración guardada con éxito",
|
||||
"settingsSaveFailed": "Error al guardar la configuración",
|
||||
"invalidWebhook": "URL de webhook no válida"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Configuración del Perfil",
|
||||
"description": "Actualiza tu nombre para mostrar y foto de perfil.",
|
||||
"photoUpdated": "Foto Actualizada",
|
||||
"photoUpdatedMessage": "¡Tu foto de perfil ha sido actualizada con éxito!",
|
||||
"uploadFailed": "Error al Subir",
|
||||
"uploadFailedMessage": "Error al subir tu foto. Por favor, inténtalo de nuevo.",
|
||||
"profileUpdated": "Perfil Actualizado",
|
||||
"profileUpdatedMessage": "¡Tu información de perfil ha sido guardada con éxito!",
|
||||
"updateFailed": "Error al Actualizar",
|
||||
"updateFailedMessage": "No se pudo actualizar tu perfil. Por favor, verifica tu conexión e inténtalo de nuevo.",
|
||||
"changePhoto": "Cambiar Foto",
|
||||
"displayName": "Nombre para Mostrar",
|
||||
"displayNamePlaceholder": "Ingresa tu nombre para mostrar",
|
||||
"timezone": "Zona Horaria",
|
||||
"timezonePlaceholder": "Selecciona tu zona horaria",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Configuración",
|
||||
"subtitle": "Personaliza tu experiencia y administra las preferencias de tu cuenta",
|
||||
"upgrade": {
|
||||
"title": "Actualizar a Plus",
|
||||
"description": "Desbloquea funciones potentes para mejorar tu productividad",
|
||||
"button": "Actualizar Ahora",
|
||||
"features": {
|
||||
"richText": "Descripciones en texto enriquecido",
|
||||
"notifications": "Notificaciones de tareas",
|
||||
"apiIntegrations": "Integraciones API",
|
||||
"advancedAutomation": "Automatización avanzada"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Configuración del Perfil",
|
||||
"description": "Actualiza tu información de perfil, foto, nombre para mostrar y preferencias de zona horaria."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Configuración del Círculo",
|
||||
"description": "Administra tu círculo, invita miembros y gestiona solicitudes de unión."
|
||||
},
|
||||
"account": {
|
||||
"title": "Configuración de la Cuenta",
|
||||
"description": "Administra tu suscripción, cambia la contraseña y opciones de eliminación de cuenta."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Cuentas Administradas",
|
||||
"description": "Crea y administra subcuentas para iniciar sesión y completar tareas asignadas."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notificaciones",
|
||||
"description": "Configura notificaciones push, alertas por correo electrónico y destinos de notificación para tareas."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Autenticación Multifactor",
|
||||
"description": "Agrega una capa adicional de seguridad con MFA usando aplicaciones de autenticación."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "Tokens API",
|
||||
"description": "Genera y administra tokens de acceso para integraciones de terceros y acceso a la API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Configuración de Almacenamiento",
|
||||
"description": "Respalda y restaura tus datos, administra el almacenamiento local y las preferencias de sincronización."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personalización del Panel Lateral",
|
||||
"description": "Personaliza el diseño y la visibilidad de las tarjetas en la interfaz del panel lateral."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Preferencias de Tema",
|
||||
"description": "Elige tu tema preferido y configura los ajustes de modo oscuro/claro."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localización",
|
||||
"description": "Personaliza el idioma, formato de fecha, formato de hora y preferencias regionales."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Configuración Avanzada",
|
||||
"description": "Configura webhooks, actualizaciones en tiempo real y otras funciones avanzadas para mejorar la productividad."
|
||||
},
|
||||
"developer": {
|
||||
"title": "Configuración de Desarrollador",
|
||||
"description": "Ver información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,51 +29,5 @@
|
||||
"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,10 +168,6 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"title": "Tâches",
|
||||
"myChores": "Mes Tâches",
|
||||
"allChores": "Toutes les Tâches",
|
||||
"addChore": "Ajouter une Tâche",
|
||||
"editChore": "Modifier la Tâche",
|
||||
"deleteChore": "Supprimer la Tâche",
|
||||
"completeChore": "Terminer la Tâche",
|
||||
"dueDate": "Date d'Échéance",
|
||||
"assignedTo": "Assigné à",
|
||||
"priority": "Priorité",
|
||||
"status": "Statut",
|
||||
"description": "Description",
|
||||
"choreView": {
|
||||
"assignment": "Attribution",
|
||||
"assigned": "Assigné",
|
||||
"last": "Dernier",
|
||||
"schedule": "Calendrier",
|
||||
"due": "Échéance",
|
||||
"statistics": "Statistiques",
|
||||
"completed": "Terminé",
|
||||
"times": "fois",
|
||||
"details": "Détails",
|
||||
"createdBy": "Créé par",
|
||||
"na": "N/A",
|
||||
"taskCompleted": "Tâche Terminée",
|
||||
"taskCompletedMessage": "Votre tâche a été marquée comme terminée",
|
||||
"taskCompletionUndone": "La complétion de la tâche a été annulée.",
|
||||
"taskSkipUndone": "Le saut de tâche a été annulé.",
|
||||
"undoSuccessful": "Annulation Réussie",
|
||||
"undoFailed": "Annulation Échouée",
|
||||
"undoFailedMessage": "Impossible d'annuler l'action. Veuillez réessayer.",
|
||||
"resetTimer": "Réinitialiser le Minuteur",
|
||||
"resetTimerConfirmation": "Êtes-vous sûr de vouloir réinitialiser le minuteur ? Cela effacera tous les enregistrements de temps depuis le début de la tâche.",
|
||||
"clearAllTimeRecords": "Effacer Tous les Enregistrements de Temps",
|
||||
"clearAllTimeConfirmation": "Cela supprimera définitivement tous les minuteurs de cette tâche et la remettra à l'état \"non démarrée\".",
|
||||
"descriptionTitle": "Description",
|
||||
"description": "Description :",
|
||||
"previousNote": "Note Précédente",
|
||||
"previousNoteLabel": "Note précédente :",
|
||||
"subtasksLabel": "Sous-tâches :",
|
||||
"taskActions": "Actions de Tâche",
|
||||
"addNote": "Ajouter une note",
|
||||
"additionalNotes": "Notes Supplémentaires :",
|
||||
"notePlaceholder": "Ajouter une note sur la complétion...",
|
||||
"setCustomCompletionTime": "Définir une heure de complétion personnalisée",
|
||||
"skipTask": "Passer la Tâche",
|
||||
"skipTaskConfirmation": "Êtes-vous sûr de vouloir passer cette tâche ?",
|
||||
"markComplete": "Marquer comme Terminée",
|
||||
"markAsDone": "Marquer comme fait",
|
||||
"edit": "Modifier",
|
||||
"archive": "Archiver",
|
||||
"unarchive": "Désarchiver",
|
||||
"viewHistory": "Voir l'Historique",
|
||||
"history": "Historique",
|
||||
"startTimer": "Démarrer le Minuteur",
|
||||
"start": "Démarrer",
|
||||
"pauseTimer": "Mettre en Pause le Minuteur",
|
||||
"approve": "Approuver",
|
||||
"reject": "Rejeter",
|
||||
"pendingApproval": "En Attente d'Approbation",
|
||||
"undo": "Annuler",
|
||||
"skip": "Passer",
|
||||
"cancel": "Annuler",
|
||||
"noPriority": "Aucune Priorité",
|
||||
"subtasks": "Sous-tâches",
|
||||
"noDescription": "Aucune description disponible",
|
||||
"timer": {
|
||||
"active": "Minuteur Actif",
|
||||
"paused": "Minuteur en Pause",
|
||||
"reset": "Réinitialiser le Minuteur",
|
||||
"delete": "Supprimer la Session"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler",
|
||||
"delete": "Supprimer",
|
||||
"edit": "Modifier",
|
||||
"close": "Fermer",
|
||||
"confirm": "Confirmer",
|
||||
"loading": "Chargement...",
|
||||
"error": "Erreur",
|
||||
"success": "Succès",
|
||||
"warning": "Avertissement",
|
||||
"refresh": "Actualiser",
|
||||
"copy": "Copier",
|
||||
"copied": "Copié !",
|
||||
"settings": "Paramètres",
|
||||
"yes": "Oui",
|
||||
"no": "Non",
|
||||
"back": "Retour",
|
||||
"backToCalendar": "Retour au Calendrier",
|
||||
"logout": "Déconnexion",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"allTasks": "Toutes les Tâches",
|
||||
"archived": "Archivées",
|
||||
"things": "Objets",
|
||||
"labels": "Étiquettes",
|
||||
"projects": "Projets",
|
||||
"filters": "Filtres",
|
||||
"activities": "Activités",
|
||||
"points": "Points",
|
||||
"settings": "Paramètres"
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
{
|
||||
"title": "Paramètres",
|
||||
"circleSettings": {
|
||||
"title": "Paramètres du cercle",
|
||||
"description": "Votre compte est automatiquement connecté à un Cercle lorsque vous en créez un ou en rejoignez un. Invitez facilement des amis en partageant le code unique du Cercle ou le lien ci-dessous.",
|
||||
"circleCode": "Code du Cercle",
|
||||
"copyCode": "Copier le Code",
|
||||
"copyLink": "Copier le Lien",
|
||||
"codeCopied": "Code du cercle copié !",
|
||||
"linkCopied": "Lien copié !",
|
||||
"joinCircle": "Rejoindre un Cercle",
|
||||
"joinCirclePlaceholder": "Entrer le Code du Cercle",
|
||||
"join": "Rejoindre",
|
||||
"leave": "Quitter le Cercle",
|
||||
"leaveConfirmTitle": "Quitter le Cercle",
|
||||
"leaveConfirmMessage": "Êtes-vous sûr de vouloir quitter ce cercle ?",
|
||||
"circleMembers": "Membres du Cercle",
|
||||
"circleMemberRequests": "Demandes de Membres du Cercle",
|
||||
"admin": "Administrateur",
|
||||
"member": "Membre",
|
||||
"pending": "En attente",
|
||||
"accept": "Accepter",
|
||||
"reject": "Rejeter",
|
||||
"makeAdmin": "Nommer Administrateur",
|
||||
"makeMember": "Nommer Membre",
|
||||
"remove": "Supprimer",
|
||||
"webhookURL": "URL du Webhook",
|
||||
"webhookDescription": "Entrez une URL de webhook pour recevoir des notifications pour les événements du cercle",
|
||||
"webhookPlaceholder": "https://votre-url-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Paramètres du Compte",
|
||||
"subscription": "Abonnement",
|
||||
"subscriptionStatus": "Plan Actuel",
|
||||
"free": "Gratuit",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Mettre à Niveau",
|
||||
"cancel": "Annuler",
|
||||
"changePassword": "Changer le Mot de Passe",
|
||||
"password": "Mot de passe",
|
||||
"dangerZone": "Zone Dangereuse",
|
||||
"dangerZoneDescription": "Une fois votre compte supprimé, il n'y a pas de retour en arrière. Veuillez être certain.",
|
||||
"deleteAccount": "Supprimer le Compte"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localisation",
|
||||
"description": "Personnalisez la langue, le format de date et les préférences régionales pour votre compte.",
|
||||
"language": "Langue",
|
||||
"languageDescription": "Sélectionnez votre langue préférée",
|
||||
"dateFormat": "Format de Date",
|
||||
"dateFormatDescription": "Choisissez comment les dates doivent être affichées dans l'application",
|
||||
"timeFormat": "Format de l'Heure",
|
||||
"timeFormatDescription": "Sélectionnez le format 12 heures ou 24 heures",
|
||||
"12hour": "12 heures (AM/PM)",
|
||||
"24hour": "24 heures",
|
||||
"firstDayOfWeek": "Premier Jour de la Semaine",
|
||||
"firstDayOfWeekDescription": "Sélectionnez quel jour commence votre semaine",
|
||||
"sunday": "Dimanche",
|
||||
"monday": "Lundi",
|
||||
"saturday": "Samedi",
|
||||
"formats": {
|
||||
"mdy": "MM/JJ/AAAA (États-Unis)",
|
||||
"dmy": "JJ/MM/AAAA (Europe)",
|
||||
"ymd": "AAAA-MM-JJ (ISO)",
|
||||
"long": "Format long (ex., 1 janvier 2024)",
|
||||
"short": "Format court (ex., 1 janv. 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personnalisation du Panneau Latéral",
|
||||
"description": "Personnalisez la disposition et la visibilité des cartes dans le panneau latéral. Cette section n'est disponible que sur les grands écrans comme les tablettes et les ordinateurs de bureau."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Préférences de thème",
|
||||
"description": "Choisissez comment le site vous apparaît. Sélectionnez un thème unique ou synchronisez avec votre système pour basculer automatiquement entre les thèmes jour et nuit.",
|
||||
"themeMode": "Mode de thème",
|
||||
"light": "Clair",
|
||||
"dark": "Sombre",
|
||||
"system": "Système"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Paramètres enregistrés avec succès",
|
||||
"settingsSaveFailed": "Échec de l'enregistrement des paramètres",
|
||||
"invalidWebhook": "URL de webhook invalide"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Paramètres du Profil",
|
||||
"description": "Mettez à jour votre nom d'affichage et votre photo de profil.",
|
||||
"photoUpdated": "Photo Mise à Jour",
|
||||
"photoUpdatedMessage": "Votre photo de profil a été mise à jour avec succès !",
|
||||
"uploadFailed": "Échec du Téléchargement",
|
||||
"uploadFailedMessage": "Échec du téléchargement de votre photo. Veuillez réessayer.",
|
||||
"profileUpdated": "Profil Mis à Jour",
|
||||
"profileUpdatedMessage": "Vos informations de profil ont été enregistrées avec succès !",
|
||||
"updateFailed": "Échec de la Mise à Jour",
|
||||
"updateFailedMessage": "Impossible de mettre à jour votre profil. Veuillez vérifier votre connexion et réessayer.",
|
||||
"changePhoto": "Changer la Photo",
|
||||
"displayName": "Nom d'Affichage",
|
||||
"displayNamePlaceholder": "Entrez votre nom d'affichage",
|
||||
"timezone": "Fuseau Horaire",
|
||||
"timezonePlaceholder": "Sélectionnez votre fuseau horaire",
|
||||
"save": "Enregistrer",
|
||||
"cancel": "Annuler"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Paramètres",
|
||||
"subtitle": "Personnalisez votre expérience et gérez les préférences de votre compte",
|
||||
"upgrade": {
|
||||
"title": "Passer à Plus",
|
||||
"description": "Débloquez des fonctionnalités puissantes pour améliorer votre productivité",
|
||||
"button": "Mettre à Niveau Maintenant",
|
||||
"features": {
|
||||
"richText": "Descriptions en texte enrichi",
|
||||
"notifications": "Notifications de tâches",
|
||||
"apiIntegrations": "Intégrations API",
|
||||
"advancedAutomation": "Automatisation avancée"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Paramètres du Profil",
|
||||
"description": "Mettez à jour vos informations de profil, photo, nom d'affichage et préférences de fuseau horaire."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Paramètres du Cercle",
|
||||
"description": "Gérez votre cercle, invitez des membres et gérez les demandes d'adhésion."
|
||||
},
|
||||
"account": {
|
||||
"title": "Paramètres du Compte",
|
||||
"description": "Gérez votre abonnement, changez le mot de passe et les options de suppression de compte."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Comptes Gérés",
|
||||
"description": "Créez et gérez des sous-comptes pour vous connecter et accomplir les tâches assignées."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Configurez les notifications push, les alertes par e-mail et les cibles de notification pour les tâches."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Authentification Multi-Facteurs",
|
||||
"description": "Ajoutez une couche de sécurité supplémentaire avec MFA en utilisant des applications d'authentification."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "Jetons API",
|
||||
"description": "Générez et gérez des jetons d'accès pour les intégrations tierces et l'accès à l'API."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Paramètres de Stockage",
|
||||
"description": "Sauvegardez et restaurez vos données, gérez le stockage local et les préférences de synchronisation."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Personnalisation du Panneau Latéral",
|
||||
"description": "Personnalisez la disposition et la visibilité des cartes dans l'interface du panneau latéral."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Préférences de Thème",
|
||||
"description": "Choisissez votre thème préféré et configurez les paramètres de mode sombre/clair."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localisation",
|
||||
"description": "Personnalisez la langue, le format de date, le format de l'heure et les préférences régionales."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Paramètres Avancés",
|
||||
"description": "Configurez les webhooks, les mises à jour en temps réel et d'autres fonctionnalités avancées pour améliorer la productivité."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,51 +29,5 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"makeMember": "Nommer Membre",
|
||||
"remove": "Supprimer",
|
||||
"webhookURL": "URL du Webhook",
|
||||
"webhookDescription": "Entrez une URL de Webhook pour recevoir des notifications pour les événements du cercle",
|
||||
"webhookDescription": "Entrez une URL de webhook pour recevoir des notifications pour les événements du cercle",
|
||||
"webhookPlaceholder": "https://votre-url-webhook.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
@@ -168,10 +168,6 @@
|
||||
"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,51 +29,5 @@
|
||||
"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,10 +168,6 @@
|
||||
"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,51 +29,5 @@
|
||||
"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,10 +168,6 @@
|
||||
"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,51 +29,5 @@
|
||||
"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,10 +168,6 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{
|
||||
"title": "Chores",
|
||||
"myChores": "My Chores",
|
||||
"allChores": "All Chores",
|
||||
"addChore": "Add Chore",
|
||||
"editChore": "Edit Chore",
|
||||
"deleteChore": "Delete Chore",
|
||||
"completeChore": "Complete Task",
|
||||
"dueDate": "Due Date",
|
||||
"assignedTo": "Assigned To",
|
||||
"priority": "Priority",
|
||||
"status": "Status",
|
||||
"description": "Description",
|
||||
"choreView": {
|
||||
"assignment": "Assignment",
|
||||
"assigned": "Assigned",
|
||||
"last": "Last",
|
||||
"schedule": "Schedule",
|
||||
"due": "Due",
|
||||
"statistics": "Statistics",
|
||||
"completed": "Completed",
|
||||
"times": "times",
|
||||
"details": "Details",
|
||||
"createdBy": "Created By",
|
||||
"na": "N/A",
|
||||
"taskCompleted": "Task Completed",
|
||||
"taskCompletedMessage": "Your task has been marked as complete",
|
||||
"taskCompletionUndone": "Task completion has been undone.",
|
||||
"taskSkipUndone": "Task skip has been undone.",
|
||||
"undoSuccessful": "Undo Successful",
|
||||
"undoFailed": "Undo Failed",
|
||||
"undoFailedMessage": "Unable to undo the action. Please try again.",
|
||||
"resetTimer": "Reset Timer",
|
||||
"resetTimerConfirmation": "Are you sure you want to reset the timer? This will clear all time records since you started the task.",
|
||||
"clearAllTimeRecords": "Clear All Time Records",
|
||||
"clearAllTimeConfirmation": "This will permanently delete all timers for this task and set it back to \"not started\".",
|
||||
"descriptionTitle": "Description",
|
||||
"description": "Description :",
|
||||
"previousNote": "Previous Note",
|
||||
"previousNoteLabel": "Previous note:",
|
||||
"subtasksLabel": "Subtasks :",
|
||||
"taskActions": "Task Actions",
|
||||
"addNote": "Add a note",
|
||||
"additionalNotes": "Additional Notes:",
|
||||
"notePlaceholder": "Add a note about the completion...",
|
||||
"setCustomCompletionTime": "Set custom completion time",
|
||||
"skipTask": "Skip Task",
|
||||
"skipTaskConfirmation": "Are you sure you want to skip this task?",
|
||||
"markComplete": "Mark Complete",
|
||||
"markAsDone": "Mark as done",
|
||||
"edit": "Edit",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive",
|
||||
"viewHistory": "View History",
|
||||
"history": "History",
|
||||
"startTimer": "Start Timer",
|
||||
"start": "Start",
|
||||
"pauseTimer": "Pause Timer",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"pendingApproval": "Pending Approval",
|
||||
"undo": "Undo",
|
||||
"skip": "Skip",
|
||||
"cancel": "Cancel",
|
||||
"noPriority": "No Priority",
|
||||
"subtasks": "Subtasks",
|
||||
"noDescription": "No description available",
|
||||
"timer": {
|
||||
"active": "Timer Active",
|
||||
"paused": "Timer Paused",
|
||||
"reset": "Reset Timer",
|
||||
"delete": "Delete Session"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"warning": "Warning",
|
||||
"refresh": "Refresh",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied!",
|
||||
"settings": "Settings",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"back": "Back",
|
||||
"backToCalendar": "Back to Calendar",
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
"labels": "Labels",
|
||||
"projects": "Projects",
|
||||
"filters": "Filters",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
{
|
||||
"title": "Settings",
|
||||
"circleSettings": {
|
||||
"title": "Circle settings",
|
||||
"description": "Your account is automatically connected to a Circle when you create or join one. Easily invite friends by sharing the unique Circle code or link below. You'll receive a notification below when someone requests to join your Circle. If you'd like to leave, simply hit the 'Leave Circle' button.",
|
||||
"circleCode": "Circle Code",
|
||||
"copyCode": "Copy Code",
|
||||
"copyLink": "Copy Link",
|
||||
"codeCopied": "Circle code copied!",
|
||||
"linkCopied": "Circle link copied!",
|
||||
"joinCircle": "Join a Circle",
|
||||
"joinCirclePlaceholder": "Enter Circle Code",
|
||||
"join": "Join",
|
||||
"leave": "Leave Circle",
|
||||
"leaveConfirmTitle": "Leave Circle",
|
||||
"leaveConfirmMessage": "Are you sure you want to leave this circle?",
|
||||
"circleMembers": "Circle Members",
|
||||
"circleMemberRequests": "Circle Member Requests",
|
||||
"admin": "Admin",
|
||||
"member": "Member",
|
||||
"pending": "Pending",
|
||||
"accept": "Accept",
|
||||
"reject": "Reject",
|
||||
"makeAdmin": "Make Admin",
|
||||
"makeMember": "Make Member",
|
||||
"remove": "Remove",
|
||||
"webhookURL": "Webhook URL",
|
||||
"webhookDescription": "Enter a webhook URL to receive notifications for circle events",
|
||||
"webhookPlaceholder": "https://your-webhook-url.com"
|
||||
},
|
||||
"accountSettings": {
|
||||
"title": "Account Settings",
|
||||
"subscription": "Subscription",
|
||||
"subscriptionStatus": "Current Plan",
|
||||
"free": "Free",
|
||||
"plus": "Plus",
|
||||
"upgrade": "Upgrade",
|
||||
"cancel": "Cancel",
|
||||
"changePassword": "Change Password",
|
||||
"password": "Password",
|
||||
"dangerZone": "Danger Zone",
|
||||
"dangerZoneDescription": "Once you delete your account, there is no going back. Please be certain.",
|
||||
"deleteAccount": "Delete Account"
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localization",
|
||||
"description": "Customize language, date format, and regional preferences for your account.",
|
||||
"language": "Language",
|
||||
"languageDescription": "Select your preferred language",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatDescription": "Choose how dates should be displayed throughout the application",
|
||||
"timeFormat": "Time Format",
|
||||
"timeFormatDescription": "Select 12-hour or 24-hour time format",
|
||||
"12hour": "12-hour (AM/PM)",
|
||||
"24hour": "24-hour",
|
||||
"firstDayOfWeek": "First Day of Week",
|
||||
"firstDayOfWeekDescription": "Select which day starts your week",
|
||||
"sunday": "Sunday",
|
||||
"monday": "Monday",
|
||||
"saturday": "Saturday",
|
||||
"formats": {
|
||||
"mdy": "MM/DD/YYYY (US)",
|
||||
"dmy": "DD/MM/YYYY (Europe)",
|
||||
"ymd": "YYYY-MM-DD (ISO)",
|
||||
"long": "Long format (e.g., January 1, 2024)",
|
||||
"short": "Short format (e.g., Jan 1, 2024)"
|
||||
}
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Sidepanel Customization",
|
||||
"description": "Customize the layout and visibility of cards in the sidepanel. This section is only available on large screen devices such as tablets and desktops."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme preferences",
|
||||
"description": "Choose how the site looks to you. Select a single theme, or sync with your system and automatically switch between day and night themes.",
|
||||
"themeMode": "Theme mode",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"notifications": {
|
||||
"settingsSaved": "Settings saved successfully",
|
||||
"settingsSaveFailed": "Failed to save settings",
|
||||
"invalidWebhook": "Invalid webhook URL"
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
"description": "Update your display name and profile photo.",
|
||||
"photoUpdated": "Photo Updated",
|
||||
"photoUpdatedMessage": "Your profile photo has been updated successfully!",
|
||||
"uploadFailed": "Upload Failed",
|
||||
"uploadFailedMessage": "Failed to upload your photo. Please try again.",
|
||||
"profileUpdated": "Profile Updated",
|
||||
"profileUpdatedMessage": "Your profile information has been saved successfully!",
|
||||
"updateFailed": "Update Failed",
|
||||
"updateFailedMessage": "Unable to update your profile. Please check your connection and try again.",
|
||||
"changePhoto": "Change Photo",
|
||||
"displayName": "Display Name",
|
||||
"displayNamePlaceholder": "Enter your display name",
|
||||
"timezone": "Timezone",
|
||||
"timezonePlaceholder": "Select your timezone",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"overview": {
|
||||
"title": "Settings",
|
||||
"subtitle": "Customize your experience and manage your account preferences",
|
||||
"upgrade": {
|
||||
"title": "Upgrade to Plus",
|
||||
"description": "Unlock powerful features to enhance your productivity",
|
||||
"button": "Upgrade Now",
|
||||
"features": {
|
||||
"richText": "Rich text descriptions",
|
||||
"notifications": "Task notifications",
|
||||
"apiIntegrations": "API integrations",
|
||||
"advancedAutomation": "Advanced automation"
|
||||
}
|
||||
},
|
||||
"sections": {
|
||||
"profile": {
|
||||
"title": "Profile Settings",
|
||||
"description": "Update your profile information, photo, display name, and timezone preferences."
|
||||
},
|
||||
"circle": {
|
||||
"title": "Circle Settings",
|
||||
"description": "Manage your circle, invite members, and handle join requests."
|
||||
},
|
||||
"account": {
|
||||
"title": "Account Settings",
|
||||
"description": "Manage your subscription, change password, and account deletion options."
|
||||
},
|
||||
"subaccounts": {
|
||||
"title": "Managed Accounts",
|
||||
"description": "Create and manage sub accounts to log in and complete assigned tasks."
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"description": "Configure push notifications, email alerts, and notification targets for tasks."
|
||||
},
|
||||
"mfa": {
|
||||
"title": "Multi-Factor Authentication",
|
||||
"description": "Add an extra layer of security with MFA using authenticator apps."
|
||||
},
|
||||
"apitokens": {
|
||||
"title": "API Tokens",
|
||||
"description": "Generate and manage access tokens for third-party integrations and API access."
|
||||
},
|
||||
"storage": {
|
||||
"title": "Storage Settings",
|
||||
"description": "Backup and restore your data, manage local storage and sync preferences."
|
||||
},
|
||||
"sidepanel": {
|
||||
"title": "Sidepanel Customization",
|
||||
"description": "Customize the layout and visibility of cards in the sidepanel interface."
|
||||
},
|
||||
"theme": {
|
||||
"title": "Theme Preferences",
|
||||
"description": "Choose your preferred theme and configure dark/light mode settings."
|
||||
},
|
||||
"localization": {
|
||||
"title": "Localization",
|
||||
"description": "Customize language, date format, time format, and regional preferences."
|
||||
},
|
||||
"advanced": {
|
||||
"title": "Advanced Settings",
|
||||
"description": "Configure webhooks, real-time updates, and other advanced features for enhanced productivity."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,11 @@ PLATFORM="both"
|
||||
BUMP="patch"
|
||||
SKIP_BUMP=false
|
||||
UPLOAD=false
|
||||
UPLOAD_ONLY=false
|
||||
ANDROID_TRACK="internal"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [--android|--ios] [--bump patch|minor|major] [--skip-bump] [--upload|--upload-only] [--track TRACK]
|
||||
Usage: $(basename "$0") [--android|--ios] [--bump patch|minor|major] [--skip-bump] [--upload] [--track TRACK]
|
||||
|
||||
Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
|
||||
|
||||
@@ -22,7 +21,6 @@ Builds signed release artifacts for Android (.aab) and/or iOS (.ipa).
|
||||
--bump TYPE Version bump type before building (default: patch)
|
||||
--skip-bump Don't bump the version, build with the current one
|
||||
--upload Also upload: Android to Play Store, iOS to TestFlight
|
||||
--upload-only Skip bump/build entirely, just upload the artifacts already built
|
||||
--track TRACK Play Store track for --upload (default: internal)
|
||||
-h, --help Show this help
|
||||
|
||||
@@ -40,7 +38,6 @@ while [[ $# -gt 0 ]]; do
|
||||
--bump) BUMP="$2"; shift 2 ;;
|
||||
--skip-bump) SKIP_BUMP=true; shift ;;
|
||||
--upload) UPLOAD=true; shift ;;
|
||||
--upload-only) UPLOAD=true; UPLOAD_ONLY=true; shift ;;
|
||||
--track) ANDROID_TRACK="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage; exit 1 ;;
|
||||
@@ -54,19 +51,17 @@ if [ -f "$REPO_ROOT/.env.production" ]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
if [ "$UPLOAD_ONLY" = false ]; then
|
||||
if [ "$SKIP_BUMP" = false ]; then
|
||||
echo "→ Bumping version ($BUMP)"
|
||||
node bump-version.js "$BUMP"
|
||||
fi
|
||||
|
||||
echo "→ Building web assets"
|
||||
npm run build
|
||||
|
||||
echo "→ Syncing Capacitor"
|
||||
npx cap sync
|
||||
if [ "$SKIP_BUMP" = false ]; then
|
||||
echo "→ Bumping version ($BUMP)"
|
||||
node bump-version.js "$BUMP"
|
||||
fi
|
||||
|
||||
echo "→ Building web assets"
|
||||
npm run build
|
||||
|
||||
echo "→ Syncing Capacitor"
|
||||
npx cap sync
|
||||
|
||||
if [ -f Gemfile ] && command -v bundle >/dev/null 2>&1; then
|
||||
RUN="bundle exec fastlane"
|
||||
else
|
||||
@@ -74,10 +69,8 @@ else
|
||||
fi
|
||||
|
||||
if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
|
||||
if [ "$UPLOAD_ONLY" = false ]; then
|
||||
echo "→ Building signed Android release (.aab)"
|
||||
$RUN android release
|
||||
fi
|
||||
echo "→ Building signed Android release (.aab)"
|
||||
$RUN android release
|
||||
|
||||
if [ "$UPLOAD" = true ]; then
|
||||
echo "→ Uploading Android release to Play Store ($ANDROID_TRACK track)"
|
||||
@@ -86,10 +79,8 @@ if [ "$PLATFORM" = "android" ] || [ "$PLATFORM" = "both" ]; then
|
||||
fi
|
||||
|
||||
if [ "$PLATFORM" = "ios" ] || [ "$PLATFORM" = "both" ]; then
|
||||
if [ "$UPLOAD_ONLY" = false ]; then
|
||||
echo "→ Building signed iOS release (.ipa)"
|
||||
$RUN ios release
|
||||
fi
|
||||
echo "→ Building signed iOS release (.ipa)"
|
||||
$RUN ios release
|
||||
|
||||
if [ "$UPLOAD" = true ]; then
|
||||
echo "→ Uploading iOS release to TestFlight"
|
||||
|
||||
39
src/App.jsx
39
src/App.jsx
@@ -1,24 +1,21 @@
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, 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 { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { GlobalSearchProvider } from './search/GlobalSearchContext'
|
||||
import { recordRoute } from './service/DiagnosticsSession'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -34,24 +31,14 @@ 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.
|
||||
const isRedirectingToOnboarding = useOnboardingGate()
|
||||
|
||||
// Initialize status bar with theme-aware configuration
|
||||
useStatusBar()
|
||||
|
||||
const {
|
||||
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
offlineReady: [offlineReady, setOfflineReady],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({
|
||||
onRegistered(r) {
|
||||
@@ -96,8 +83,6 @@ const AppContent = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [needRefresh])
|
||||
|
||||
if (isRedirectingToOnboarding) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImpersonateUserProvider>
|
||||
@@ -111,7 +96,7 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const resource = useResource()
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -148,9 +133,7 @@ function App() {
|
||||
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<GlobalSearchProvider>
|
||||
<AppContent />
|
||||
</GlobalSearchProvider>
|
||||
<AppContent />
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import App from './App.jsx'
|
||||
import Contexts from './contexts/Contexts.jsx'
|
||||
|
||||
const Application = () => (
|
||||
<Contexts>
|
||||
<App />
|
||||
</Contexts>
|
||||
)
|
||||
|
||||
export default Application
|
||||
@@ -6,11 +6,8 @@ import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { PushNotifications } from '@capacitor/push-notifications'
|
||||
import { focusManager } from '@tanstack/react-query'
|
||||
|
||||
import { RegisterDeviceToken } from './utils/Fetcher'
|
||||
import { beginOAuthExchange } from './utils/OAuthExchangeState'
|
||||
import { hasSeenOnboarding } from './utils/Onboarding'
|
||||
import { setPendingInvite } from './utils/PendingInvite'
|
||||
|
||||
// React Router navigate(), injected by <App /> once the router is mounted.
|
||||
// Using client-side navigation (instead of window.location.href) avoids a full
|
||||
@@ -66,27 +63,7 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
|
||||
|
||||
const handleUrlOpen = (url, isColdStart = false) => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
let parsedUrl
|
||||
try {
|
||||
parsedUrl = new URL(url)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const isCircleInvite =
|
||||
(parsedUrl.protocol === 'donetick:' &&
|
||||
parsedUrl.host === 'circle' &&
|
||||
parsedUrl.pathname === '/join') ||
|
||||
(parsedUrl.protocol === 'https:' && parsedUrl.pathname === '/circle/join')
|
||||
|
||||
if (isCircleInvite) {
|
||||
setPendingInvite(parsedUrl.searchParams.get('code'))
|
||||
const needsOnboarding =
|
||||
!hasSeenOnboarding() && !localStorage.getItem('token')
|
||||
routerNavigate(
|
||||
needsOnboarding ? '/onboarding' : `/circle/join${parsedUrl.search}`,
|
||||
)
|
||||
} else if (url.startsWith('donetick://chores/add')) {
|
||||
if (url.startsWith('donetick://chores/add')) {
|
||||
// Widget "+" / quick-capture buttons: land on the chore list with the
|
||||
// quick-add modal open (MyChores watches for the add_task param and
|
||||
// consumes it). ?mode=scan|voice opens straight into that capture panel.
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
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
|
||||
1192
src/assets/logo.svg
1192
src/assets/logo.svg
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 63 KiB |
@@ -63,10 +63,6 @@ function getInternalValue(timing, displayValue) {
|
||||
|
||||
const NotificationTemplate = ({
|
||||
maxNotifications = 5,
|
||||
// ChoreEdit gates this editor behind its own on/off switch, so the last row
|
||||
// must stay put — `notification: true` with no templates is not a valid task.
|
||||
// Consumers that own an empty state themselves pass 0.
|
||||
minNotifications = 1,
|
||||
onChange,
|
||||
value,
|
||||
showTimeline = true,
|
||||
@@ -263,8 +259,7 @@ const NotificationTemplate = ({
|
||||
return next
|
||||
})
|
||||
|
||||
// No direct onChange here: consumers expect { notifications }, and the
|
||||
// effect below already emits that shape once the state settles.
|
||||
onChange && onChange(updated)
|
||||
setShowSaveDefault(true)
|
||||
}
|
||||
|
||||
@@ -312,6 +307,9 @@ const NotificationTemplate = ({
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography level={'body-md'} sx={{ mb: 1 }}>
|
||||
Notification Timeline
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -644,7 +642,7 @@ const NotificationTemplate = ({
|
||||
</Select>
|
||||
<IconButton
|
||||
onClick={() => removeNotification(idx)}
|
||||
disabled={notifications.length <= minNotifications}
|
||||
disabled={notifications.length === 1}
|
||||
color={'danger'}
|
||||
size={'sm'}
|
||||
variant={'soft'}
|
||||
|
||||
@@ -85,9 +85,6 @@ const PageTransition = ({ children }) => {
|
||||
location.pathname.includes('/login') ||
|
||||
location.pathname.includes('/signup') ||
|
||||
location.pathname.includes('/landing') ||
|
||||
location.pathname.includes('/onboarding') ||
|
||||
location.pathname.includes('/get-started') ||
|
||||
location.pathname.includes('/ready') ||
|
||||
location.pathname.includes('/auth/')
|
||||
|
||||
// Apply transition type as data attribute for CSS
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import PropTypes from 'prop-types'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
/**
|
||||
* The single empty/error surface for the app.
|
||||
*
|
||||
* variant drives the tone, the icon tile color and the a11y role:
|
||||
* - 'empty' nothing exists yet. Teach the feature, offer the way in.
|
||||
* - 'no-results' something exists, the current search/filter hides it.
|
||||
* - 'error' we failed to load. Say what happened, offer a retry.
|
||||
*
|
||||
* Actions are objects instead of nodes so every call site gets the same
|
||||
* button vocabulary (solid primary lead, plain neutral follow).
|
||||
*/
|
||||
|
||||
const TONES = {
|
||||
empty: {
|
||||
tileBg: 'primary.softHoverBg',
|
||||
halo: 'primary.softBg',
|
||||
iconColor: 'primary.softColor',
|
||||
role: 'status',
|
||||
},
|
||||
'no-results': {
|
||||
tileBg: 'neutral.softHoverBg',
|
||||
halo: 'neutral.softBg',
|
||||
iconColor: 'neutral.softColor',
|
||||
role: 'status',
|
||||
},
|
||||
error: {
|
||||
tileBg: 'danger.softHoverBg',
|
||||
halo: 'danger.softBg',
|
||||
iconColor: 'danger.softColor',
|
||||
role: 'alert',
|
||||
},
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: {
|
||||
tile: 48,
|
||||
icon: '1.375rem',
|
||||
halo: 6,
|
||||
py: 5,
|
||||
title: 'title-sm',
|
||||
titleSize: '1rem',
|
||||
},
|
||||
md: {
|
||||
tile: 68,
|
||||
icon: '1.875rem',
|
||||
halo: 10,
|
||||
py: 8,
|
||||
title: 'title-md',
|
||||
titleSize: '1.25rem',
|
||||
},
|
||||
}
|
||||
|
||||
const ActionButton = ({ action, ...buttonProps }) => {
|
||||
const { label, to, onClick, ...rest } = action
|
||||
return (
|
||||
<Button
|
||||
{...buttonProps}
|
||||
{...rest}
|
||||
onClick={onClick}
|
||||
{...(to ? { component: Link, to } : {})}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
ActionButton.propTypes = {
|
||||
action: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
const EmptyState = ({
|
||||
variant = 'empty',
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
primaryAction,
|
||||
secondaryAction,
|
||||
size = 'md',
|
||||
fullHeight = false,
|
||||
sx,
|
||||
...rest
|
||||
}) => {
|
||||
const tone = TONES[variant] || TONES.empty
|
||||
const dimensions = SIZES[size] || SIZES.md
|
||||
const buttonSize = size === 'sm' ? 'sm' : 'md'
|
||||
|
||||
return (
|
||||
<Box
|
||||
role={tone.role}
|
||||
aria-live={variant === 'error' ? 'assertive' : 'polite'}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 0.75,
|
||||
px: 3,
|
||||
py: dimensions.py,
|
||||
...(fullHeight && { minHeight: '55vh' }),
|
||||
animation: 'dt-empty-state-in 200ms cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'@keyframes dt-empty-state-in': {
|
||||
from: { opacity: 0, transform: 'translateY(4px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
...sx,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{icon && (
|
||||
<Box
|
||||
aria-hidden='true'
|
||||
sx={{
|
||||
// Two concentric tints: a wide, pale halo with a deeper medallion
|
||||
// inside it, so the icon reads as an object rather than a chip.
|
||||
width: dimensions.tile + dimensions.halo * 2,
|
||||
height: dimensions.tile + dimensions.halo * 2,
|
||||
mb: 1.5,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
bgcolor: tone.halo,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: dimensions.tile,
|
||||
height: dimensions.tile,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
bgcolor: tone.tileBg,
|
||||
color: tone.iconColor,
|
||||
'& > svg': { fontSize: dimensions.icon },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Typography
|
||||
level={dimensions.title}
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
fontSize: dimensions.titleSize,
|
||||
fontWeight: 'lg',
|
||||
letterSpacing: '-0.01em',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{description && (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
maxWidth: '38ch',
|
||||
lineHeight: 1.55,
|
||||
textWrap: 'pretty',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{(primaryAction || secondaryAction) && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1.5,
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{primaryAction && (
|
||||
<ActionButton
|
||||
action={primaryAction}
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size={buttonSize}
|
||||
sx={{ minWidth: size === 'sm' ? 0 : 148 }}
|
||||
/>
|
||||
)}
|
||||
{secondaryAction && (
|
||||
<ActionButton
|
||||
action={secondaryAction}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size={buttonSize}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
EmptyState.propTypes = {
|
||||
variant: PropTypes.oneOf(['empty', 'no-results', 'error']),
|
||||
icon: PropTypes.node,
|
||||
title: PropTypes.node.isRequired,
|
||||
description: PropTypes.node,
|
||||
primaryAction: PropTypes.shape({
|
||||
label: PropTypes.node.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
to: PropTypes.string,
|
||||
startDecorator: PropTypes.node,
|
||||
}),
|
||||
secondaryAction: PropTypes.shape({
|
||||
label: PropTypes.node.isRequired,
|
||||
onClick: PropTypes.func,
|
||||
to: PropTypes.string,
|
||||
startDecorator: PropTypes.node,
|
||||
}),
|
||||
size: PropTypes.oneOf(['sm', 'md']),
|
||||
fullHeight: PropTypes.bool,
|
||||
sx: PropTypes.object,
|
||||
}
|
||||
|
||||
export default EmptyState
|
||||
@@ -1,22 +1,20 @@
|
||||
import { Add, Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||
import { Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, Typography } from '@mui/joy'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chipSize = 'md',
|
||||
chipSx,
|
||||
chips = [],
|
||||
onOpen,
|
||||
onClearAll,
|
||||
resultCount,
|
||||
totalCount,
|
||||
maxVisible = 2,
|
||||
chipSize = 'md',
|
||||
clearButtonSize = 'sm',
|
||||
clearButtonSx,
|
||||
containerSx,
|
||||
maxVisible = 2,
|
||||
onAdd,
|
||||
onClearAll,
|
||||
onOpen,
|
||||
chipSx,
|
||||
overflowChipSx,
|
||||
resultCount,
|
||||
resultSx,
|
||||
showAddChip = false,
|
||||
totalCount,
|
||||
}) => {
|
||||
if (!chips.length) {
|
||||
return null
|
||||
@@ -39,31 +37,20 @@ const ActiveFilterChips = ({
|
||||
...containerSx,
|
||||
}}
|
||||
>
|
||||
{visible.map(({ color = 'primary', key, label, onClear }) => (
|
||||
{visible.map(({ key, label, onClear, color = 'primary' }) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
color={color}
|
||||
endDecorator={
|
||||
// ChipDelete rather than a bare icon: Joy's chip end decorator is
|
||||
// `pointer-events: none`, so anything else here is swallowed by the
|
||||
// chip's own click surface and can never clear the condition.
|
||||
<ChipDelete
|
||||
variant='plain'
|
||||
color={color}
|
||||
onDelete={event => {
|
||||
event.stopPropagation()
|
||||
<Close
|
||||
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
aria-label={`Remove ${label} filter`}
|
||||
sx={{
|
||||
'--Chip-deleteSize': chipSize === 'sm' ? '1.1rem' : '1.4rem',
|
||||
'--Icon-fontSize': chipSize === 'sm' ? '12px' : '16px',
|
||||
}}
|
||||
>
|
||||
<Close />
|
||||
</ChipDelete>
|
||||
/>
|
||||
}
|
||||
onClick={onOpen}
|
||||
sx={{
|
||||
@@ -87,10 +74,7 @@ const ActiveFilterChips = ({
|
||||
</Chip>
|
||||
))}
|
||||
|
||||
{/* Everything already visible → spend that slot on a "+" for appending
|
||||
another condition instead. With overflow, the count chip opens the
|
||||
same sheet anyway. */}
|
||||
{overflow > 0 ? (
|
||||
{overflow > 0 && (
|
||||
<Chip
|
||||
size={chipSize}
|
||||
variant='soft'
|
||||
@@ -106,30 +90,6 @@ const ActiveFilterChips = ({
|
||||
>
|
||||
+{overflow} more
|
||||
</Chip>
|
||||
) : (
|
||||
showAddChip &&
|
||||
(onAdd || onOpen) && (
|
||||
<Chip
|
||||
size={chipSize}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
onClick={onAdd || onOpen}
|
||||
aria-label='Add filter condition'
|
||||
title='Add filter condition'
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
px: 0.75,
|
||||
transition: 'all 0.15s ease',
|
||||
'&:hover': { opacity: 0.85 },
|
||||
...overflowChipSx,
|
||||
}}
|
||||
>
|
||||
<Add
|
||||
sx={{ fontSize: chipSize === 'sm' ? 12 : 16, display: 'block' }}
|
||||
/>
|
||||
</Chip>
|
||||
)
|
||||
)}
|
||||
|
||||
{resultCount != null && totalCount != null && (
|
||||
|
||||
@@ -5,8 +5,3 @@ import tailwindConfig from '/tailwind.config.mjs'
|
||||
export const { theme: THEME } = resolveConfig(tailwindConfig)
|
||||
|
||||
export const COLORS = THEME.colors
|
||||
|
||||
export const THEME_BACKGROUND = {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF',
|
||||
}
|
||||
|
||||
@@ -22,8 +22,6 @@ export const Z_INDEX = {
|
||||
MODAL_BACKDROP: 2000,
|
||||
MODAL_CONTENT: 2001,
|
||||
MODAL_CLOSE_BUTTON: 2002,
|
||||
// Popups that must float above open modals (portaled to document.body)
|
||||
MODAL_POPOVER: 2100,
|
||||
TOAST: 3000,
|
||||
|
||||
// Critical System UI (9000-9999)
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import App from '@/App'
|
||||
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
|
||||
import Error from '@/views/Error'
|
||||
@@ -12,8 +10,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 GlobalSearchPage from '../search/GlobalSearchPage'
|
||||
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'
|
||||
@@ -29,11 +26,6 @@ import FilterView from '../views/Filters/FilterView'
|
||||
import ChoreHistory from '../views/History/ChoreHistory'
|
||||
import LabelView from '../views/Labels/LabelView'
|
||||
import Landing from '../views/Landing/Landing'
|
||||
import CircleSetupView from '../views/Onboarding/CircleSetupView'
|
||||
import GetStartedView from '../views/Onboarding/GetStartedView'
|
||||
import HeardAboutView from '../views/Onboarding/HeardAboutView'
|
||||
import OnboardingView from '../views/Onboarding/OnboardingView'
|
||||
import WorkspaceReadyView from '../views/Onboarding/WorkspaceReadyView'
|
||||
import PaymentCancelledView from '../views/Payments/PaymentFailView'
|
||||
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
|
||||
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
|
||||
@@ -52,6 +44,16 @@ 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: '/',
|
||||
@@ -60,7 +62,7 @@ const Router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: <MyChores />,
|
||||
element: getMainRoute(),
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -132,10 +134,6 @@ const Router = createBrowserRouter([
|
||||
path: '/chores',
|
||||
element: <MyChores />,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
element: <GlobalSearchPage />,
|
||||
},
|
||||
{
|
||||
path: '/archived',
|
||||
element: <ArchivedTasks />,
|
||||
@@ -184,26 +182,6 @@ const Router = createBrowserRouter([
|
||||
path: '/signup',
|
||||
element: <SignupView />,
|
||||
},
|
||||
{
|
||||
path: '/onboarding',
|
||||
element: <OnboardingView />,
|
||||
},
|
||||
{
|
||||
path: '/get-started',
|
||||
element: <GetStartedView />,
|
||||
},
|
||||
{
|
||||
path: '/ready',
|
||||
element: <WorkspaceReadyView />,
|
||||
},
|
||||
{
|
||||
path: '/circle-setup',
|
||||
element: <CircleSetupView />,
|
||||
},
|
||||
{
|
||||
path: '/heard-about',
|
||||
element: <HeardAboutView />,
|
||||
},
|
||||
|
||||
{
|
||||
path: '/auth/:provider',
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { COLORS } from '@/constants/theme'
|
||||
import { CssBaseline } from '@mui/joy'
|
||||
import { CssVarsProvider, extendTheme } from '@mui/joy/styles'
|
||||
import PropType from 'prop-types'
|
||||
|
||||
import { COLORS, THEME_BACKGROUND } from '@/constants/theme'
|
||||
|
||||
const primaryColor = 'cyan'
|
||||
const shades = [
|
||||
'50',
|
||||
@@ -35,9 +34,6 @@ const theme = extendTheme({
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.light,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
success: {
|
||||
50: '#f3faf7',
|
||||
@@ -79,9 +75,6 @@ const theme = extendTheme({
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.dark,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -59,13 +59,7 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
if (!response.ok) {
|
||||
const res = await response.json()
|
||||
// `status` is passed back so callers can tell a rejected password from
|
||||
// an unreachable or broken server without parsing the message.
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
error: res?.error || 'Login failed',
|
||||
}
|
||||
return { success: false, error: res?.error || 'Login failed' }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import imageCompression from 'browser-image-compression'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
import { useUserProfile } from '../queries/UserQueries'
|
||||
import { useNotification } from '../service/NotificationProvider'
|
||||
import { apiClient } from '../utils/ApiClient'
|
||||
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
|
||||
|
||||
export const useFileUpload = ({
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
} = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -20,36 +19,28 @@ export const useFileUpload = ({
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
// Only images go through compression — anything else (PDFs, docs)
|
||||
// would be destroyed by re-encoding it as a JPEG.
|
||||
let fileToUpload = file
|
||||
if (file.type?.startsWith('image/')) {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(
|
||||
file,
|
||||
compressionOptions,
|
||||
)
|
||||
fileToUpload = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
}
|
||||
|
||||
const compressedFile = await imageCompression(file, compressionOptions)
|
||||
const compressedJpegFile = new File(
|
||||
[compressedFile],
|
||||
`${file.name.split('.')[0]}.jpg`,
|
||||
{ type: 'image/jpeg' },
|
||||
)
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', fileToUpload)
|
||||
formData.append('file', compressedJpegFile)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
@@ -71,7 +62,7 @@ export const useFileUpload = ({
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'File uploads are only available for Plus accounts.',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
@@ -83,7 +74,7 @@ export const useFileUpload = ({
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload file.',
|
||||
message: 'Failed to upload image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -100,7 +91,7 @@ export const useFileUpload = ({
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the file.',
|
||||
message: 'An error occurred while processing the image.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { hasSeenOnboarding, isNativeApp } from '../utils/Onboarding'
|
||||
import { setPendingInvite } from '../utils/PendingInvite'
|
||||
|
||||
// Routes a first-run user may legitimately be on without having gone through
|
||||
// onboarding: the flow itself, deep-link auth callbacks, and the legal pages
|
||||
// linked from it.
|
||||
const ALLOWED_PATHS = [
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/login/settings',
|
||||
'/privacy',
|
||||
'/terms',
|
||||
// An invite link is a legitimate first launch: the join view explains itself
|
||||
// and routes to sign-in, so onboarding must not swallow the code.
|
||||
'/circle/join',
|
||||
]
|
||||
|
||||
const isAllowed = pathname =>
|
||||
ALLOWED_PATHS.includes(pathname) || pathname.startsWith('/auth/')
|
||||
|
||||
/**
|
||||
* Sends first-launch native users to the onboarding flow. Runs on every
|
||||
* navigation because an expired session hard-redirects to /login through
|
||||
* `window.location`, which remounts the app.
|
||||
*/
|
||||
const useOnboardingGate = () => {
|
||||
const navigate = useNavigate()
|
||||
const { pathname, search } = useLocation()
|
||||
const isRedirecting =
|
||||
isNativeApp() &&
|
||||
!hasSeenOnboarding() &&
|
||||
!localStorage.getItem('token') &&
|
||||
!isAllowed(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRedirecting) return
|
||||
|
||||
if (pathname === '/circle/join') {
|
||||
setPendingInvite(new URLSearchParams(search).get('code'))
|
||||
}
|
||||
|
||||
navigate('/onboarding', { replace: true })
|
||||
}, [isRedirecting, pathname, search, navigate])
|
||||
|
||||
return isRedirecting
|
||||
}
|
||||
|
||||
export default useOnboardingGate
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import statusBarManager from '../utils/StatusBarManager'
|
||||
|
||||
/**
|
||||
@@ -36,7 +35,10 @@ export const useStatusBar = () => {
|
||||
|
||||
// Update the status bar with the resolved theme
|
||||
await statusBarManager.updateResolvedTheme(resolvedTheme)
|
||||
|
||||
|
||||
// Also update the base theme for future reference
|
||||
await statusBarManager.setTheme(mode)
|
||||
|
||||
// Notify any custom listeners
|
||||
statusBarManager.notifyThemeChange(resolvedTheme)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ import { App as capacitorApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
@@ -92,18 +91,11 @@ export function useSyncOnReconnect() {
|
||||
|
||||
const runSync = async () => {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
// Public routes (onboarding, login, signup) have no session to sync.
|
||||
// Calling /sync/changes here returns 401 and the global auth handler
|
||||
// hard-navigates to /login, which reloads the WebView mid-onboarding.
|
||||
if (!localStorage.getItem('token')) return
|
||||
// Skip while the OAuth code exchange is in flight — there's no session
|
||||
// yet, so a sync here just 401s. Note the app-resume listener fires in
|
||||
// the same tick as the deep link, before the route changes, so this has
|
||||
// to test the shared flag rather than the pathname.
|
||||
if (isOAuthExchangeInProgress()) return
|
||||
// No session, nothing to sync — and a 401 here would force a logout that
|
||||
// hard-navigates signed-out visitors (invite links) away to /login.
|
||||
if (!localStorage.getItem('token')) return
|
||||
const wasOffline = !networkManager.isOnline
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
|
||||
23
src/main.jsx
23
src/main.jsx
@@ -1,23 +1,14 @@
|
||||
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>
|
||||
<React.Suspense fallback={null}>
|
||||
<Site />
|
||||
</React.Suspense>
|
||||
<Contexts>
|
||||
<App />
|
||||
</Contexts>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -99,8 +99,7 @@ export const useChores = (includeArchive = false) => {
|
||||
queryFn: async () => {
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
try {
|
||||
// Sync from server first (coalesced with any run already in flight,
|
||||
// so a just-created chore can't be missed by a stale cursor)
|
||||
// Sync from server first (no-op if already syncing or offline)
|
||||
if (networkManager.isOnline) {
|
||||
await syncEngine.sync()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
@@ -29,7 +28,6 @@ export const useAllUsers = () => {
|
||||
|
||||
export const useCircleMembers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
@@ -47,10 +45,6 @@ export const useCircleMembers = () => {
|
||||
return { res: [] }
|
||||
}
|
||||
},
|
||||
// NavBar's avatar mounts this on every route, including the signed-out
|
||||
// ones. Without the gate the 401 tips ApiClient into a forced logout that
|
||||
// hard-navigates to /login — which is what used to eat circle invites.
|
||||
enabled: !!token,
|
||||
})
|
||||
|
||||
const handleRefetch = () => {
|
||||
@@ -60,7 +54,7 @@ export const useCircleMembers = () => {
|
||||
return { data, error, isLoading, handleRefetch }
|
||||
}
|
||||
|
||||
export const useUserProfile = ({ enabled = true } = {}) => {
|
||||
export const useUserProfile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
@@ -86,7 +80,7 @@ export const useUserProfile = ({ enabled = true } = {}) => {
|
||||
},
|
||||
staleTime: 30 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
enabled: enabled && !!token,
|
||||
enabled: !!token,
|
||||
})
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import useMediaQuery from '@mui/material/useMediaQuery'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isParentUser } from '../utils/UserHelpers'
|
||||
import GlobalSearchPalette from './GlobalSearchPalette'
|
||||
import { getSearchProviders } from './searchProviders'
|
||||
|
||||
const GlobalSearchContext = createContext(null)
|
||||
const BLOCKED_ROUTES = [
|
||||
'/login',
|
||||
'/signup',
|
||||
'/welcome',
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/ready',
|
||||
]
|
||||
|
||||
const unwrap = value => (Array.isArray(value) ? value : value?.res || [])
|
||||
const uniqueBy = (items, getId) => [
|
||||
...new Map(
|
||||
items.filter(Boolean).map(item => [String(getId(item)), item]),
|
||||
).values(),
|
||||
]
|
||||
|
||||
export const GlobalSearchProvider = ({ children }) => {
|
||||
const queryClient = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const isMobile = useMediaQuery('(max-width:768px)')
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [initialQuery, setInitialQuery] = useState('')
|
||||
const [documents, setDocuments] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const cachedChores = queryClient
|
||||
.getQueriesData({ queryKey: ['chores'] })
|
||||
.flatMap(([, data]) => unwrap(data))
|
||||
const cachedHistory = [
|
||||
...queryClient.getQueriesData({ queryKey: ['choresHistory'] }),
|
||||
...queryClient.getQueriesData({ queryKey: ['choreHistory'] }),
|
||||
].flatMap(([, data]) => unwrap(data))
|
||||
|
||||
const [
|
||||
offlineChores,
|
||||
offlineHistory,
|
||||
offlineProjects,
|
||||
offlineLabels,
|
||||
offlineMembers,
|
||||
offlineProfile,
|
||||
] = await Promise.all([
|
||||
offlineDB.getChores(true).catch(() => []),
|
||||
offlineDB.getHistoryByDays(365).catch(() => []),
|
||||
offlineDB.getKV('projects').catch(() => []),
|
||||
offlineDB.getKV('labels').catch(() => []),
|
||||
offlineDB.getKV('circle_members').catch(() => []),
|
||||
offlineDB.getKV('user_profile').catch(() => null),
|
||||
])
|
||||
|
||||
const projects = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['projects'])),
|
||||
...unwrap(offlineProjects),
|
||||
],
|
||||
item => item.id,
|
||||
)
|
||||
const labels = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['labels'])),
|
||||
...unwrap(offlineLabels),
|
||||
],
|
||||
item => item.id,
|
||||
)
|
||||
const members = uniqueBy(
|
||||
[
|
||||
...unwrap(queryClient.getQueryData(['allCircleMembers'])),
|
||||
...unwrap(offlineMembers),
|
||||
],
|
||||
item => item.userId,
|
||||
)
|
||||
const chores = uniqueBy(
|
||||
[...cachedChores, ...unwrap(offlineChores)],
|
||||
item => item.id,
|
||||
)
|
||||
const history = uniqueBy(
|
||||
[...cachedHistory, ...unwrap(offlineHistory)],
|
||||
item => item.id,
|
||||
)
|
||||
const profile =
|
||||
queryClient
|
||||
.getQueriesData({ queryKey: ['userProfile'] })
|
||||
.find(([, data]) => data)?.[1] || offlineProfile
|
||||
|
||||
const sources = {
|
||||
chores,
|
||||
history,
|
||||
projects,
|
||||
labels,
|
||||
members,
|
||||
isParent: isParentUser(profile),
|
||||
choresById: new Map(chores.map(item => [String(item.id), item])),
|
||||
projectsById: new Map(projects.map(item => [String(item.id), item])),
|
||||
membersById: new Map(members.map(item => [String(item.userId), item])),
|
||||
}
|
||||
|
||||
const nextDocuments = getSearchProviders().flatMap(provider => {
|
||||
try {
|
||||
return provider.getDocuments(sources) || []
|
||||
} catch (error) {
|
||||
console.warn(`Search provider ${provider.id} failed`, error)
|
||||
return []
|
||||
}
|
||||
})
|
||||
setDocuments(nextDocuments)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [queryClient])
|
||||
|
||||
const openSearch = useCallback(
|
||||
(query = '') => {
|
||||
if (BLOCKED_ROUTES.some(route => location.pathname.startsWith(route)))
|
||||
return
|
||||
if (isMobile) {
|
||||
loadDocuments()
|
||||
navigate('/search', { state: { initialQuery: query } })
|
||||
return
|
||||
}
|
||||
setInitialQuery(query)
|
||||
setIsOpen(true)
|
||||
loadDocuments()
|
||||
},
|
||||
[isMobile, loadDocuments, location.pathname, navigate],
|
||||
)
|
||||
|
||||
const closeSearch = useCallback(() => setIsOpen(false), [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = event => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'f') {
|
||||
event.preventDefault()
|
||||
isOpen ? closeSearch() : openSearch()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [closeSearch, isOpen, openSearch])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
closeSearch,
|
||||
documents,
|
||||
isLoading,
|
||||
loadDocuments,
|
||||
openSearch,
|
||||
}),
|
||||
[closeSearch, documents, isLoading, loadDocuments, openSearch],
|
||||
)
|
||||
|
||||
return (
|
||||
<GlobalSearchContext.Provider value={value}>
|
||||
{children}
|
||||
{isOpen && (
|
||||
<GlobalSearchPalette
|
||||
documents={documents}
|
||||
initialQuery={initialQuery}
|
||||
isLoading={isLoading}
|
||||
onClose={closeSearch}
|
||||
/>
|
||||
)}
|
||||
</GlobalSearchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useGlobalSearch = () => {
|
||||
const context = useContext(GlobalSearchContext)
|
||||
if (!context)
|
||||
throw new Error('useGlobalSearch must be used inside GlobalSearchProvider')
|
||||
return context
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useGlobalSearch } from './GlobalSearchContext'
|
||||
import GlobalSearchPalette from './GlobalSearchPalette'
|
||||
|
||||
const GlobalSearchPage = () => {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { documents, isLoading, loadDocuments } = useGlobalSearch()
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments()
|
||||
}, [loadDocuments])
|
||||
|
||||
const handleClose = () => {
|
||||
if (window.history.state?.idx > 0) navigate(-1)
|
||||
else navigate('/chores', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<GlobalSearchPalette
|
||||
documents={documents}
|
||||
initialQuery={location.state?.initialQuery || ''}
|
||||
isLoading={isLoading}
|
||||
onClose={handleClose}
|
||||
presentation='page'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default GlobalSearchPage
|
||||
@@ -1,468 +0,0 @@
|
||||
import {
|
||||
AddRounded,
|
||||
CheckCircleOutline,
|
||||
FolderOutlined,
|
||||
HistoryRounded,
|
||||
InboxOutlined,
|
||||
LabelOutlined,
|
||||
PersonOutline,
|
||||
SearchRounded,
|
||||
SettingsOutlined,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Input,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemContent,
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import PropTypes from 'prop-types'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import AppModal from '../components/common/AppModal'
|
||||
|
||||
const RECENTS_KEY = 'donetick.globalSearch.recents'
|
||||
const GROUPS = [
|
||||
'tasks',
|
||||
'history',
|
||||
'projects',
|
||||
'labels',
|
||||
'people',
|
||||
'settings',
|
||||
'actions',
|
||||
]
|
||||
const GROUP_LABELS = {
|
||||
tasks: 'Tasks',
|
||||
history: 'Notes',
|
||||
projects: 'Projects',
|
||||
labels: 'Labels',
|
||||
people: 'People',
|
||||
settings: 'Settings',
|
||||
actions: 'Quick actions',
|
||||
}
|
||||
|
||||
const ICONS = {
|
||||
tasks: <CheckCircleOutline />,
|
||||
history: <HistoryRounded />,
|
||||
projects: <FolderOutlined />,
|
||||
labels: <LabelOutlined />,
|
||||
people: <PersonOutline />,
|
||||
settings: <SettingsOutlined />,
|
||||
actions: <AddRounded />,
|
||||
}
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{
|
||||
id: 'action:create',
|
||||
provider: 'actions',
|
||||
title: 'Create a task',
|
||||
subtitle: 'Quick action',
|
||||
route: '/chores/create',
|
||||
},
|
||||
{
|
||||
id: 'action:tasks',
|
||||
provider: 'actions',
|
||||
title: 'View all tasks',
|
||||
subtitle: 'Navigation',
|
||||
route: '/chores',
|
||||
},
|
||||
{
|
||||
id: 'action:archived',
|
||||
provider: 'actions',
|
||||
title: 'View archived tasks',
|
||||
subtitle: 'Navigation',
|
||||
route: '/archived',
|
||||
},
|
||||
{
|
||||
id: 'action:settings',
|
||||
provider: 'actions',
|
||||
title: 'Open settings',
|
||||
subtitle: 'Navigation',
|
||||
route: '/settings',
|
||||
},
|
||||
]
|
||||
|
||||
const readRecents = () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(RECENTS_KEY)) || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const saveRecent = result => {
|
||||
if (result.provider === 'actions') return
|
||||
const recent = {
|
||||
id: result.id,
|
||||
provider: result.provider,
|
||||
route: result.route,
|
||||
title: result.title,
|
||||
subtitle: result.subtitle,
|
||||
}
|
||||
localStorage.setItem(
|
||||
RECENTS_KEY,
|
||||
JSON.stringify(
|
||||
[recent, ...readRecents().filter(item => item.id !== result.id)].slice(
|
||||
0,
|
||||
6,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const Highlight = ({ query, text }) => {
|
||||
if (!text || !query.trim()) return text || null
|
||||
const words = query.trim().split(/\s+/).filter(Boolean)
|
||||
const escaped = words.map(word => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
if (!escaped.length) return text
|
||||
const pattern = new RegExp(`(${escaped.join('|')})`, 'ig')
|
||||
const isMatch = new RegExp(`^(${escaped.join('|')})$`, 'i')
|
||||
return String(text)
|
||||
.split(pattern)
|
||||
.map((part, index) =>
|
||||
isMatch.test(part) ? (
|
||||
<Box
|
||||
component='mark'
|
||||
key={index}
|
||||
sx={{ bgcolor: 'warning.softBg', color: 'inherit', borderRadius: 2 }}
|
||||
>
|
||||
{part}
|
||||
</Box>
|
||||
) : (
|
||||
part
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const SearchContainer = ({ children, onClose, presentation }) => {
|
||||
if (presentation === 'page') {
|
||||
return (
|
||||
<Box
|
||||
component='main'
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: 'calc(100dvh - 56px)',
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
open
|
||||
onClose={onClose}
|
||||
disableRestoreFocus
|
||||
title='Search'
|
||||
size='lg'
|
||||
maxHeight='min(720px, calc(100dvh - 48px))'
|
||||
contentSx={{
|
||||
p: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
sx={{ height: 'min(720px, calc(100dvh - 48px))' }}
|
||||
>
|
||||
{children}
|
||||
</AppModal>
|
||||
)
|
||||
}
|
||||
|
||||
const GlobalSearchPalette = ({
|
||||
documents,
|
||||
initialQuery,
|
||||
isLoading,
|
||||
onClose,
|
||||
presentation = 'modal',
|
||||
}) => {
|
||||
const navigate = useNavigate()
|
||||
const focusInputRef = useCallback(node => {
|
||||
if (node) requestAnimationFrame(() => node.focus())
|
||||
}, [])
|
||||
const [query, setQuery] = useState(initialQuery || '')
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const [recents] = useState(readRecents)
|
||||
const selectedResultRef = useRef(null)
|
||||
|
||||
const searchIndexes = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
GROUPS.filter(group => group !== 'actions').map(group => [
|
||||
group,
|
||||
new Fuse(
|
||||
documents.filter(item => item.provider === group),
|
||||
{
|
||||
threshold: 0.38,
|
||||
distance: 120,
|
||||
ignoreLocation: true,
|
||||
includeScore: true,
|
||||
keys:
|
||||
group === 'history'
|
||||
? [{ name: 'body', weight: 1 }]
|
||||
: [
|
||||
{ name: 'title', weight: 0.5 },
|
||||
{ name: 'keywords', weight: 0.25 },
|
||||
{ name: 'body', weight: 0.17 },
|
||||
{ name: 'subtitle', weight: 0.08 },
|
||||
],
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
[documents],
|
||||
)
|
||||
|
||||
const results = useMemo(() => {
|
||||
const normalized = query.trim().toLocaleLowerCase()
|
||||
if (!normalized) {
|
||||
const currentById = new Map(documents.map(item => [item.id, item]))
|
||||
const recentResults = recents
|
||||
.map(item => currentById.get(item.id) || item)
|
||||
.filter(item => item.provider !== 'history' || currentById.has(item.id))
|
||||
return [...recentResults, ...QUICK_ACTIONS]
|
||||
}
|
||||
|
||||
const grouped = GROUPS.filter(group => group !== 'actions').flatMap(group =>
|
||||
(searchIndexes.get(group)?.search(normalized, { limit: 7 }) || [])
|
||||
.map(match => {
|
||||
const title = match.item.title?.toLocaleLowerCase() ?? ''
|
||||
let score = match.score ?? 1
|
||||
|
||||
if (group !== 'history') {
|
||||
if (title === normalized) {
|
||||
score -= 1
|
||||
} else if (title.startsWith(normalized)) {
|
||||
score -= 0.15
|
||||
} else if (title.includes(normalized)) {
|
||||
score -= 0.08
|
||||
}
|
||||
}
|
||||
|
||||
return { ...match.item, score }
|
||||
})
|
||||
.sort((a, b) => a.score - b.score),
|
||||
)
|
||||
grouped.push({
|
||||
id: 'action:filter-tasks',
|
||||
provider: 'actions',
|
||||
title: `Show tasks matching “${query.trim()}”`,
|
||||
subtitle: 'Filter the task list',
|
||||
route: `/chores?search=${encodeURIComponent(query.trim())}`,
|
||||
})
|
||||
return grouped
|
||||
}, [documents, query, recents, searchIndexes])
|
||||
|
||||
useEffect(() => {
|
||||
selectedResultRef.current?.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}, [selectedIndex, results])
|
||||
|
||||
const selectResult = result => {
|
||||
saveRecent(result)
|
||||
navigate(result.route)
|
||||
if (presentation === 'modal') onClose()
|
||||
}
|
||||
|
||||
const onInputKeyDown = event => {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(index => Math.min(index + 1, results.length - 1))
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
setSelectedIndex(index => Math.max(index - 1, 0))
|
||||
} else if (event.key === 'Enter' && results[selectedIndex]) {
|
||||
event.preventDefault()
|
||||
selectResult(results[selectedIndex])
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchContainer onClose={onClose} presentation={presentation}>
|
||||
<Box sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Input
|
||||
autoFocus
|
||||
slotProps={{
|
||||
input: {
|
||||
ref: focusInputRef,
|
||||
'aria-label':
|
||||
'Search tasks, history, projects, labels and settings',
|
||||
},
|
||||
}}
|
||||
value={query}
|
||||
onChange={event => {
|
||||
setQuery(event.target.value)
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder='Search Donetick'
|
||||
startDecorator={<SearchRounded />}
|
||||
endDecorator={
|
||||
isLoading ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : presentation === 'modal' ? (
|
||||
<Chip size='sm' variant='outlined'>
|
||||
Esc
|
||||
</Chip>
|
||||
) : null
|
||||
}
|
||||
sx={{
|
||||
'--Input-minHeight': '48px',
|
||||
fontSize: 'md',
|
||||
borderRadius: 'lg',
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', mt: 1, px: 0.5 }}
|
||||
>
|
||||
Searching content available on this device
|
||||
</Typography>
|
||||
</Box>
|
||||
<Divider />
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
pb: 'var(--safe-area-inset-bottom, 0px)',
|
||||
}}
|
||||
>
|
||||
{!isLoading && query.trim() && results.length === 1 && (
|
||||
<Box sx={{ px: 3, py: 6, textAlign: 'center' }}>
|
||||
<InboxOutlined
|
||||
sx={{ fontSize: 36, color: 'text.tertiary', mb: 1 }}
|
||||
/>
|
||||
<Typography level='title-md'>No direct matches</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
|
||||
You can still filter the task list with this search.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<List aria-live='polite' sx={{ px: 1, py: 1 }}>
|
||||
{results.map((result, index) => {
|
||||
const hasQuery = Boolean(query.trim())
|
||||
const showHeading = hasQuery
|
||||
? index === 0 || result.provider !== results[index - 1].provider
|
||||
: index === 0 ||
|
||||
(result.provider === 'actions' &&
|
||||
results[index - 1].provider !== 'actions')
|
||||
return (
|
||||
<Box key={result.id}>
|
||||
{showHeading && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
fontWeight: 'lg',
|
||||
px: 1.5,
|
||||
pt: index ? 2 : 0.5,
|
||||
pb: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{!query.trim() && result.provider !== 'actions'
|
||||
? 'Recent'
|
||||
: GROUP_LABELS[result.provider]}
|
||||
</Typography>
|
||||
)}
|
||||
<ListItemButton
|
||||
ref={index === selectedIndex ? selectedResultRef : null}
|
||||
selected={index === selectedIndex}
|
||||
onMouseMove={() => setSelectedIndex(index)}
|
||||
onClick={() => selectResult(result)}
|
||||
sx={{
|
||||
borderRadius: 'md',
|
||||
py: 1.1,
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator
|
||||
sx={{ mt: 0.25, color: result.color || 'text.secondary' }}
|
||||
>
|
||||
{ICONS[result.provider]}
|
||||
</ListItemDecorator>
|
||||
<ListItemContent>
|
||||
<Typography
|
||||
level='title-sm'
|
||||
sx={{ overflowWrap: 'anywhere' }}
|
||||
>
|
||||
<Highlight query={query} text={result.title} />
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.secondary' }}
|
||||
noWrap
|
||||
>
|
||||
{[result.subtitle, result.body]
|
||||
.filter(Boolean)
|
||||
.join(' · ')}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
</ListItemButton>
|
||||
</Box>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'flex' },
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-xs'>↑↓ Navigate</Typography>
|
||||
<Typography level='body-xs'>↵ Open</Typography>
|
||||
<Typography level='body-xs' sx={{ ml: 'auto' }}>
|
||||
{query.trim()
|
||||
? `${Math.max(0, results.length - 1)} results`
|
||||
: 'Type to search'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</SearchContainer>
|
||||
)
|
||||
}
|
||||
|
||||
SearchContainer.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
presentation: PropTypes.oneOf(['modal', 'page']).isRequired,
|
||||
}
|
||||
|
||||
Highlight.propTypes = {
|
||||
query: PropTypes.string.isRequired,
|
||||
text: PropTypes.string,
|
||||
}
|
||||
|
||||
GlobalSearchPalette.propTypes = {
|
||||
documents: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
initialQuery: PropTypes.string,
|
||||
isLoading: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
presentation: PropTypes.oneOf(['modal', 'page']),
|
||||
}
|
||||
|
||||
export default GlobalSearchPalette
|
||||
@@ -1,182 +0,0 @@
|
||||
const stripHtml = value => {
|
||||
if (!value) return ''
|
||||
if (typeof globalThis.document === 'undefined')
|
||||
return String(value).replace(/<[^>]*>/g, ' ')
|
||||
const element = globalThis.document.createElement('div')
|
||||
element.innerHTML = String(value)
|
||||
return element.textContent || element.innerText || ''
|
||||
}
|
||||
|
||||
const HISTORY_STATUS = {
|
||||
0: 'in progress',
|
||||
1: 'completed',
|
||||
2: 'skipped',
|
||||
3: 'pending approval',
|
||||
4: 'rejected',
|
||||
5: 'missed',
|
||||
6: 'rescheduled',
|
||||
}
|
||||
|
||||
const SETTINGS = [
|
||||
['profile', 'Profile', 'Name, avatar and personal details'],
|
||||
['circle', 'Circle', 'Members and household settings', true],
|
||||
['account', 'Account', 'Subscription and account management', true],
|
||||
['subaccounts', 'Subaccounts', 'Manage child accounts'],
|
||||
['notifications', 'Notifications', 'Reminders and notification preferences'],
|
||||
['mfa', 'Multi-factor authentication', 'Secure your account', true],
|
||||
['apitokens', 'API tokens', 'Manage integrations and access tokens', true],
|
||||
['storage', 'Storage', 'Files, backups and device storage'],
|
||||
['sidepanel', 'Side panel', 'Customize navigation'],
|
||||
['theme', 'Appearance', 'Theme, dark mode and colors'],
|
||||
['localization', 'Language and region', 'Language, dates and time formats'],
|
||||
[
|
||||
'advanced',
|
||||
'Advanced settings',
|
||||
'Offline support, webhooks and application behavior',
|
||||
],
|
||||
['developer', 'Developer settings', 'Diagnostics and experimental tools'],
|
||||
]
|
||||
|
||||
const providers = []
|
||||
|
||||
export const registerSearchProvider = provider => {
|
||||
if (!provider?.id || typeof provider.getDocuments !== 'function') {
|
||||
throw new Error('A search provider needs an id and getDocuments function')
|
||||
}
|
||||
const existing = providers.findIndex(item => item.id === provider.id)
|
||||
if (existing >= 0) providers.splice(existing, 1, provider)
|
||||
else providers.push(provider)
|
||||
return () => {
|
||||
const index = providers.indexOf(provider)
|
||||
if (index >= 0) providers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
export const getSearchProviders = () => [...providers]
|
||||
|
||||
const document = (provider, item) => ({ provider, ...item })
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'tasks',
|
||||
getDocuments: ({ chores, membersById, projectsById }) =>
|
||||
chores.map(chore => {
|
||||
const labels =
|
||||
chore.labelsV2?.map(label => label.name).filter(Boolean) || []
|
||||
const project = projectsById.get(String(chore.projectId))
|
||||
const assignees = (chore.assignees || [])
|
||||
.map(assignee => membersById.get(String(assignee.userId))?.displayName)
|
||||
.filter(Boolean)
|
||||
const description = stripHtml(chore.description)
|
||||
return document('tasks', {
|
||||
id: `task:${chore.id}`,
|
||||
entityId: chore.id,
|
||||
title: chore.name || 'Untitled task',
|
||||
subtitle:
|
||||
[project?.name, ...labels].filter(Boolean).join(' · ') || 'Task',
|
||||
body: description,
|
||||
keywords: [...labels, project?.name, ...assignees]
|
||||
.filter(Boolean)
|
||||
.join(' '),
|
||||
route: `/chores/${chore.id}`,
|
||||
updatedAt: chore.updatedAt || chore.createdAt,
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'history',
|
||||
getDocuments: ({ choresById, history, membersById }) =>
|
||||
history.flatMap(entry => {
|
||||
const note = stripHtml(entry.notes).trim()
|
||||
if (!note) return []
|
||||
|
||||
const chore = choresById.get(String(entry.choreId))
|
||||
const member = membersById.get(String(entry.completedBy))
|
||||
return [
|
||||
document('history', {
|
||||
id: `history:${entry.id}`,
|
||||
entityId: entry.id,
|
||||
title: chore?.name || entry.choreName || 'Task note',
|
||||
subtitle: [
|
||||
member?.displayName,
|
||||
entry.performedAt
|
||||
? new Date(entry.performedAt).toLocaleDateString()
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · '),
|
||||
body: note,
|
||||
keywords: `${HISTORY_STATUS[entry.status] || 'activity'} ${member?.displayName || ''}`,
|
||||
route: entry.choreId
|
||||
? `/chores/${entry.choreId}/history`
|
||||
: '/activities',
|
||||
updatedAt: entry.performedAt || entry.updatedAt,
|
||||
}),
|
||||
]
|
||||
}),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'projects',
|
||||
getDocuments: ({ projects }) =>
|
||||
projects.map(project =>
|
||||
document('projects', {
|
||||
id: `project:${project.id}`,
|
||||
entityId: project.id,
|
||||
title: project.name || 'Untitled project',
|
||||
subtitle: 'Project',
|
||||
body: stripHtml(project.description),
|
||||
keywords: 'folder project',
|
||||
route: `/chores?project=${encodeURIComponent(project.id)}`,
|
||||
updatedAt: project.updatedAt,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'labels',
|
||||
getDocuments: ({ labels }) =>
|
||||
labels.map(label =>
|
||||
document('labels', {
|
||||
id: `label:${label.id}`,
|
||||
entityId: label.id,
|
||||
title: label.name || 'Untitled label',
|
||||
subtitle: 'Label',
|
||||
keywords: 'tag label',
|
||||
route: '/labels',
|
||||
color: label.color,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'people',
|
||||
getDocuments: ({ members }) =>
|
||||
members.map(member =>
|
||||
document('people', {
|
||||
id: `person:${member.userId}`,
|
||||
entityId: member.userId,
|
||||
title: member.displayName || member.username || 'Circle member',
|
||||
subtitle: 'Circle member',
|
||||
keywords: `${member.username || ''} person member assignee`,
|
||||
route: '/chores',
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
registerSearchProvider({
|
||||
id: 'settings',
|
||||
getDocuments: ({ isParent }) =>
|
||||
SETTINGS.filter(([, , , parentOnly]) => !parentOnly || isParent).map(
|
||||
([id, title, description]) =>
|
||||
document('settings', {
|
||||
id: `setting:${id}`,
|
||||
entityId: id,
|
||||
title,
|
||||
subtitle: 'Settings',
|
||||
body: description,
|
||||
keywords: `preferences configuration ${id}`,
|
||||
route: `/settings/${id}`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
@@ -1,189 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
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(),
|
||||
// No error means the user came here deliberately from settings rather than
|
||||
// off the back of a crash — same diagnostics, different story to tell.
|
||||
kind: error ? 'crash' : 'bug',
|
||||
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}`,
|
||||
'',
|
||||
// A user-initiated report has no throw behind it; "Error: Unknown" would
|
||||
// only be noise in the panel the user is being asked to read.
|
||||
report.kind === 'bug'
|
||||
? 'Reported manually (no crash)'
|
||||
: `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 isBug = report.kind === 'bug'
|
||||
const title = isBug
|
||||
? `[bug] ${description?.trim().slice(0, 80) || 'Reported from the app'}`
|
||||
: `[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: report.kind === 'bug' ? 'bug-report' : '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,11 +1,6 @@
|
||||
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'
|
||||
@@ -134,7 +129,7 @@ class ApiClient {
|
||||
|
||||
// Process queued requests after refresh attempt
|
||||
processQueue(error, token = null) {
|
||||
this.failedQueue.forEach(({ reject, resolve }) => {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
@@ -155,21 +150,6 @@ class ApiClient {
|
||||
return
|
||||
}
|
||||
|
||||
// An expired session on an invite link would otherwise drop the code on the
|
||||
// way to /login. Stash it first so sign-in returns to the join.
|
||||
try {
|
||||
const { pathname, search } = window.location
|
||||
if (pathname === '/circle/join') {
|
||||
const code = new URLSearchParams(search).get('code')
|
||||
if (code) {
|
||||
const { setPendingInvite } = await import('./PendingInvite')
|
||||
setPendingInvite(code)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error preserving pending invite on logout', e)
|
||||
}
|
||||
|
||||
await clearAllTokens()
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
@@ -219,17 +199,6 @@ 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
|
||||
@@ -311,7 +280,6 @@ 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,8 +89,6 @@ 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:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Turns an image source the scanners produce — a base64 data URI on iOS/web,
|
||||
* a Capacitor localhost URL on Android — into a File the upload endpoint
|
||||
* accepts. Both forms are fetchable, so one path covers them.
|
||||
*/
|
||||
export async function imageSourceToFile(source, fileName = 'scan.jpg') {
|
||||
if (!source) return null
|
||||
try {
|
||||
const response = await fetch(source)
|
||||
const blob = await response.blob()
|
||||
const type = blob.type && blob.type !== '' ? blob.type : 'image/jpeg'
|
||||
return new File([blob], fileName, { type })
|
||||
} catch (e) {
|
||||
console.error('[FileConvert] failed to convert image source:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
// First-run onboarding is a native-app-only flow. On the web the user already
|
||||
// chose to visit a URL, so we drop them straight on the auth screens.
|
||||
const STORAGE_KEY = 'onboardingCompletedAt'
|
||||
|
||||
export const isNativeApp = () => {
|
||||
try {
|
||||
return Capacitor.isNativePlatform()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const hasSeenOnboarding = () => {
|
||||
try {
|
||||
return Boolean(localStorage.getItem(STORAGE_KEY))
|
||||
} catch {
|
||||
// Private-mode / storage-disabled webviews: never trap the user in a loop.
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const markOnboardingSeen = () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, new Date().toISOString())
|
||||
} catch {
|
||||
// ignore, worst case the flow is shown once more
|
||||
}
|
||||
}
|
||||
|
||||
export const resetOnboarding = () => {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const ACQUISITION_SOURCE_KEY = 'acquisitionSource'
|
||||
|
||||
/**
|
||||
* Stashes the "where'd you hear about us" answer locally for now. No
|
||||
* analytics pipeline is wired up yet — this is the single place that'll
|
||||
* change once there is one, so the survey screen itself never has to.
|
||||
*/
|
||||
export const recordAcquisitionSource = source => {
|
||||
try {
|
||||
localStorage.setItem(ACQUISITION_SOURCE_KEY, source)
|
||||
} catch {
|
||||
// ignore, this is best-effort telemetry
|
||||
}
|
||||
}
|
||||
|
||||
const PRIVACY_PREFERENCES_KEY = 'privacyPreferences'
|
||||
|
||||
/**
|
||||
* Stashes the self-hosted privacy opt-ins locally, same stub-for-now
|
||||
* treatment as recordAcquisitionSource: no crash reporter or PostHog is wired
|
||||
* up yet, so this is just the one place that'll change once there is one.
|
||||
*/
|
||||
export const recordPrivacyPreferences = ({ crashReports, analytics }) => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
PRIVACY_PREFERENCES_KEY,
|
||||
JSON.stringify({ crashReports, analytics }),
|
||||
)
|
||||
} catch {
|
||||
// ignore, this is best-effort telemetry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the OS for notification permission during onboarding and records the
|
||||
* answer under the same `notificationPreferences` key NotificationAccessSnackbar
|
||||
* reads, so a user who says yes here is never asked again after signing in.
|
||||
*
|
||||
* Only the permission is requested: registering the push token needs a session,
|
||||
* and there isn't one yet. The snackbar picks that up once the user is in.
|
||||
*/
|
||||
export const requestNotificationPermission = async () => {
|
||||
if (!isNativeApp()) return false
|
||||
try {
|
||||
const { LocalNotifications } = await import(
|
||||
'@capacitor/local-notifications'
|
||||
)
|
||||
const { Preferences } = await import('@capacitor/preferences')
|
||||
|
||||
const result = await LocalNotifications.requestPermissions()
|
||||
const granted = result?.display === 'granted'
|
||||
|
||||
await Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify({ optOut: false, granted }),
|
||||
})
|
||||
return granted
|
||||
} catch {
|
||||
// Permission plugins are missing or the prompt was dismissed: carry on,
|
||||
// the in-app snackbar can still ask later.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const haptic = async (kind = 'light') => {
|
||||
if (!isNativeApp()) return
|
||||
try {
|
||||
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
|
||||
await Haptics.impact({
|
||||
style: kind === 'medium' ? ImpactStyle.Medium : ImpactStyle.Light,
|
||||
})
|
||||
} catch {
|
||||
// no haptics on this platform
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import Cookies from 'js-cookie'
|
||||
|
||||
// A circle invite link is often the very first thing a new user opens, so the
|
||||
// code has to survive the trip through login/signup (including OAuth, which
|
||||
// leaves and re-enters the app) and be replayed once a session exists.
|
||||
const INVITE_KEY = 'pending_circle_invite'
|
||||
const REDIRECT_COOKIE = 'ca_redirect'
|
||||
|
||||
// `auto=1` tells the join view this visit is the return leg of an auth
|
||||
// round-trip, so it can submit the request instead of asking a second time.
|
||||
export const joinCirclePath = code =>
|
||||
`/circle/join?code=${encodeURIComponent(code)}&auto=1`
|
||||
|
||||
export const setPendingInvite = code => {
|
||||
if (!code) return
|
||||
|
||||
try {
|
||||
localStorage.setItem(INVITE_KEY, code)
|
||||
} catch {
|
||||
// The redirect cookie still preserves the invite through authentication.
|
||||
}
|
||||
// Every post-auth landing point (password login, OAuth callback, MFA) already
|
||||
// consumes `ca_redirect`, so reusing it is all the routing this needs.
|
||||
Cookies.set(REDIRECT_COOKIE, joinCirclePath(code), { expires: 1 })
|
||||
}
|
||||
|
||||
export const getPendingInvite = () => {
|
||||
try {
|
||||
return localStorage.getItem(INVITE_KEY)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPendingInvite = () => {
|
||||
try {
|
||||
localStorage.removeItem(INVITE_KEY)
|
||||
} catch {
|
||||
// Ignore unavailable storage during cleanup.
|
||||
}
|
||||
|
||||
const redirect = Cookies.get(REDIRECT_COOKIE)
|
||||
if (redirect?.startsWith('/circle/join')) {
|
||||
Cookies.remove(REDIRECT_COOKIE)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { Capacitor } from '@capacitor/core'
|
||||
import { StatusBar, Style } from '@capacitor/status-bar'
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
|
||||
import { THEME_BACKGROUND } from '@/constants/theme'
|
||||
|
||||
/**
|
||||
* StatusBarManager - A utility class to handle status bar configuration
|
||||
* following Capacitor best practices and theme-aware styling
|
||||
@@ -54,18 +52,17 @@ class StatusBarManager {
|
||||
this.currentTheme = theme
|
||||
|
||||
try {
|
||||
const resolvedTheme =
|
||||
theme === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
|
||||
let style = Style.Light // Default to light content (dark status bar)
|
||||
|
||||
if (theme === 'dark') {
|
||||
style = Style.Dark // Dark content (light status bar)
|
||||
} else if (theme === 'system') {
|
||||
// For system theme, we need to detect the actual system preference
|
||||
// Joy UI's useColorScheme will handle this, but we default to light
|
||||
style = Style.Light
|
||||
}
|
||||
|
||||
await StatusBar.setStyle({ style })
|
||||
await StatusBar.setBackgroundColor({
|
||||
color: THEME_BACKGROUND[resolvedTheme],
|
||||
})
|
||||
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to set theme:', error)
|
||||
@@ -165,7 +162,15 @@ class StatusBarManager {
|
||||
async updateResolvedTheme(resolvedTheme) {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
await this.setTheme(resolvedTheme)
|
||||
try {
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : Style.Light
|
||||
await StatusBar.setStyle({ style })
|
||||
console.log(
|
||||
`StatusBarManager: Resolved theme updated to ${resolvedTheme}`,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('StatusBarManager: Failed to update resolved theme:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,10 +28,6 @@ class SyncEngine {
|
||||
constructor() {
|
||||
this.isSyncing = false
|
||||
this.listeners = []
|
||||
// The run currently in flight, and the single follow-up run queued behind
|
||||
// it. See sync() for why a follow-up is needed rather than just waiting.
|
||||
this.inFlight = null
|
||||
this.queued = null
|
||||
}
|
||||
|
||||
// Register listener for sync state changes
|
||||
@@ -46,38 +42,10 @@ class SyncEngine {
|
||||
this.listeners.forEach(cb => cb(state))
|
||||
}
|
||||
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise.
|
||||
//
|
||||
// Concurrent callers are coalesced rather than dropped. Returning early while
|
||||
// another run is in flight used to lose writes: that run's /sync/changes
|
||||
// request may have been issued *before* the caller's change reached the
|
||||
// server, so its cursor skips past the change and the caller reads a cache
|
||||
// that will never contain it until something else triggers a sync. That is
|
||||
// why a task created from the modal could vanish on the refetch right after
|
||||
// it was created. Waiting for the in-flight run is not enough for the same
|
||||
// reason, so callers that arrive mid-run share one follow-up run instead.
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise
|
||||
async sync() {
|
||||
if (!isOfflineFeatureEnabled()) return false
|
||||
|
||||
if (this.inFlight) {
|
||||
if (!this.queued) {
|
||||
this.queued = this.inFlight
|
||||
.catch(() => false)
|
||||
.then(() => {
|
||||
this.queued = null
|
||||
return this.sync()
|
||||
})
|
||||
}
|
||||
return this.queued
|
||||
}
|
||||
|
||||
this.inFlight = this._runSync().finally(() => {
|
||||
this.inFlight = null
|
||||
})
|
||||
return this.inFlight
|
||||
}
|
||||
|
||||
async _runSync() {
|
||||
if (this.isSyncing) return false
|
||||
this.isSyncing = true
|
||||
this._notify({ syncing: true, error: null })
|
||||
|
||||
|
||||
@@ -12,14 +12,12 @@ import Cookies from 'js-cookie'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LoginSocialGoogle } from 'reactjs-social-login'
|
||||
|
||||
import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config'
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { saveTokens } from '../../utils/TokenStorage'
|
||||
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
|
||||
import {
|
||||
@@ -140,15 +138,7 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
// An already-signed-in visitor who lands here from a deep link (a circle
|
||||
// invite, for example) still has to end up where they were headed.
|
||||
const redirectUrl = Cookies.get('ca_redirect')
|
||||
if (redirectUrl && redirectUrl !== '/') {
|
||||
Cookies.remove('ca_redirect')
|
||||
Navigate(redirectUrl)
|
||||
} else {
|
||||
Navigate('/chores')
|
||||
}
|
||||
Navigate('/chores')
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
const handleSubmit = async e => {
|
||||
@@ -426,11 +416,9 @@ const LoginView = () => {
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Sign in and we’ll send your circle join request right after.'
|
||||
: userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
}
|
||||
logoSize={0}
|
||||
footer={<LegalLinks />}
|
||||
|
||||
@@ -2,11 +2,8 @@ import { Box, Link, Typography } from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useAuth } from '../../hooks/useAuth.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { signUp } from '../../utils/Fetcher'
|
||||
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
|
||||
import { login, signUp } from '../../utils/Fetcher'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
@@ -28,39 +25,24 @@ const SignupView = () => {
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
const { login: authLogin } = useAuth()
|
||||
// Sign-in goes through the auth context, not a bare fetch: it stores the
|
||||
// refresh token and updates the provider's own state, so the rest of the app
|
||||
// sees the new session without a reload.
|
||||
const handleLogin = async (username, password) => {
|
||||
const result = await authLogin({ username, password })
|
||||
if (!result.success) {
|
||||
showError({
|
||||
title: 'Almost there',
|
||||
message:
|
||||
'Your account was created, but signing in failed. Please sign in.',
|
||||
})
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
const handleLogin = (username, password) => {
|
||||
login(username, password).then(response => {
|
||||
if (response.status === 200) {
|
||||
response.json().then(res => {
|
||||
localStorage.setItem('token', res.token)
|
||||
localStorage.setItem('token_expiry', res.expire)
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
// Someone who signed up from a circle invite is joining an existing
|
||||
// circle, so sending them through "name your circle" is both a dead
|
||||
// end for the invite and the wrong question.
|
||||
const pendingInvite = getPendingInvite()
|
||||
if (pendingInvite) {
|
||||
Navigate(joinCirclePath(pendingInvite), { replace: true })
|
||||
return
|
||||
}
|
||||
Navigate('/chores')
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
|
||||
// The "how did you hear about us" step (/heard-about) is
|
||||
// temporarily skipped; new accounts go straight to circle setup.
|
||||
// Re-enable by navigating to '/heard-about' again — that view
|
||||
// already forwards to '/circle-setup' when done.
|
||||
Navigate('/circle-setup', { replace: true })
|
||||
// Navigate('/login')
|
||||
}
|
||||
})
|
||||
}
|
||||
const handleSignUpValidation = () => {
|
||||
// Reset errors before validation
|
||||
@@ -146,13 +128,9 @@ const SignupView = () => {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Create an account and we’ll send your circle join request right after.'
|
||||
: 'Track chores and tasks together, in one shared place.'
|
||||
}
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
logoSize={0}
|
||||
>
|
||||
<Box
|
||||
component='form'
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
DocumentScanner,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
@@ -38,11 +37,9 @@ 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'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import {
|
||||
useArchiveChore,
|
||||
useChore,
|
||||
@@ -61,13 +58,12 @@ import {
|
||||
GetThings,
|
||||
UploadChoreAttachment,
|
||||
} from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
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'
|
||||
@@ -88,7 +84,6 @@ 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']
|
||||
@@ -108,7 +103,7 @@ const ChoreEdit = () => {
|
||||
const [anyone, setAnyone] = useState(false)
|
||||
const [assignableTo, setAssignableTo] = useState([])
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [assignStrategy, setAssignStrategy] = useState(DEFAULT_ASSIGN_STRATEGY)
|
||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
@@ -156,7 +151,7 @@ const ChoreEdit = () => {
|
||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
|
||||
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects)
|
||||
|
||||
const [projectId, setProjectId] = useState(
|
||||
@@ -175,8 +170,7 @@ const ChoreEdit = () => {
|
||||
} = useChore(choreId)
|
||||
const { data: membersData, isLoading: isMemberDataLoading } =
|
||||
useCircleMembers()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
|
||||
@@ -189,26 +183,20 @@ 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`
|
||||
}
|
||||
@@ -378,8 +366,8 @@ const ChoreEdit = () => {
|
||||
frequencyType: frequencyType,
|
||||
frequency: Number(frequency),
|
||||
frequencyMetadata: frequencyMetadata,
|
||||
assignedTo: assignedToValue,
|
||||
assignStrategy: assignStrategyValue,
|
||||
assignedTo: assignStrategy === 'no_assignee' ? null : assignedTo,
|
||||
assignStrategy: assignStrategy,
|
||||
isRolling: isRolling,
|
||||
isActive: isActive,
|
||||
notification: isNotificable,
|
||||
@@ -475,20 +463,6 @@ 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)
|
||||
@@ -575,7 +549,7 @@ const ChoreEdit = () => {
|
||||
setAssignStrategy(
|
||||
data.res.assignStrategy
|
||||
? data.res.assignStrategy
|
||||
: DEFAULT_ASSIGN_STRATEGY,
|
||||
: ASSIGN_STRATEGIES[2],
|
||||
)
|
||||
setIsRolling(data.res.isRolling)
|
||||
setIsActive(data.res.isActive)
|
||||
@@ -658,6 +632,21 @@ 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([
|
||||
@@ -675,67 +664,6 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const uploadAttachmentFile = async file => {
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(file, 'chore_attachment_draft', {
|
||||
draftId,
|
||||
})
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Native only: the OS scanner returns a cropped, deskewed page which is a
|
||||
// better attachment than a raw camera shot of the same document.
|
||||
const handleScanAttachment = async () => {
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await uploadAttachmentFile(file)
|
||||
}
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
isOpen: true,
|
||||
@@ -1174,39 +1102,62 @@ const ChoreEdit = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Box sx={{ display: 'flex', gap: 1, alignSelf: 'flex-start' }}>
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
<Button
|
||||
component='label'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={isUploadingAttachment ? null : <UploadFile />}
|
||||
loading={isUploadingAttachment}
|
||||
sx={{ alignSelf: 'flex-start' }}
|
||||
>
|
||||
Upload File
|
||||
<input
|
||||
type='file'
|
||||
hidden
|
||||
onChange={async e => {
|
||||
const file = e.target.files[0]
|
||||
if (!file) return
|
||||
setIsUploadingAttachment(true)
|
||||
try {
|
||||
const response = choreId
|
||||
? await UploadChoreAttachment(file, 'chore_attachment', {
|
||||
entityId: choreId,
|
||||
})
|
||||
: await UploadChoreAttachment(
|
||||
file,
|
||||
'chore_attachment_draft',
|
||||
{ draftId },
|
||||
)
|
||||
if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
return
|
||||
}
|
||||
const data = await response.json()
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{
|
||||
file_path: data.path,
|
||||
file_name: data.file_name,
|
||||
size_bytes: data.size_bytes,
|
||||
sign: data.sign,
|
||||
},
|
||||
])
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload attachment.',
|
||||
})
|
||||
} finally {
|
||||
setIsUploadingAttachment(false)
|
||||
e.target.value = ''
|
||||
await uploadAttachmentFile(file)
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<DocumentScanner />}
|
||||
disabled={isUploadingAttachment}
|
||||
onClick={handleScanAttachment}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1309,13 +1260,12 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{canPickStrategy && (
|
||||
{!anyone && assignableTo.length > 1 && (
|
||||
<>
|
||||
<Box
|
||||
mb={3}
|
||||
sx={{
|
||||
display:
|
||||
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
|
||||
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<Typography level='h4'>Currently Assigned To</Typography>
|
||||
@@ -1329,7 +1279,7 @@ const ChoreEdit = () => {
|
||||
: 'Select an assignee for this task'
|
||||
}
|
||||
disabled={assignees.length === 0}
|
||||
value={assignedToValue}
|
||||
value={assignedTo > -1 ? assignedTo : null}
|
||||
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
|
||||
>
|
||||
{performers
|
||||
@@ -1359,7 +1309,7 @@ const ChoreEdit = () => {
|
||||
{ASSIGN_STRATEGIES.map((item, idx) => (
|
||||
<ListItem key={item}>
|
||||
<Checkbox
|
||||
checked={assignStrategyValue === item}
|
||||
checked={assignStrategy === item}
|
||||
onClick={() => setAssignStrategy(item)}
|
||||
overlay
|
||||
disableIcon
|
||||
|
||||
@@ -84,7 +84,9 @@ 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}`
|
||||
@@ -107,11 +109,11 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
}
|
||||
|
||||
export const RepeatOnSections = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
frequencyType,
|
||||
onFrequencyMetadataUpdate,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
|
||||
@@ -525,21 +527,20 @@ export const RepeatOnSections = ({
|
||||
}
|
||||
|
||||
const RepeatSection = ({
|
||||
OnTriggerValidate,
|
||||
allUserThings,
|
||||
frequency,
|
||||
frequencyError,
|
||||
frequencyMetadata,
|
||||
frequencyType,
|
||||
isAttemptToSave,
|
||||
onFrequencyMetadataUpdate,
|
||||
onFrequencyTypeUpdate,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyTypeUpdate,
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
frequencyError,
|
||||
allUserThings,
|
||||
onTriggerUpdate,
|
||||
OnTriggerValidate,
|
||||
isAttemptToSave,
|
||||
selectedThing,
|
||||
viewOnly = false,
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||
const { data: userProfile } = useUserProfile()
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Label,
|
||||
Person,
|
||||
PriorityHigh,
|
||||
SearchOff,
|
||||
SelectAll,
|
||||
Unarchive,
|
||||
ViewAgenda,
|
||||
@@ -28,8 +27,6 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
@@ -39,9 +36,9 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../../utils/OfflineFeatureToggle'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import ChoreCard from './ChoreCard'
|
||||
@@ -94,7 +91,7 @@ const applyPendingArchivedState = async chores => {
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const queryClient = useQueryClient()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
@@ -201,11 +198,11 @@ const ArchivedTasks = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
activeFilters,
|
||||
clearAll,
|
||||
filteredData: finalChores,
|
||||
hasActiveFilters,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
hasActiveFilters,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -254,6 +251,13 @@ const ArchivedTasks = () => {
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + F to focus search input
|
||||
if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
searchInputRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl/Cmd + S Toggle Multi-select mode
|
||||
if (isHoldingCmdOrCtrl && event.key === 's') {
|
||||
event.preventDefault()
|
||||
@@ -1000,37 +1004,45 @@ const ArchivedTasks = () => {
|
||||
|
||||
{/* Content */}
|
||||
{finalChores.length === 0 ? (
|
||||
searchTerm || hasActiveFilters ? (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No archived tasks match'
|
||||
description={
|
||||
searchTerm
|
||||
? `Nothing in the archive matches "${searchTerm}".`
|
||||
: 'There are archived tasks, but none fit the filters that are currently on.'
|
||||
}
|
||||
primaryAction={
|
||||
searchTerm
|
||||
? { label: 'Clear search', onClick: handleSearchClose }
|
||||
: { label: 'Clear filters', onClick: clearAll }
|
||||
}
|
||||
secondaryAction={
|
||||
searchTerm && hasActiveFilters
|
||||
? { label: 'Clear filters', onClick: clearAll }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Archive />}
|
||||
title='Nothing archived'
|
||||
description='Archiving hides a task without deleting it. Anything you archive from your task list shows up here, ready to restore.'
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
/>
|
||||
)
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' gutterBottom>
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'No archived tasks found'
|
||||
: 'No archived tasks'}
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
{searchTerm || hasActiveFilters
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Archived tasks will appear here when you archive them from the main task list'}
|
||||
</Typography>
|
||||
{(searchTerm || hasActiveFilters) && (
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
{searchTerm && (
|
||||
<Button
|
||||
onClick={handleSearchClose}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Clear search
|
||||
</Button>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<Button onClick={clearAll} variant='outlined' color='neutral'>
|
||||
Clear filters
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Box>
|
||||
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Repeat,
|
||||
ThumbDown,
|
||||
Schedule,
|
||||
ThumbUp,
|
||||
TimesOneMobiledata,
|
||||
Toll,
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
@@ -38,18 +37,18 @@ import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
const ChoreCard = ({
|
||||
chore,
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onAction,
|
||||
onChipClick,
|
||||
onSelectionToggle,
|
||||
performers,
|
||||
// Multi-select props
|
||||
showActions = true,
|
||||
sx,
|
||||
viewOnly,
|
||||
showActions = true,
|
||||
onChipClick,
|
||||
onAction,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onSelectionToggle,
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
@@ -360,6 +359,27 @@ const ChoreCard = ({
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 0.75,
|
||||
py: 0.5,
|
||||
minHeight: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Schedule sx={{ fontSize: 16 }} />
|
||||
<Typography level='body-xs'>Pending</Typography>
|
||||
</Chip>
|
||||
)}
|
||||
{showActions && (
|
||||
<Box
|
||||
display='flex'
|
||||
@@ -380,10 +400,8 @@ const ChoreCard = ({
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
minWidth: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
@@ -400,31 +418,29 @@ const ChoreCard = ({
|
||||
>
|
||||
<ThumbUp sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAction('reject', chore)
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
minWidth: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThumbDown sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{/* <IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAction('reject', chore)
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThumbDown sx={{ fontSize: 18 }} />
|
||||
</IconButton> */}
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton
|
||||
@@ -433,10 +449,8 @@ const ChoreCard = ({
|
||||
disabled={true}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
minWidth: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
opacity: 0.5,
|
||||
}}
|
||||
@@ -467,10 +481,8 @@ const ChoreCard = ({
|
||||
disabled={notInCompletionWindow(chore)}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
minWidth: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
@@ -497,7 +509,6 @@ const ChoreCard = ({
|
||||
</IconButton>
|
||||
)}
|
||||
<ChoreActionMenu
|
||||
variant='plain'
|
||||
chore={chore}
|
||||
onCompleteWithNote={() =>
|
||||
onAction('completeWithNote', chore)
|
||||
@@ -513,16 +524,6 @@ const ChoreCard = ({
|
||||
onWriteNFC={() => onAction('writeNFC', chore)}
|
||||
onNudge={() => onAction('nudge', chore)}
|
||||
onDelete={() => onAction('delete', chore)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'text.tertiary',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
color: 'text.secondary',
|
||||
bgcolor: 'background.level1',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -49,23 +49,6 @@ const getTimeFromTemplate = (template, relativeTime) => {
|
||||
}
|
||||
return time
|
||||
}
|
||||
// Decide whether this device's user should be notified about a chore:
|
||||
// - assignedTo set -> only that user
|
||||
// - no assignedTo -> everyone listed in assignees
|
||||
// - no assignees -> "Anyone" mode, notify the whole circle
|
||||
const shouldNotifyUser = (chore, userId) => {
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
if (chore.assignedTo > 0) {
|
||||
return chore.assignedTo === userId
|
||||
}
|
||||
if (chore.assignees?.length > 0) {
|
||||
return chore.assignees.some(assignee => assignee.userId === userId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const scheduleNotificationFromTemplate = (
|
||||
chore,
|
||||
userProfile,
|
||||
@@ -210,8 +193,7 @@ const scheduleChoreNotification = async (
|
||||
if (
|
||||
chore.notification === false ||
|
||||
chore.nextDueDate === null ||
|
||||
chore.isActive === false ||
|
||||
!shouldNotifyUser(chore, userProfile?.id)
|
||||
chore.isActive === false
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -2,60 +2,57 @@ import {
|
||||
Add,
|
||||
Bolt,
|
||||
CalendarMonth,
|
||||
CloudOff,
|
||||
EditCalendar,
|
||||
ExpandCircleDown,
|
||||
PriorityHigh,
|
||||
SearchOff,
|
||||
Style,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
AccordionGroup,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Container,
|
||||
Divider,
|
||||
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 { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
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'
|
||||
@@ -77,7 +74,7 @@ const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
const { showError, showSuccess, showUndo, showWarning } = useNotification()
|
||||
const { showSuccess, showError, showWarning, showUndo } = useNotification()
|
||||
const queryClient = useQueryClient()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const Navigate = useNavigate()
|
||||
@@ -86,19 +83,20 @@ const MyChores = () => {
|
||||
const { data: projects = [], isLoading: projectsLoading } = useProjects()
|
||||
const {
|
||||
data: choresData,
|
||||
error: choresErrorDetails,
|
||||
isError: choresError,
|
||||
isLoading: choresLoading,
|
||||
isError: choresError,
|
||||
error: choresErrorDetails,
|
||||
refetch: refetchChores,
|
||||
} = useChores(false)
|
||||
const {
|
||||
data: membersData,
|
||||
isError: membersError,
|
||||
isLoading: membersLoading,
|
||||
isError: membersError,
|
||||
} = 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)
|
||||
@@ -115,9 +113,6 @@ 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 +121,15 @@ const MyChores = () => {
|
||||
const menuRef = useRef(null)
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects, !projectsLoading)
|
||||
|
||||
const {
|
||||
nonProjectFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
searchTerm,
|
||||
selectedChoreFilter,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
setSearchTerm,
|
||||
setSelectedChoreFilterWithCache,
|
||||
} = useChoreFilters({
|
||||
@@ -145,37 +140,37 @@ const MyChores = () => {
|
||||
})
|
||||
|
||||
const {
|
||||
clearSelection,
|
||||
enterMultiSelectWithChore,
|
||||
getSelectedChoresData,
|
||||
isMultiSelectMode,
|
||||
selectAllVisibleChores,
|
||||
selectedChores,
|
||||
toggleChoreSelection,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
getSelectedChoresData,
|
||||
} = useMultiSelect()
|
||||
|
||||
const { activeModal, closeModal, modalChore, modalData, openModal } =
|
||||
const { activeModal, modalChore, modalData, openModal, closeModal } =
|
||||
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,
|
||||
@@ -218,7 +213,8 @@ const MyChores = () => {
|
||||
)
|
||||
case 'Due Later':
|
||||
return (
|
||||
d !== null && d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
d !== null &&
|
||||
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
|
||||
)
|
||||
case 'No Due Date':
|
||||
return item.nextDueDate === null
|
||||
@@ -276,10 +272,10 @@ const MyChores = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
clearAll: clearQuickFilters,
|
||||
filteredData: quickFilteredChores,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
@@ -301,7 +297,7 @@ const MyChores = () => {
|
||||
return sortedChores
|
||||
}, [choresData?.res, impersonatedUser])
|
||||
|
||||
const choreSections = useMemo(() => {
|
||||
const processedSections = useMemo(() => {
|
||||
if (!chores.length || !userProfile?.id) {
|
||||
return []
|
||||
}
|
||||
@@ -372,6 +368,7 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
processEffectAsync()
|
||||
// throw new Error('Fake Error to test posthog')
|
||||
}
|
||||
}, [
|
||||
membersLoading,
|
||||
@@ -384,16 +381,26 @@ const MyChores = () => {
|
||||
impersonatedUser?.userId,
|
||||
])
|
||||
|
||||
// Auto-update sections when processedSections changes
|
||||
useEffect(() => {
|
||||
if (openSectionsInitializedRef.current || choreSections.length === 0) return
|
||||
// Always update choreSections to match processedSections, even if empty
|
||||
setChoreSections(processedSections)
|
||||
|
||||
openSectionsInitializedRef.current = true
|
||||
const openSections = choreSections.reduce((acc, _section, index) => {
|
||||
acc[index] = true
|
||||
return acc
|
||||
}, {})
|
||||
setOpenChoreSections(openSections)
|
||||
}, [choreSections])
|
||||
// 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])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
@@ -411,29 +418,14 @@ const MyChores = () => {
|
||||
}
|
||||
}, [searchInputFocus])
|
||||
|
||||
// A global-search result can hand a query back to the task list as a scoped filter.
|
||||
useEffect(() => {
|
||||
const query = searchParams.get('search')
|
||||
if (query !== null) {
|
||||
setSearchTerm(query.toLowerCase())
|
||||
setSelectedCalendarDate(null)
|
||||
clearActiveFilter()
|
||||
clearQuickFilters()
|
||||
}
|
||||
// The setters above are intentionally applied only when URL search params change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [searchParams])
|
||||
|
||||
// Read and apply project from URL parameters
|
||||
useEffect(() => {
|
||||
if (!projects.length) return
|
||||
|
||||
const projectIdFromUrl = searchParams.get('project')
|
||||
|
||||
if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
|
||||
const project = projectsWithDefault.find(
|
||||
p => String(p.id) === projectIdFromUrl,
|
||||
)
|
||||
if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
|
||||
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
|
||||
if (project) {
|
||||
setSelectedProjectWithCache(project)
|
||||
}
|
||||
@@ -571,17 +563,17 @@ const MyChores = () => {
|
||||
}, [tempFilterMeta?.id, searchParams])
|
||||
|
||||
const {
|
||||
handleAssigneeChange,
|
||||
handleBulkArchive,
|
||||
handleBulkComplete,
|
||||
handleBulkDelete,
|
||||
handleBulkMoveToProject,
|
||||
handleBulkSkip,
|
||||
handleChangeDueDate,
|
||||
handleChoreAction,
|
||||
handleCompleteWithNote,
|
||||
handleChangeDueDate,
|
||||
handleCompleteWithPastDate,
|
||||
handleAssigneeChange,
|
||||
handleCompleteWithNote,
|
||||
handleNudge,
|
||||
handleBulkComplete,
|
||||
handleBulkArchive,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
} = useChoreActions({
|
||||
chores,
|
||||
filteredChores,
|
||||
@@ -607,14 +599,24 @@ 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,
|
||||
@@ -623,7 +625,6 @@ const MyChores = () => {
|
||||
hasQuickFilters,
|
||||
quickFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
searchTerm,
|
||||
])
|
||||
|
||||
@@ -632,8 +633,7 @@ const MyChores = () => {
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter:
|
||||
hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
|
||||
filteredChores: getFilteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
@@ -683,46 +683,14 @@ const MyChores = () => {
|
||||
setAnchorEl(null)
|
||||
}
|
||||
|
||||
// Clicking a label / priority chip on a task card feeds the advanced filter
|
||||
// (as a temp filter) rather than the legacy quick filters. Clicking the same
|
||||
// chip again removes that value, so chips toggle.
|
||||
const handleLabelFiltering = chipClicked => {
|
||||
const type = chipClicked.label ? 'label' : 'priority'
|
||||
const value = chipClicked.label
|
||||
? chipClicked.label.id
|
||||
: chipClicked.priority
|
||||
if (value === undefined || value === null) return
|
||||
|
||||
const selections = conditionsToSelections(tempFilter?.conditions)
|
||||
const currentValues = selections[type].values || []
|
||||
const isActive = currentValues.some(v => String(v) === String(value))
|
||||
selections[type] = {
|
||||
operator: selections[type].operator || 'is',
|
||||
values: isActive
|
||||
? currentValues.filter(v => String(v) !== String(value))
|
||||
: [...currentValues, value],
|
||||
clearActiveFilter()
|
||||
if (chipClicked.label) {
|
||||
setQuickFilter('label', [chipClicked.label.id])
|
||||
} else if (chipClicked.priority) {
|
||||
setQuickFilter('priority', [chipClicked.priority])
|
||||
}
|
||||
|
||||
const conditions = selectionsToConditions(selections)
|
||||
|
||||
clearQuickFilters()
|
||||
setSelectedCalendarDate(null)
|
||||
|
||||
if (conditions.length === 0) {
|
||||
clearTempFilter()
|
||||
clearActiveFilter()
|
||||
return
|
||||
}
|
||||
|
||||
const chipName = chipClicked.label
|
||||
? chipClicked.label.name
|
||||
: `P${chipClicked.priority}`
|
||||
applyTempFilter(
|
||||
{ conditions, operator: 'AND' },
|
||||
// Keep whatever the temp filter was already labelled as (e.g. an
|
||||
// in-progress saved-filter edit); only name it when starting fresh.
|
||||
tempFilterMeta ?? { name: chipName },
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to update URL with filter parameters
|
||||
@@ -746,10 +714,29 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const clearTempFilterAndUrl = () => {
|
||||
clearTempFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
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 handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
@@ -766,6 +753,22 @@ 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)
|
||||
}
|
||||
@@ -774,11 +777,6 @@ const MyChores = () => {
|
||||
setFilteredChores(selectedProject ? projectFilteredChores : chores)
|
||||
setSearchInputFocus(0)
|
||||
setSelectedCalendarDate(null)
|
||||
if (searchParams.has('search')) {
|
||||
const params = new URLSearchParams(searchParams)
|
||||
params.delete('search')
|
||||
setSearchParams(params, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const setSelectedChoreSectionWithCache = value => {
|
||||
@@ -792,12 +790,10 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
const toggleViewMode = value => {
|
||||
const newMode =
|
||||
value ??
|
||||
(() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
const newMode = value ?? (() => {
|
||||
const modes = ['default', 'compact', 'calendar']
|
||||
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
|
||||
})()
|
||||
setViewMode(newMode)
|
||||
localStorage.setItem('choreCardViewMode', newMode)
|
||||
if (newMode !== 'calendar') {
|
||||
@@ -860,32 +856,18 @@ const MyChores = () => {
|
||||
// )
|
||||
// }
|
||||
|
||||
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
|
||||
// project is an empty place, not a filtered-away result.
|
||||
const isNarrowed = Boolean(
|
||||
searchTerm?.length > 0 || hasQuickFilters || activeFilterId,
|
||||
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 isCustomProjectSelected = Boolean(
|
||||
selectedProject && selectedProject.id !== 'default',
|
||||
)
|
||||
|
||||
const clearNarrowing = () => {
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
|
||||
const appendChore = (prev, newChore) => {
|
||||
let newChores = [...prev, newChore]
|
||||
@@ -912,23 +894,40 @@ const MyChores = () => {
|
||||
if (choresError || membersError) {
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<EmptyState
|
||||
variant='error'
|
||||
fullHeight
|
||||
icon={<CloudOff />}
|
||||
title={"Can't reach Donetick"}
|
||||
description={
|
||||
choresErrorDetails?.message ||
|
||||
'Your tasks are safe. We just could not load them right now, check your connection and try again.'
|
||||
}
|
||||
primaryAction={{
|
||||
label: 'Try again',
|
||||
onClick: () => {
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '70vh',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2, opacity: 0.7 }}>
|
||||
<Logo />
|
||||
</Box>
|
||||
<Typography level='h4' color='danger'>
|
||||
Unable to communicate with server
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{ textAlign: 'center', maxWidth: 400 }}
|
||||
>
|
||||
{choresErrorDetails?.message ||
|
||||
'The server is currently unavailable. Please check your connection and try again.'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
onClick={() => {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['circleMembers'])
|
||||
},
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -970,7 +969,7 @@ const MyChores = () => {
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilterAndUrl}
|
||||
clearTempFilter={clearTempFilter}
|
||||
saveFilter={saveFilter}
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
@@ -1083,79 +1082,50 @@ const MyChores = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Empty state. Three different situations, three different messages:
|
||||
nothing created yet, nothing left after narrowing, or an empty
|
||||
project. Only the middle one is about filters. */}
|
||||
{(isNarrowed
|
||||
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
|
||||
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
|
||||
? getFilteredChores.length === 0
|
||||
: projectFilteredChores.length === 0) &&
|
||||
// only if not in calendar view:
|
||||
viewMode !== 'calendar' &&
|
||||
(chores.length === 0 ? (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title='No tasks yet'
|
||||
description='Create your first task and Donetick keeps track of when it is due, whose turn it is, and what comes next.'
|
||||
primaryAction={{
|
||||
label: 'Create a task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
viewMode !== 'calendar' && (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: 'More options',
|
||||
onClick: () => Navigate('/chores/create'),
|
||||
}}
|
||||
/>
|
||||
) : isNarrowed ? (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
fullHeight
|
||||
icon={<SearchOff />}
|
||||
title='No tasks match this view'
|
||||
description={
|
||||
searchTerm?.length > 0
|
||||
? `Nothing matches "${searchTerm}". Try a different search, or clear what is narrowing the list.`
|
||||
: 'You have tasks, but none of them fit the filters that are currently on.'
|
||||
}
|
||||
primaryAction={{
|
||||
label:
|
||||
searchTerm?.length > 0 ? 'Clear search' : 'Clear filters',
|
||||
onClick: clearNarrowing,
|
||||
}}
|
||||
/>
|
||||
) : isCustomProjectSelected ? (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title={`Nothing in ${selectedProject.name} yet`}
|
||||
description='Tasks you add to this project show up here. Your other tasks are still where you left them.'
|
||||
primaryAction={{
|
||||
label: 'Add a task here',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
secondaryAction={{
|
||||
label: 'See tasks outside projects',
|
||||
onClick: () => setSelectedProjectWithCache(null),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
variant='empty'
|
||||
fullHeight
|
||||
icon={<EditCalendar />}
|
||||
title='No tasks here yet'
|
||||
description='Tasks that do not belong to a project live here. Add one, or switch projects to see what is in them.'
|
||||
primaryAction={{
|
||||
label: 'Create a task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
>
|
||||
<EditCalendar
|
||||
sx={{
|
||||
fontSize: '4rem',
|
||||
// color: 'text.disabled',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
Nothing scheduled
|
||||
</Typography>
|
||||
{chores.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
clearQuickFilters()
|
||||
setSearchTerm('')
|
||||
clearActiveFilter()
|
||||
setSelectedProjectWithCache(null)
|
||||
updateFilterUrl(null, null)
|
||||
}}
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
>
|
||||
Reset filters
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||
<ChoreListView
|
||||
chores={getFilteredChores}
|
||||
@@ -1323,21 +1293,20 @@ const MyChores = () => {
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{selectedDateChores.length === 0 ? (
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<EditCalendar />}
|
||||
title='Nothing scheduled'
|
||||
description='This day is free. Add a task if you want something to land here.'
|
||||
primaryAction={{
|
||||
label: 'Add task',
|
||||
startDecorator: <Add />,
|
||||
onClick: () => setAddTaskModalOpen(true),
|
||||
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 2,
|
||||
color: 'text.tertiary',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
No tasks scheduled for this date
|
||||
</Typography>
|
||||
) : (
|
||||
<ChoreListView
|
||||
chores={selectedDateChores}
|
||||
chores={getChoresForDate(selectedCalendarDate)}
|
||||
viewMode={'compact'}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
@@ -1417,20 +1386,18 @@ const MyChores = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{openChoreSections[index] && (
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
@@ -1536,7 +1503,7 @@ const MyChores = () => {
|
||||
allChores={chores}
|
||||
performers={membersData?.res || []}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilterAndUrl}
|
||||
clearTempFilter={clearTempFilter}
|
||||
tempFilter={tempFilter}
|
||||
/>
|
||||
|
||||
|
||||
@@ -17,16 +17,9 @@ const NotificationAccessSnackbar = () => {
|
||||
useEffect(() => {
|
||||
// Only run the effect on native platforms
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
getNotificationPreferences().then(async data => {
|
||||
getNotificationPreferences().then(data => {
|
||||
// if optOut is true then don't show the snackbar
|
||||
if (data?.optOut === true || data?.granted === true) {
|
||||
// Onboarding (and the system settings screen) can grant permission
|
||||
// while no session exists, so the push token still needs registering.
|
||||
if (data?.granted === true) {
|
||||
await registerPushNotifications().catch(error =>
|
||||
console.error('Error registering push notifications:', error),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
setOpen(true)
|
||||
@@ -76,7 +69,7 @@ const NotificationAccessSnackbar = () => {
|
||||
} catch (error) {
|
||||
console.error('Error setting up notifications:', error)
|
||||
}
|
||||
|
||||
|
||||
await Preferences.set({
|
||||
key: 'notificationPreferences',
|
||||
value: JSON.stringify(notificationPreferences),
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { BarChart, Person } from '@mui/icons-material'
|
||||
import { Avatar, Box, Sheet, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
@@ -128,13 +127,10 @@ const TasksByAssigneeCard = ({ chores = [] }) => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
size='sm'
|
||||
icon={<Person />}
|
||||
title='No one has tasks yet'
|
||||
description='Assign a task to someone in your circle and their workload shows up here.'
|
||||
/>
|
||||
<Person sx={{ fontSize: 48, opacity: 0.3, mb: 1 }} />
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
No assigned tasks found
|
||||
</Typography>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,6 +317,27 @@ const ChoreToolbar = ({
|
||||
return `${prefix}${typeLabel} (${rawValues.length})`
|
||||
}
|
||||
|
||||
const clearConditionAtIndex = index => {
|
||||
const nextConditions = (tempFilter?.conditions || []).filter(
|
||||
(_condition, conditionIndex) => conditionIndex !== index,
|
||||
)
|
||||
|
||||
if (nextConditions.length === 0) {
|
||||
setLocalSelections(defaultSelections())
|
||||
clearTempFilter?.()
|
||||
return
|
||||
}
|
||||
|
||||
const nextFilter = {
|
||||
...tempFilter,
|
||||
operator: tempFilter?.operator || 'AND',
|
||||
conditions: nextConditions,
|
||||
}
|
||||
|
||||
setLocalSelections(conditionsToSelections(nextConditions))
|
||||
applyTempFilter?.(nextFilter)
|
||||
}
|
||||
|
||||
const activeSavedFilter = savedFilterActive
|
||||
? savedFilters.find(f => f.id === activeFilterId)
|
||||
: null
|
||||
@@ -325,61 +346,17 @@ const ChoreToolbar = ({
|
||||
? activeSavedFilter?.conditions || []
|
||||
: tempFilter?.conditions || []
|
||||
|
||||
const savedFilterEditMeta = activeSavedFilter
|
||||
? {
|
||||
name: activeSavedFilter.name,
|
||||
description: activeSavedFilter.description,
|
||||
sourceFilterId: activeSavedFilter.id,
|
||||
sourceFilterName: activeSavedFilter.name,
|
||||
sourceFilterDescription: activeSavedFilter.description,
|
||||
sourceFilterColor: activeSavedFilter.color,
|
||||
isEditingSavedFilter: true,
|
||||
}
|
||||
: null
|
||||
|
||||
const clearConditionAtIndex = index => {
|
||||
const nextConditions = activeChipConditions.filter(
|
||||
(_condition, conditionIndex) => conditionIndex !== index,
|
||||
)
|
||||
|
||||
if (nextConditions.length === 0) {
|
||||
setLocalSelections(defaultSelections())
|
||||
clearTempFilter?.()
|
||||
// A saved filter still owns the view until it's toggled back off.
|
||||
if (savedFilterActive) onSavedFilterClick?.(activeFilterId)
|
||||
return
|
||||
}
|
||||
|
||||
setLocalSelections(conditionsToSelections(nextConditions))
|
||||
|
||||
// Dropping a condition from a saved filter detaches it into a temp filter
|
||||
// that remembers its source, rather than editing the saved filter itself.
|
||||
if (savedFilterActive) {
|
||||
applyTempFilter?.(
|
||||
{
|
||||
conditions: nextConditions,
|
||||
operator: activeSavedFilter?.operator || 'AND',
|
||||
},
|
||||
savedFilterEditMeta,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
applyTempFilter?.(
|
||||
{
|
||||
...tempFilter,
|
||||
operator: tempFilter?.operator || 'AND',
|
||||
conditions: nextConditions,
|
||||
},
|
||||
tempFilterMeta,
|
||||
)
|
||||
}
|
||||
|
||||
activeChipConditions.forEach((condition, index) => {
|
||||
inlineChips.push({
|
||||
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
|
||||
label: getConditionChipLabel(condition),
|
||||
onClear: () => clearConditionAtIndex(index),
|
||||
onClear: () => {
|
||||
if (savedFilterActive) {
|
||||
onSavedFilterClick?.(activeFilterId)
|
||||
return
|
||||
}
|
||||
clearConditionAtIndex(index)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -661,8 +638,6 @@ const ChoreToolbar = ({
|
||||
<ActiveFilterChips
|
||||
chips={inlineChips}
|
||||
onOpen={openFilterSheet}
|
||||
showAddChip
|
||||
onAdd={openFilterSheet}
|
||||
onClearAll={() => {
|
||||
setLocalSelections(defaultSelections())
|
||||
onClearAllFilters?.()
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import { CancelRounded, SearchRounded } from '@mui/icons-material'
|
||||
import { CancelRounded } from '@mui/icons-material'
|
||||
import { Box, Input } from '@mui/joy'
|
||||
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { useGlobalSearch } from '../../../search/GlobalSearchContext'
|
||||
|
||||
const SearchBar = ({
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
onFocus,
|
||||
showKeyboardShortcuts,
|
||||
value,
|
||||
inputRef,
|
||||
}) => {
|
||||
const { openSearch } = useGlobalSearch()
|
||||
const handleOpen = () => {
|
||||
onFocus?.()
|
||||
openSearch(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
slotProps={{ input: { ref: inputRef, readOnly: true } }}
|
||||
placeholder='Search Donetick'
|
||||
slotProps={{ input: { ref: inputRef } }}
|
||||
placeholder='Search'
|
||||
value={value}
|
||||
onFocus={handleOpen}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
handleOpen()
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
fullWidth
|
||||
sx={{
|
||||
mt: 1,
|
||||
@@ -36,29 +24,17 @@ const SearchBar = ({
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
cursor: 'pointer',
|
||||
'& input': { cursor: 'pointer' },
|
||||
}}
|
||||
onChange={onChange}
|
||||
startDecorator={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
</Box>
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{value && (
|
||||
<>
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
<CancelRounded
|
||||
aria-label='Clear task search'
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
<CancelRounded onClick={onClose} />
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -22,36 +22,23 @@ 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 (searchIndex) {
|
||||
return searchIndex
|
||||
.search(searchTerm.toLowerCase())
|
||||
.map(result => result.item)
|
||||
.filter(chore => projectChoreIds.has(chore.id))
|
||||
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 (impersonatedUser) {
|
||||
@@ -68,8 +55,6 @@ export const useChoreFilters = ({
|
||||
}, [
|
||||
searchTerm,
|
||||
projectFilteredChores,
|
||||
searchIndex,
|
||||
projectChoreIds,
|
||||
impersonatedUser,
|
||||
userProfile?.id,
|
||||
selectedChoreFilter,
|
||||
@@ -79,10 +64,20 @@ export const useChoreFilters = ({
|
||||
const nonProjectFilteredChores = useMemo(() => {
|
||||
let baseChores = chores
|
||||
|
||||
if (searchIndex) {
|
||||
return searchIndex
|
||||
.search(searchTerm.toLowerCase())
|
||||
.map(result => result.item)
|
||||
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 (impersonatedUser) {
|
||||
@@ -99,7 +94,6 @@ export const useChoreFilters = ({
|
||||
}, [
|
||||
searchTerm,
|
||||
chores,
|
||||
searchIndex,
|
||||
impersonatedUser,
|
||||
userProfile?.id,
|
||||
selectedChoreFilter,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { useUserProfile } from '../../../queries/UserQueries'
|
||||
import { applyFilter, validateFilter } from '../../../utils/FilterEngine'
|
||||
import {
|
||||
applyFilter,
|
||||
getFilterCount,
|
||||
getFilterOverdueCount,
|
||||
validateFilter,
|
||||
} from '../../../utils/FilterEngine'
|
||||
import {
|
||||
useCreateFilter,
|
||||
useDeleteFilter,
|
||||
@@ -39,17 +43,12 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
|
||||
return filtersData.map(filter => {
|
||||
const validation = validateFilter(filter, context)
|
||||
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 count = validation.isValid
|
||||
? getFilterCount(chores, filter, context)
|
||||
: 0
|
||||
const overdueCount = validation.isValid
|
||||
? getFilterOverdueCount(chores, filter, context)
|
||||
: 0
|
||||
|
||||
const result = {
|
||||
...filter,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export const useKeyboardShortcuts = ({
|
||||
addTaskModalOpen,
|
||||
choreSections,
|
||||
filteredChores,
|
||||
handlers,
|
||||
isMultiSelectMode,
|
||||
openChoreSections,
|
||||
searchFilter,
|
||||
searchTerm,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
handlers,
|
||||
}) => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
@@ -35,6 +35,10 @@ export const useKeyboardShortcuts = ({
|
||||
event.preventDefault()
|
||||
handlers.onNavigateToCreate()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
|
||||
event.preventDefault()
|
||||
handlers.onFocusSearch()
|
||||
return
|
||||
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
|
||||
event.preventDefault()
|
||||
if (searchTerm?.length > 0) {
|
||||
|
||||
@@ -1,260 +1,162 @@
|
||||
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { JoinCircle } from '../../utils/Fetcher'
|
||||
import { clearPendingInvite, setPendingInvite } from '../../utils/PendingInvite'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
import { CircleVignette } from '../Onboarding/OnboardingVignettes'
|
||||
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
const enter = (delay = 0) => ({
|
||||
animation: `joinCircleIn 520ms ${EASE} ${delay}ms both`,
|
||||
'@keyframes joinCircleIn': {
|
||||
from: { opacity: 0, transform: 'translateY(12px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
})
|
||||
|
||||
const JoinCircleView = () => {
|
||||
const { data: userProfile, isLoading: isProfileLoading } = useUserProfile()
|
||||
// Read the token rather than useAuth(): the provider's copy only updates
|
||||
// through its own login(), so signup and the OAuth callback — which save
|
||||
// tokens directly — would still look signed out here. The query hooks read
|
||||
// storage the same way.
|
||||
const isAuthenticated = !!localStorage.getItem('token')
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { showError } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
let [searchParams, setSearchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
const code = searchParams.get('code')
|
||||
// `auto=1` is on the link we send the user back to after they authenticate,
|
||||
// and only there — someone who opens an invite while already signed in gets
|
||||
// asked, not auto-joined.
|
||||
const isReturningFromAuth = searchParams.get('auto') === '1'
|
||||
const autoJoinAttempted = useRef(false)
|
||||
|
||||
const submitJoin = useCallback(() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code)
|
||||
.then(resp => {
|
||||
clearPendingInvite()
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your request has been sent. A circle admin will need to approve ' +
|
||||
"it before you can access the circle and its chores. We'll " +
|
||||
"notify you when it's approved.",
|
||||
'Request sent',
|
||||
() => navigate('/chores'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/chores')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setIsJoining(false)
|
||||
clearPendingInvite()
|
||||
showError('Could not send your join request. Please try again.')
|
||||
})
|
||||
}, [code, navigate, showAcknowledgment, showError])
|
||||
|
||||
// Coming back from login/signup the user already said yes by opening the
|
||||
// link, so send the request instead of asking a second time. This step used
|
||||
// to be missing entirely: the login page was a dead end.
|
||||
useEffect(() => {
|
||||
if (autoJoinAttempted.current) return
|
||||
if (!code || !isReturningFromAuth) return
|
||||
if (!isAuthenticated || !userProfile) return
|
||||
|
||||
autoJoinAttempted.current = true
|
||||
submitJoin()
|
||||
}, [code, isReturningFromAuth, isAuthenticated, userProfile, submitJoin])
|
||||
|
||||
// Park the code so it survives the round-trip, including OAuth flows that
|
||||
// leave the app entirely.
|
||||
const goToAuth = destination => {
|
||||
setPendingInvite(code)
|
||||
navigate(destination)
|
||||
}
|
||||
|
||||
const inviteCodeField = (
|
||||
<Input
|
||||
value={code || ''}
|
||||
readOnly
|
||||
size='lg'
|
||||
slotProps={{ input: { style: { textAlign: 'center', fontWeight: 600 } } }}
|
||||
/>
|
||||
)
|
||||
|
||||
let title = "You're invited to join a circle"
|
||||
let subtitle = null
|
||||
let body = null
|
||||
|
||||
if (!code) {
|
||||
title = 'Invite link is incomplete'
|
||||
subtitle =
|
||||
'This invite link is missing a code. Ask the person who invited you to send a new link.'
|
||||
body = (
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => navigate('/chores')}
|
||||
>
|
||||
Go to Donetick
|
||||
</Button>
|
||||
)
|
||||
// A token that no longer resolves to a profile is as good as signed out —
|
||||
// better to offer sign-in than to spin forever.
|
||||
} else if (!isAuthenticated || (!isProfileLoading && !userProfile)) {
|
||||
subtitle =
|
||||
"Sign in or create a Donetick account to continue. We'll send your join request once you're signed in."
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
{inviteCodeField}
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/login')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => goToAuth('/signup')}
|
||||
>
|
||||
Create an account
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
} else if (isProfileLoading || isJoining) {
|
||||
title = 'Sending your request'
|
||||
subtitle = 'Sending your request…'
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)
|
||||
} else {
|
||||
subtitle =
|
||||
`Hi ${userProfile?.displayName || userProfile?.username}. ` +
|
||||
"Send a request to share this circle's chores with its members."
|
||||
body = (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ textAlign: 'center', color: 'text.secondary' }}
|
||||
>
|
||||
A circle admin will review your request before you get access.
|
||||
</Typography>
|
||||
<Button fullWidth size='lg' sx={authButtonSx} onClick={submitJoin}>
|
||||
Send join request
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={authButtonSx}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
<Container
|
||||
component='main'
|
||||
sx={{
|
||||
minHeight: 'calc(100dvh - var(--safe-area-inset-top, 0px))',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
px: 3,
|
||||
pb: 'calc(var(--safe-area-inset-bottom, 0px) + 24px)',
|
||||
bgcolor: 'background.body',
|
||||
}}
|
||||
maxWidth='xs'
|
||||
|
||||
// make content center in the middle of the page:
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
my: 'auto',
|
||||
marginTop: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ mb: 2, ...enter(0) }}>
|
||||
<CircleVignette />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
<Sheet
|
||||
component='form'
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
textAlign: 'center',
|
||||
gap: 1.5,
|
||||
mb: 4,
|
||||
...enter(60),
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h1'
|
||||
sx={{
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
textWrap: 'balance',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
{subtitle && (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
maxWidth: '34ch',
|
||||
textWrap: 'pretty',
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
tick
|
||||
</span>
|
||||
</Typography>
|
||||
{code && userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Hi {userProfile?.displayName}, you have been invited to join the
|
||||
circle{' '}
|
||||
</Typography>
|
||||
<Input
|
||||
fullWidth
|
||||
placeholder='Enter code'
|
||||
value={code}
|
||||
disabled={!!code}
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
Joining will give you access to the circle's chores and members.
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You can leave the circle later from you Settings page.
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
disabled={isJoining}
|
||||
onClick={() => {
|
||||
setIsJoining(true)
|
||||
JoinCircle(code).then(resp => {
|
||||
if (resp.ok) {
|
||||
showAcknowledgment(
|
||||
'Your join request has been sent successfully! The circle admin will need to approve your request before you can access the circle and its chores. You will receive a notification once your request is approved.',
|
||||
'Join Request Sent!',
|
||||
() => navigate('/'),
|
||||
'Got it',
|
||||
'success',
|
||||
)
|
||||
} else {
|
||||
setIsJoining(false)
|
||||
if (resp.status === 409) {
|
||||
showError('You are already a member of this circle')
|
||||
} else {
|
||||
showError('Failed to join circle')
|
||||
}
|
||||
navigate('/')
|
||||
}
|
||||
})
|
||||
}}
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join Circle'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
q
|
||||
variant='plain'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
navigate('/chores')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ ...enter(120) }}>{body}</Box>
|
||||
{!code ||
|
||||
(!userProfile && (
|
||||
<>
|
||||
<Typography level='body-md' alignSelf={'center'}>
|
||||
You need to be logged in to join a circle
|
||||
</Typography>
|
||||
<Typography level='body-md' alignSelf={'center'} sx={{ mb: 9 }}>
|
||||
Login or sign up to continue
|
||||
</Typography>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
sx={{ mt: 3, mb: 2 }}
|
||||
onClick={() => {
|
||||
navigate('/login')
|
||||
}}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</>
|
||||
))}
|
||||
</Sheet>
|
||||
</Box>
|
||||
|
||||
<AcknowledgmentModal config={ackModalConfig} />
|
||||
</Box>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,15 +10,9 @@ import {
|
||||
SearchOffRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { 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 }
|
||||
@@ -54,30 +48,27 @@ 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 { Icon, color } = getErrorKind(error)
|
||||
const { color, Icon } = getErrorKind(error)
|
||||
const message = safeMessage(error)
|
||||
|
||||
// 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 url = window.location.href
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(reportText).then(() => {
|
||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
@@ -218,22 +209,9 @@ const Error = () => {
|
||||
size='lg'
|
||||
startDecorator={<RefreshRounded />}
|
||||
onClick={() => window.location.reload()}
|
||||
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
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions */}
|
||||
@@ -273,7 +251,16 @@ const Error = () => {
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||
>
|
||||
If this keeps happening, send us a report please consider sending us report so we can take a look.
|
||||
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.
|
||||
</Typography>
|
||||
|
||||
{(error?.stack || message) && (
|
||||
@@ -326,7 +313,7 @@ const Error = () => {
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{reportText || 'Collecting error details…'}
|
||||
{buildErrorText(error, url)}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -334,12 +321,6 @@ const Error = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ErrorReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2500}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
StarBorder,
|
||||
Task,
|
||||
} from '@mui/icons-material'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
|
||||
@@ -418,17 +417,29 @@ const FilterView = () => {
|
||||
}}
|
||||
>
|
||||
{savedFilters.length === 0 ? (
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<FilterAlt />}
|
||||
title='No saved filters yet'
|
||||
description='Save a set of conditions once, like "overdue and assigned to me", and jump straight back to it from anywhere.'
|
||||
primaryAction={{
|
||||
label: 'Create a filter',
|
||||
startDecorator: <Add />,
|
||||
onClick: handleAddFilter,
|
||||
<Box
|
||||
sx={{
|
||||
p: 4,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<FilterAlt
|
||||
sx={{
|
||||
fontSize: 48,
|
||||
color: 'neutral.300',
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
<Typography
|
||||
level='title-lg'
|
||||
sx={{ mb: 1, color: 'text.secondary' }}
|
||||
>
|
||||
No saved filters yet
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 2 }}>
|
||||
Create custom filters to quickly access your most used chore
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{savedFilters.map(filter => {
|
||||
|
||||
@@ -28,11 +28,10 @@ import {
|
||||
} from '@mui/icons-material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import { Box, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { Link, useParams } from 'react-router-dom'
|
||||
import FilterBar from '../../components/common/FilterBar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
@@ -304,14 +303,36 @@ const ChoreHistory = () => {
|
||||
}
|
||||
if (!choreHistory.length) {
|
||||
return (
|
||||
<Container maxWidth='md'>
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<EventBusy />}
|
||||
title='No history yet'
|
||||
description='Every time this task gets completed or skipped, it lands here with who did it and when. Nothing has happened yet.'
|
||||
primaryAction={{ label: 'Back to tasks', to: '/chores' }}
|
||||
<Container
|
||||
maxWidth='md'
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
// make sure the content is centered vertically:
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<EventBusy
|
||||
sx={{
|
||||
fontSize: '6rem',
|
||||
// color: 'text.disabled',
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography level='h3' gutterBottom>
|
||||
No History Yet
|
||||
</Typography>
|
||||
<Typography level='body1'>
|
||||
You haven't completed any tasks. Once you start finishing tasks,
|
||||
they'll show up here.
|
||||
</Typography>
|
||||
<Button variant='soft' sx={{ mt: 2 }}>
|
||||
<Link to='/chores'>Go back to chores</Link>
|
||||
</Button>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@@ -420,13 +441,27 @@ const ChoreHistory = () => {
|
||||
/>
|
||||
</Box>
|
||||
{sortedHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<EmptyState
|
||||
variant='no-results'
|
||||
icon={<FilterList />}
|
||||
title='No history matches these filters'
|
||||
description='There is history here, but none of it fits the filters that are currently on.'
|
||||
primaryAction={{ label: 'Clear filters', onClick: clearAll }}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
py: 6,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
}}
|
||||
>
|
||||
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
|
||||
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
|
||||
No results match your filters
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
|
||||
Try adjusting or clearing the active filters.
|
||||
</Typography>
|
||||
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sortedHistory.length > 0 && (
|
||||
|
||||
@@ -21,8 +21,7 @@ import {
|
||||
TrailingActions,
|
||||
} from '@meauxt/react-swipeable-list'
|
||||
import '@meauxt/react-swipeable-list/dist/styles.css'
|
||||
import { Add, MoreVert, Style } from '@mui/icons-material'
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import { Add, MoreVert } from '@mui/icons-material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors'
|
||||
@@ -259,17 +258,19 @@ const LabelView = () => {
|
||||
}}
|
||||
>
|
||||
{userLabels.length === 0 && (
|
||||
<EmptyState
|
||||
fullHeight
|
||||
icon={<Style />}
|
||||
title='No labels yet'
|
||||
description='Labels group tasks across your circle, like "kitchen" or "bills", so you can filter down to them in one tap.'
|
||||
primaryAction={{
|
||||
label: 'Create a label',
|
||||
startDecorator: <Add />,
|
||||
onClick: handleAddLabel,
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexDirection: 'column',
|
||||
height: '50vh',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<Typography level='title-md' gutterBottom>
|
||||
No labels available. Add a new label to get started.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{userLabels.map(label => (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Box, Card, Grid, List, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
|
||||
import HistoryCard from '../History/HistoryCard'
|
||||
|
||||
const DemoHistory = () => {
|
||||
@@ -8,32 +7,29 @@ const DemoHistory = () => {
|
||||
{
|
||||
id: 32,
|
||||
choreId: 12,
|
||||
performedAt: moment().hour(4).format(),
|
||||
completedAt: moment().hour(4).format(),
|
||||
completedBy: 1,
|
||||
assignedTo: 1,
|
||||
notes: null,
|
||||
dueDate: moment().format(),
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
choreId: 12,
|
||||
performedAt: moment().day(-1).format(),
|
||||
completedAt: 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,
|
||||
performedAt: moment().day(-10).hour(1).format(),
|
||||
completedAt: moment().day(-10).hour(1).format(),
|
||||
completedBy: 2,
|
||||
assignedTo: 1,
|
||||
notes: null,
|
||||
dueDate: moment().day(-10).format(),
|
||||
status: 1,
|
||||
},
|
||||
]
|
||||
const performers = [
|
||||
@@ -65,7 +61,6 @@ const DemoHistory = () => {
|
||||
key={index}
|
||||
index={index}
|
||||
performers={performers}
|
||||
pendingCommands={[]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user