Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c474041c7c | ||
|
|
c649e051d8 | ||
|
|
207888affe | ||
|
|
5f0c5cdc5d | ||
|
|
5b7594ac64 | ||
|
|
90a38bf091 | ||
|
|
eecfc98ebc | ||
|
|
6428239be2 | ||
|
|
4c6e244069 | ||
|
|
af926b033e | ||
|
|
b7bdfa7885 | ||
|
|
309fd32d50 | ||
|
|
2442b0c441 | ||
|
|
5d8095bd9c | ||
|
|
1a8aeb84cc | ||
|
|
b2529a0091 | ||
|
|
841f83d6ae | ||
|
|
731c46b26a | ||
|
|
612924e7c3 | ||
|
|
78760ceebb | ||
|
|
e05aa2d169 | ||
|
|
1ddf2539e0 | ||
|
|
83a5760959 | ||
|
|
0215a78404 | ||
|
|
475806ac29 | ||
|
|
903d022877 | ||
|
|
5fb0b5b656 | ||
|
|
0b4b21bbb0 | ||
|
|
555c9d0774 | ||
|
|
48dbc7a152 | ||
|
|
784ffbcd00 | ||
|
|
412faf4ad5 | ||
|
|
a05f8dc4a4 |
@@ -13,8 +13,8 @@ android {
|
||||
applicationId "com.donetick.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 57
|
||||
versionName "1.2.36"
|
||||
versionCode 66
|
||||
versionName "1.2.45"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -21,6 +21,7 @@ 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,6 +30,17 @@
|
||||
<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,6 +38,9 @@ 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 = 57;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.36;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -485,12 +485,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.2.36;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -504,12 +504,12 @@
|
||||
buildSettings = {
|
||||
CODE_SIGN_ENTITLEMENTS = DonetickWidget/DonetickWidget.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.36;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
@@ -527,12 +527,12 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
PROVISIONING_PROFILE_SPECIFIER = "Donetick Widget App Store(fastline)";
|
||||
CURRENT_PROJECT_VERSION = 57;
|
||||
CURRENT_PROJECT_VERSION = 66;
|
||||
DEVELOPMENT_TEAM = 6UJJ78R3BS;
|
||||
INFOPLIST_FILE = DonetickWidget/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 1.2.36;
|
||||
MARKETING_VERSION = 1.2.45;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app.widget;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
<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,6 +23,7 @@ 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'
|
||||
|
||||
@@ -43,6 +43,8 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorPushNotifications (8.1.2):
|
||||
- Capacitor
|
||||
- CapacitorShare (8.0.1):
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (8.0.3):
|
||||
- Capacitor
|
||||
- CapgoCapacitorDocumentScanner (8.4.2):
|
||||
@@ -157,6 +159,7 @@ 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`)"
|
||||
@@ -222,6 +225,8 @@ 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:
|
||||
@@ -256,6 +261,7 @@ SPEC CHECKSUMS:
|
||||
CapacitorPluginSafeArea: 874619c00586248f1694210e72038123d422c2d9
|
||||
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
|
||||
CapacitorPushNotifications: 32a7f840815f319fd9ba1c1c9b0b914be9d95237
|
||||
CapacitorShare: 0c58305114538568059bfc07111f22dcb9cb2a82
|
||||
CapacitorStatusBar: eca7bc2b58d9f886f1ef9edb66e57a9b29c121af
|
||||
CapgoCapacitorDocumentScanner: 262bb84b73707f9e2e59071ca58013e3f9acf942
|
||||
CapgoCapacitorNfc: 8ea158143c441e1cf6d231a971e375511558d027
|
||||
@@ -283,6 +289,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: 028fdeb50d56158db0a97459bd577ed700f03c0c
|
||||
PODFILE CHECKSUM: 0170e6b548e03117ef7d6814596b8b140a6acce4
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.33",
|
||||
"version": "1.2.38",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.33",
|
||||
"version": "1.2.38",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@capacitor-community/in-app-review": "^8.0.0",
|
||||
@@ -24,6 +24,7 @@
|
||||
"@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",
|
||||
@@ -2174,6 +2175,15 @@
|
||||
"@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.36",
|
||||
"version": "1.2.45",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -56,6 +56,7 @@
|
||||
"@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",
|
||||
|
||||
11
public/.well-known/apple-app-site-association
Normal file
11
public/.well-known/apple-app-site-association
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
11
public/.well-known/apple-app-site-association.json
Normal file
11
public/.well-known/apple-app-site-association.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"applinks": {
|
||||
"apps": [],
|
||||
"details": [
|
||||
{
|
||||
"appID": "6UJJ78R3BS.com.donetick.app",
|
||||
"paths": ["/circle/join*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
13
public/.well-known/assetlinks.json
Normal file
13
public/.well-known/assetlinks.json
Normal file
@@ -0,0 +1,13 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
8
public/_headers
Normal file
8
public/_headers
Normal file
@@ -0,0 +1,8 @@
|
||||
/.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
public/_redirects
Normal file
1
public/_redirects
Normal file
@@ -0,0 +1 @@
|
||||
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "الأنشطة",
|
||||
"points": "النقاط",
|
||||
"settings": "الإعدادات"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "إعدادات المطور",
|
||||
"description": "عرض المعلومات الفنية حول رموز المصادقة واتصالات SSE وبيانات التصحيح."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Entwickler-Einstellungen",
|
||||
"description": "Zeige technische Informationen über Authentifizierungs-Token, SSE-Verbindungen und Debug-Daten an."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"logout": "Logout",
|
||||
"version": "Version",
|
||||
"navigation": {
|
||||
"search": "Search",
|
||||
"allTasks": "All Tasks",
|
||||
"archived": "Archived",
|
||||
"things": "Things",
|
||||
|
||||
@@ -171,7 +171,11 @@
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Actividades",
|
||||
"points": "Puntos",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Configuración de Desarrollador",
|
||||
"description": "Ver información técnica sobre tokens de autenticación, conexiones SSE y datos de depuración."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activités",
|
||||
"points": "Points",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Paramètres Développeur",
|
||||
"description": "Consultez les informations techniques sur les jetons d'authentification, les connexions SSE et les données de débogage."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "アクティビティ",
|
||||
"points": "ポイント",
|
||||
"settings": "設定"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "開発者設定",
|
||||
"description": "認証トークン、SSE 接続、デバッグデータなどの技術情報を確認できます。"
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activiteiten",
|
||||
"points": "Punten",
|
||||
"settings": "Instellingen"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Ontwikkelaarsinstellingen",
|
||||
"description": "Bekijk technische informatie over authenticatietokens, SSE-verbindingen en foutopsporingsgegevens."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Atividades",
|
||||
"points": "Pontos",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Configurações de desenvolvedor",
|
||||
"description": "Veja informações técnicas sobre tokens de autenticação, conexões SSE e dados de depuração."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,5 +29,51 @@
|
||||
"activities": "Activities",
|
||||
"points": "Points",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"feedback": {
|
||||
"later": "Maybe later",
|
||||
"sentiment": {
|
||||
"title": "How's Donetick working for you?",
|
||||
"subtitle": "Your answer helps us decide what to build next.",
|
||||
"options": {
|
||||
"love": "Love it",
|
||||
"okay": "It's okay",
|
||||
"issues": "Having issues"
|
||||
}
|
||||
},
|
||||
"categories": {
|
||||
"bugs": "Bugs",
|
||||
"missingFeature": "Missing feature",
|
||||
"tooComplicated": "Too complicated",
|
||||
"slow": "Slow",
|
||||
"notifications": "Notifications",
|
||||
"ai": "AI",
|
||||
"other": "Other"
|
||||
},
|
||||
"details": {
|
||||
"title": "What could we improve?",
|
||||
"messageLabel": "Tell us more",
|
||||
"messagePlaceholder": "What happened, or what would make this better?",
|
||||
"contextNote": "We'll include your app version, device and platform so we can reproduce issues.",
|
||||
"submit": "Send feedback",
|
||||
"contextNoteSelfHosted": "You're on a self-hosted instance, so nothing is sent from your server — we'll open a pre-filled GitHub issue you can review and edit first.",
|
||||
"submitSelfHosted": "Continue to GitHub"
|
||||
},
|
||||
"review": {
|
||||
"title": "Glad you're enjoying it!",
|
||||
"subtitle": "A rating or a star helps other people find Donetick.",
|
||||
"github": "Star on GitHub",
|
||||
"appStore": "Rate on the App Store",
|
||||
"playStore": "Rate on Google Play"
|
||||
},
|
||||
"thanks": {
|
||||
"title": "Thanks for the feedback",
|
||||
"subtitle": "We read every response and it shapes what we work on next."
|
||||
},
|
||||
"github": {
|
||||
"title": "Report it on GitHub",
|
||||
"subtitle": "We've filled in an issue with your notes and version details. Nothing has been sent yet — review it and post when you're ready.",
|
||||
"open": "Open the issue"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,10 @@
|
||||
"developer": {
|
||||
"title": "Developer Settings",
|
||||
"description": "View technical information about authentication tokens, SSE connections, and debug data."
|
||||
},
|
||||
"feedback": {
|
||||
"title": "Send Feedback",
|
||||
"description": "Tell us how Donetick is working for you, report a bug, or request a feature."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
src/App.jsx
41
src/App.jsx
@@ -1,22 +1,24 @@
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import { Button, Typography, useColorScheme } from '@mui/joy'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { Outlet, useNavigate } from 'react-router-dom'
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useRegisterSW } from 'virtual:pwa-register/react'
|
||||
|
||||
import NavBar from '@/views/components/NavBar'
|
||||
|
||||
import { registerCapacitorListeners } from './CapacitorListener'
|
||||
import PageTransition from './components/animations/PageTransition'
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { AuthProvider } from './hooks/useAuth.jsx'
|
||||
|
||||
import useOnboardingGate from './hooks/useOnboardingGate'
|
||||
import useStatusBar from './hooks/useStatusBar'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import './styles/safe-area.css'
|
||||
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import { useResource } from './queries/ResourceQueries'
|
||||
import { GlobalSearchProvider } from './search/GlobalSearchContext'
|
||||
import { recordRoute } from './service/DiagnosticsSession'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -32,17 +34,24 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
const AppContent = () => {
|
||||
const { showNotification } = useNotification()
|
||||
const location = useLocation()
|
||||
useSyncOnReconnect()
|
||||
|
||||
// // First-launch native users see the onboarding flow before anything else.
|
||||
useOnboardingGate()
|
||||
// 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) {
|
||||
@@ -87,6 +96,8 @@ const AppContent = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [needRefresh])
|
||||
|
||||
if (isRedirectingToOnboarding) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ImpersonateUserProvider>
|
||||
@@ -100,7 +111,7 @@ const AppContent = () => {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const resource = useResource() // eslint-disable-line no-unused-vars
|
||||
const resource = useResource()
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -137,7 +148,9 @@ function App() {
|
||||
|
||||
<AuthProvider>
|
||||
<SSEProvider>
|
||||
<AppContent />
|
||||
<GlobalSearchProvider>
|
||||
<AppContent />
|
||||
</GlobalSearchProvider>
|
||||
</SSEProvider>
|
||||
</AuthProvider>
|
||||
</div>
|
||||
|
||||
10
src/Application.jsx
Normal file
10
src/Application.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import App from './App.jsx'
|
||||
import Contexts from './contexts/Contexts.jsx'
|
||||
|
||||
const Application = () => (
|
||||
<Contexts>
|
||||
<App />
|
||||
</Contexts>
|
||||
)
|
||||
|
||||
export default Application
|
||||
@@ -6,8 +6,11 @@ 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
|
||||
@@ -63,7 +66,27 @@ const handleNFCChoreDeepLink = (url, isColdStart) => {
|
||||
|
||||
const handleUrlOpen = (url, isColdStart = false) => {
|
||||
console.log('[NFC] handleUrlOpen:', url)
|
||||
if (url.startsWith('donetick://chores/add')) {
|
||||
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')) {
|
||||
// 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.
|
||||
|
||||
66
src/MarketingApp.jsx
Normal file
66
src/MarketingApp.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
|
||||
import { LocalizationProvider } from './contexts/LocalizationContext'
|
||||
import ThemeContext from './contexts/ThemeContext'
|
||||
import Landing from './views/Landing/Landing'
|
||||
import PrivacyPolicyView from './views/PrivacyPolicy/PrivacyPolicyView'
|
||||
import TermsView from './views/Terms/TermsView'
|
||||
|
||||
const AppRedirect = () => {
|
||||
useEffect(() => {
|
||||
const { hash, pathname, search } = window.location
|
||||
window.location.replace(
|
||||
`https://app.donetick.com${pathname}${search}${hash}`,
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <Landing /> },
|
||||
{ path: '/privacy', element: <PrivacyPolicyView /> },
|
||||
{ path: '/terms', element: <TermsView /> },
|
||||
{ path: '*', element: <AppRedirect /> },
|
||||
])
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { enabled: false, refetchOnWindowFocus: false, retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const ThemeClass = () => {
|
||||
const { mode, systemMode } = useColorScheme()
|
||||
|
||||
useEffect(() => {
|
||||
const storedMode = JSON.parse(localStorage.getItem('themeMode') || 'null')
|
||||
const selectedMode = storedMode || mode
|
||||
const isDark =
|
||||
selectedMode === 'dark' ||
|
||||
(selectedMode === 'system' && systemMode === 'dark')
|
||||
|
||||
document.getElementById('root').classList.toggle('dark', isDark)
|
||||
}, [mode, systemMode])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const MarketingApp = () => (
|
||||
<ThemeContext>
|
||||
<ThemeClass />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<LocalizationProvider>
|
||||
<ImpersonateUserProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ImpersonateUserProvider>
|
||||
</LocalizationProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeContext>
|
||||
)
|
||||
|
||||
export default MarketingApp
|
||||
@@ -63,6 +63,10 @@ function getInternalValue(timing, displayValue) {
|
||||
|
||||
const NotificationTemplate = ({
|
||||
maxNotifications = 5,
|
||||
// ChoreEdit gates this editor behind its own on/off switch, so the last row
|
||||
// must stay put — `notification: true` with no templates is not a valid task.
|
||||
// Consumers that own an empty state themselves pass 0.
|
||||
minNotifications = 1,
|
||||
onChange,
|
||||
value,
|
||||
showTimeline = true,
|
||||
@@ -259,7 +263,8 @@ const NotificationTemplate = ({
|
||||
return next
|
||||
})
|
||||
|
||||
onChange && onChange(updated)
|
||||
// No direct onChange here: consumers expect { notifications }, and the
|
||||
// effect below already emits that shape once the state settles.
|
||||
setShowSaveDefault(true)
|
||||
}
|
||||
|
||||
@@ -307,9 +312,6 @@ const NotificationTemplate = ({
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3, mb: 2 }}>
|
||||
<Typography level={'body-md'} sx={{ mb: 1 }}>
|
||||
Notification Timeline
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
@@ -642,7 +644,7 @@ const NotificationTemplate = ({
|
||||
</Select>
|
||||
<IconButton
|
||||
onClick={() => removeNotification(idx)}
|
||||
disabled={notifications.length === 1}
|
||||
disabled={notifications.length <= minNotifications}
|
||||
color={'danger'}
|
||||
size={'sm'}
|
||||
variant={'soft'}
|
||||
|
||||
@@ -2,21 +2,21 @@ import { Add, Close } from '@mui/icons-material'
|
||||
import { Box, Button, Chip, ChipDelete, Typography } from '@mui/joy'
|
||||
|
||||
const ActiveFilterChips = ({
|
||||
chips = [],
|
||||
onOpen,
|
||||
onClearAll,
|
||||
onAdd,
|
||||
showAddChip = false,
|
||||
resultCount,
|
||||
totalCount,
|
||||
maxVisible = 2,
|
||||
chipSize = 'md',
|
||||
chipSx,
|
||||
chips = [],
|
||||
clearButtonSize = 'sm',
|
||||
clearButtonSx,
|
||||
containerSx,
|
||||
chipSx,
|
||||
maxVisible = 2,
|
||||
onAdd,
|
||||
onClearAll,
|
||||
onOpen,
|
||||
overflowChipSx,
|
||||
resultCount,
|
||||
resultSx,
|
||||
showAddChip = false,
|
||||
totalCount,
|
||||
}) => {
|
||||
if (!chips.length) {
|
||||
return null
|
||||
@@ -39,7 +39,7 @@ const ActiveFilterChips = ({
|
||||
...containerSx,
|
||||
}}
|
||||
>
|
||||
{visible.map(({ key, label, onClear, color = 'primary' }) => (
|
||||
{visible.map(({ color = 'primary', key, label, onClear }) => (
|
||||
<Chip
|
||||
key={key}
|
||||
size={chipSize}
|
||||
@@ -52,7 +52,10 @@ const ActiveFilterChips = ({
|
||||
<ChipDelete
|
||||
variant='plain'
|
||||
color={color}
|
||||
onDelete={() => onClear?.()}
|
||||
onDelete={event => {
|
||||
event.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
aria-label={`Remove ${label} filter`}
|
||||
sx={{
|
||||
'--Chip-deleteSize': chipSize === 'sm' ? '1.1rem' : '1.4rem',
|
||||
|
||||
@@ -5,3 +5,8 @@ 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,6 +22,8 @@ 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,3 +1,5 @@
|
||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
|
||||
|
||||
import App from '@/App'
|
||||
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
|
||||
import Error from '@/views/Error'
|
||||
@@ -10,7 +12,8 @@ import Settings from '@/views/Settings/Settings'
|
||||
import SettingsOverview from '@/views/Settings/SettingsOverview'
|
||||
import SettingsRoutes from '@/views/Settings/SettingsRoutes'
|
||||
import ThemeSettings from '@/views/Settings/ThemeSettings'
|
||||
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
|
||||
|
||||
import GlobalSearchPage from '../search/GlobalSearchPage'
|
||||
import AuthenticationLoading from '../views/Authorization/Authenticating'
|
||||
import ForgotPasswordView from '../views/Authorization/ForgotPasswordView'
|
||||
import LoginSettings from '../views/Authorization/LoginSettings'
|
||||
@@ -49,16 +52,6 @@ import ThingsView from '../views/Things/ThingsView'
|
||||
import TimerDetails from '../views/Timer/TimerDetails'
|
||||
import UserActivities from '../views/User/UserActivities'
|
||||
import UserPoints from '../views/User/UserPoints'
|
||||
const getMainRoute = () => {
|
||||
if (
|
||||
// if domain is www.donetick.com or donetick.com then show landing page:
|
||||
window.location.hostname === 'www.donetick.com' ||
|
||||
window.location.hostname === 'donetick.com'
|
||||
) {
|
||||
return <Landing />
|
||||
}
|
||||
return <MyChores />
|
||||
}
|
||||
const Router = createBrowserRouter([
|
||||
{
|
||||
path: '/',
|
||||
@@ -67,7 +60,7 @@ const Router = createBrowserRouter([
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
element: getMainRoute(),
|
||||
element: <MyChores />,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -139,6 +132,10 @@ const Router = createBrowserRouter([
|
||||
path: '/chores',
|
||||
element: <MyChores />,
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
element: <GlobalSearchPage />,
|
||||
},
|
||||
{
|
||||
path: '/archived',
|
||||
element: <ArchivedTasks />,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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',
|
||||
@@ -34,6 +35,9 @@ const theme = extendTheme({
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.light,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
success: {
|
||||
50: '#f3faf7',
|
||||
@@ -75,6 +79,9 @@ const theme = extendTheme({
|
||||
},
|
||||
dark: {
|
||||
palette: {
|
||||
background: {
|
||||
body: THEME_BACKGROUND.dark,
|
||||
},
|
||||
primary: primaryPalette,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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 = ({
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
} = {}) => {
|
||||
const { showError } = useNotification()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -19,28 +20,36 @@ export const useFileUpload = ({
|
||||
showError({
|
||||
title: 'Plus Feature',
|
||||
message:
|
||||
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
|
||||
'File uploads are not available in the Basic plan. Upgrade to Plus to add files to your content.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
|
||||
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
|
||||
useWebWorker: true,
|
||||
fileType: 'image/jpeg',
|
||||
// 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 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', compressedJpegFile)
|
||||
formData.append('file', fileToUpload)
|
||||
formData.append('entityType', entityType)
|
||||
if (entityId) formData.append('entityId', String(entityId))
|
||||
if (draftId) formData.append('draftId', draftId)
|
||||
@@ -62,7 +71,7 @@ export const useFileUpload = ({
|
||||
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
|
||||
showError({
|
||||
title: 'Upgrade Required',
|
||||
message: 'Image uploads are only available for Plus accounts.',
|
||||
message: 'File uploads are only available for Plus accounts.',
|
||||
})
|
||||
return null
|
||||
} else if (response.status === 403) {
|
||||
@@ -74,7 +83,7 @@ export const useFileUpload = ({
|
||||
} else if (!response.ok) {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'Failed to upload image.',
|
||||
message: 'Failed to upload file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -91,7 +100,7 @@ export const useFileUpload = ({
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Upload Failed',
|
||||
message: 'An error occurred while processing the image.',
|
||||
message: 'An error occurred while processing the file.',
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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
|
||||
@@ -11,6 +13,9 @@ const ALLOWED_PATHS = [
|
||||
'/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 =>
|
||||
@@ -23,16 +28,24 @@ const isAllowed = pathname =>
|
||||
*/
|
||||
const useOnboardingGate = () => {
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const { pathname, search } = useLocation()
|
||||
const isRedirecting =
|
||||
isNativeApp() &&
|
||||
!hasSeenOnboarding() &&
|
||||
!localStorage.getItem('token') &&
|
||||
!isAllowed(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNativeApp() || hasSeenOnboarding()) return
|
||||
// A signed-in user upgrading from an older build has nothing to onboard to.
|
||||
if (localStorage.getItem('token')) return
|
||||
if (isAllowed(pathname)) return
|
||||
if (!isRedirecting) return
|
||||
|
||||
if (pathname === '/circle/join') {
|
||||
setPendingInvite(new URLSearchParams(search).get('code'))
|
||||
}
|
||||
|
||||
navigate('/onboarding', { replace: true })
|
||||
}, [pathname, navigate])
|
||||
}, [isRedirecting, pathname, search, navigate])
|
||||
|
||||
return isRedirecting
|
||||
}
|
||||
|
||||
export default useOnboardingGate
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useColorScheme } from '@mui/joy'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import statusBarManager from '../utils/StatusBarManager'
|
||||
|
||||
/**
|
||||
@@ -35,10 +36,7 @@ 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,9 +2,10 @@ 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 { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOAuthExchangeInProgress } from '../utils/OAuthExchangeState'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
@@ -91,11 +92,18 @@ 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,14 +1,23 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import Contexts from './contexts/Contexts.jsx'
|
||||
import './i18n/config'
|
||||
import './index.css'
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
|
||||
const marketingHosts = new Set(['donetick.com', 'www.donetick.com'])
|
||||
const isMarketingSite =
|
||||
marketingHosts.has(window.location.hostname) ||
|
||||
(import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get('site') === 'marketing')
|
||||
|
||||
export const Site = React.lazy(() =>
|
||||
isMarketingSite ? import('./MarketingApp.jsx') : import('./Application.jsx'),
|
||||
)
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<Contexts>
|
||||
<App />
|
||||
</Contexts>
|
||||
<React.Suspense fallback={null}>
|
||||
<Site />
|
||||
</React.Suspense>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
@@ -99,7 +99,8 @@ export const useChores = (includeArchive = false) => {
|
||||
queryFn: async () => {
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
try {
|
||||
// Sync from server first (no-op if already syncing or offline)
|
||||
// Sync from server first (coalesced with any run already in flight,
|
||||
// so a just-created chore can't be missed by a stale cursor)
|
||||
if (networkManager.isOnline) {
|
||||
await syncEngine.sync()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
GetAllCircleMembers,
|
||||
GetAllUsers,
|
||||
@@ -28,6 +29,7 @@ export const useAllUsers = () => {
|
||||
|
||||
export const useCircleMembers = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
@@ -45,6 +47,10 @@ 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 = () => {
|
||||
@@ -54,7 +60,7 @@ export const useCircleMembers = () => {
|
||||
return { data, error, isLoading, handleRefetch }
|
||||
}
|
||||
|
||||
export const useUserProfile = () => {
|
||||
export const useUserProfile = ({ enabled = true } = {}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
@@ -80,7 +86,7 @@ export const useUserProfile = () => {
|
||||
},
|
||||
staleTime: 30 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
enabled: !!token,
|
||||
enabled: enabled && !!token,
|
||||
})
|
||||
return {
|
||||
data,
|
||||
|
||||
192
src/search/GlobalSearchContext.jsx
Normal file
192
src/search/GlobalSearchContext.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
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
|
||||
}
|
||||
32
src/search/GlobalSearchPage.jsx
Normal file
32
src/search/GlobalSearchPage.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
468
src/search/GlobalSearchPalette.jsx
Normal file
468
src/search/GlobalSearchPalette.jsx
Normal file
@@ -0,0 +1,468 @@
|
||||
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
|
||||
182
src/search/searchProviders.js
Normal file
182
src/search/searchProviders.js
Normal file
@@ -0,0 +1,182 @@
|
||||
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}`,
|
||||
}),
|
||||
),
|
||||
})
|
||||
189
src/service/DiagnosticsSession.js
Normal file
189
src/service/DiagnosticsSession.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* Ambient session state worth having the moment something breaks: how long the
|
||||
* user has been in the app, how they got to the screen that failed, which
|
||||
* server they were talking to and what it had already refused.
|
||||
*
|
||||
* Deliberately dependency-free — ApiClient imports it on the request path, so
|
||||
* anything imported here would risk a module cycle. Everything is in memory
|
||||
* and dies with the tab; nothing is persisted.
|
||||
*/
|
||||
|
||||
const SESSION_STARTED_AT = Date.now()
|
||||
const SESSION_ID = `${SESSION_STARTED_AT.toString(36)}-${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`
|
||||
|
||||
// A cold start (vs. a reload of an already-running app) changes what a crash
|
||||
// means: a reload loop looks very different from a first-launch failure.
|
||||
const NAVIGATION_TYPE =
|
||||
performance.getEntriesByType?.('navigation')?.[0]?.type ?? 'unknown'
|
||||
|
||||
const MAX_ROUTES = 10
|
||||
const MAX_API_FAILURES = 8
|
||||
|
||||
const routeTrail = []
|
||||
const apiFailures = []
|
||||
let backgroundedCount = 0
|
||||
let serverVersion = null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route trail
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Records a navigation and closes out the dwell time on the previous screen.
|
||||
* "The crash happened 400ms after landing on /chores/12 from /chores" is a
|
||||
* far better bug report than "the crash happened on /chores/12".
|
||||
*/
|
||||
export const recordRoute = path => {
|
||||
if (!path) return
|
||||
const now = Date.now()
|
||||
const previous = routeTrail[routeTrail.length - 1]
|
||||
if (previous) {
|
||||
if (previous.path === path) return
|
||||
previous.dwellMs = now - previous.at
|
||||
}
|
||||
routeTrail.push({ path, at: now })
|
||||
if (routeTrail.length > MAX_ROUTES) routeTrail.shift()
|
||||
}
|
||||
|
||||
export const getRouteTrail = () => {
|
||||
const now = Date.now()
|
||||
return routeTrail.map((entry, index) => ({
|
||||
path: entry.path,
|
||||
// The current screen has no closing dwell yet; measure it up to now.
|
||||
dwellMs:
|
||||
entry.dwellMs ??
|
||||
(index === routeTrail.length - 1 ? now - entry.at : null),
|
||||
msAgo: now - entry.at,
|
||||
}))
|
||||
}
|
||||
|
||||
/** The screen the user came from, which is usually where the bug was planted. */
|
||||
export const getPreviousRoute = () =>
|
||||
routeTrail.length > 1 ? routeTrail[routeTrail.length - 2].path : null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Picks the server build out of response headers. Costs nothing when the
|
||||
* server doesn't send them — the field simply stays null.
|
||||
*/
|
||||
export const recordServerVersionFromResponse = response => {
|
||||
if (serverVersion) return
|
||||
try {
|
||||
serverVersion =
|
||||
response?.headers?.get?.('x-donetick-version') ||
|
||||
response?.headers?.get?.('x-api-version') ||
|
||||
null
|
||||
} catch {
|
||||
// headers may be inaccessible on opaque responses; not worth reporting
|
||||
}
|
||||
}
|
||||
|
||||
export const setServerVersion = version => {
|
||||
if (version) serverVersion = version
|
||||
}
|
||||
|
||||
export const getServerVersion = () => serverVersion
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API failures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Strips ids and query strings so failures group by endpoint, not by row. */
|
||||
const normalizeEndpoint = endpoint =>
|
||||
String(endpoint || '')
|
||||
.split('?')[0]
|
||||
.replace(/\/\d+/g, '/:id')
|
||||
|
||||
export const recordApiFailure = ({ endpoint, method, status }) => {
|
||||
apiFailures.push({
|
||||
at: Date.now(),
|
||||
method: method || 'GET',
|
||||
endpoint: normalizeEndpoint(endpoint),
|
||||
status: status ?? 'network',
|
||||
})
|
||||
if (apiFailures.length > MAX_API_FAILURES) apiFailures.shift()
|
||||
}
|
||||
|
||||
export const getApiFailures = () => {
|
||||
const now = Date.now()
|
||||
return apiFailures.map(failure => ({
|
||||
method: failure.method,
|
||||
endpoint: failure.endpoint,
|
||||
status: failure.status,
|
||||
msAgo: now - failure.at,
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') backgroundedCount += 1
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Snapshot
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const getServiceWorkerState = async () => {
|
||||
if (!('serviceWorker' in navigator)) return { supported: false }
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
return {
|
||||
supported: true,
|
||||
controlled: Boolean(navigator.serviceWorker.controller),
|
||||
// A waiting worker means the user is running a stale bundle against a
|
||||
// newer deploy — the usual cause of chunk-load failures after a release.
|
||||
updateWaiting: Boolean(registration?.waiting),
|
||||
}
|
||||
} catch {
|
||||
return { supported: true, controlled: null, updateWaiting: null }
|
||||
}
|
||||
}
|
||||
|
||||
const getStorageState = async () => {
|
||||
try {
|
||||
const { quota, usage } = await navigator.storage.estimate()
|
||||
return {
|
||||
usageMb: Math.round((usage / 1048576) * 10) / 10,
|
||||
quotaMb: Math.round(quota / 1048576),
|
||||
// Storage pressure produces failures that look like anything but.
|
||||
pressure: quota ? Math.round((usage / quota) * 100) : null,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const getSessionDiagnostics = async () => {
|
||||
const [serviceWorker, storage] = await Promise.all([
|
||||
getServiceWorkerState(),
|
||||
getStorageState(),
|
||||
])
|
||||
|
||||
return {
|
||||
sessionId: SESSION_ID,
|
||||
sessionStartedAt: new Date(SESSION_STARTED_AT).toISOString(),
|
||||
sessionDurationMs: Date.now() - SESSION_STARTED_AT,
|
||||
navigationType: NAVIGATION_TYPE,
|
||||
backgroundedCount,
|
||||
serverVersion,
|
||||
previousRoute: getPreviousRoute(),
|
||||
routeTrail: getRouteTrail(),
|
||||
apiFailures: getApiFailures(),
|
||||
// Chromium only; absent elsewhere rather than faked.
|
||||
heapUsedMb: performance.memory
|
||||
? Math.round(performance.memory.usedJSHeapSize / 1048576)
|
||||
: null,
|
||||
storage,
|
||||
serviceWorker,
|
||||
}
|
||||
}
|
||||
305
src/service/ErrorReportService.js
Normal file
305
src/service/ErrorReportService.js
Normal file
@@ -0,0 +1,305 @@
|
||||
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,6 +1,11 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
|
||||
import { API_URL } from '../Config'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import {
|
||||
recordApiFailure,
|
||||
recordServerVersionFromResponse,
|
||||
} from '../service/DiagnosticsSession'
|
||||
import { logout, RefreshToken } from './Fetcher'
|
||||
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
@@ -129,7 +134,7 @@ class ApiClient {
|
||||
|
||||
// Process queued requests after refresh attempt
|
||||
processQueue(error, token = null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
this.failedQueue.forEach(({ reject, resolve }) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
@@ -150,6 +155,21 @@ 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()
|
||||
@@ -199,6 +219,17 @@ class ApiClient {
|
||||
let response = await fetch(url, config)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
// Passive diagnostics: learn the server build from whatever it already
|
||||
// answers, and keep the last few refusals for crash reports.
|
||||
recordServerVersionFromResponse(response)
|
||||
if (!response.ok) {
|
||||
recordApiFailure({
|
||||
endpoint,
|
||||
method: config.method,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
// 2. Check for 401 (Unauthorized)
|
||||
if (response.status === 401) {
|
||||
// Always queue this request first
|
||||
@@ -280,6 +311,7 @@ class ApiClient {
|
||||
error?.name === 'AbortError' && options.signal?.aborted
|
||||
if (!externalAbort) {
|
||||
networkManager.setServerUnreachable()
|
||||
recordApiFailure({ endpoint, method: config.method, status: 'network' })
|
||||
}
|
||||
console.error('Request failed', error)
|
||||
throw error
|
||||
|
||||
@@ -89,6 +89,8 @@ const buildActualDateGroups = chores => {
|
||||
export const ChoresGrouper = (groupBy, chores, filter) => {
|
||||
if (filter) {
|
||||
chores = chores.filter(chore => filter(chore))
|
||||
} else {
|
||||
chores = [...chores]
|
||||
}
|
||||
|
||||
// sort by priority then due date:
|
||||
|
||||
17
src/utils/FileConvert.js
Normal file
17
src/utils/FileConvert.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
46
src/utils/PendingInvite.js
Normal file
46
src/utils/PendingInvite.js
Normal file
@@ -0,0 +1,46 @@
|
||||
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,6 +2,8 @@ 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
|
||||
@@ -52,17 +54,18 @@ class StatusBarManager {
|
||||
this.currentTheme = theme
|
||||
|
||||
try {
|
||||
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
|
||||
}
|
||||
const resolvedTheme =
|
||||
theme === 'system'
|
||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
: theme
|
||||
const style = resolvedTheme === 'dark' ? Style.Dark : 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)
|
||||
@@ -162,15 +165,7 @@ class StatusBarManager {
|
||||
async updateResolvedTheme(resolvedTheme) {
|
||||
if (!this.isNativePlatform) return
|
||||
|
||||
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)
|
||||
}
|
||||
await this.setTheme(resolvedTheme)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,10 @@ class SyncEngine {
|
||||
constructor() {
|
||||
this.isSyncing = false
|
||||
this.listeners = []
|
||||
// The run currently in flight, and the single follow-up run queued behind
|
||||
// it. See sync() for why a follow-up is needed rather than just waiting.
|
||||
this.inFlight = null
|
||||
this.queued = null
|
||||
}
|
||||
|
||||
// Register listener for sync state changes
|
||||
@@ -42,10 +46,38 @@ class SyncEngine {
|
||||
this.listeners.forEach(cb => cb(state))
|
||||
}
|
||||
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise.
|
||||
//
|
||||
// Concurrent callers are coalesced rather than dropped. Returning early while
|
||||
// another run is in flight used to lose writes: that run's /sync/changes
|
||||
// request may have been issued *before* the caller's change reached the
|
||||
// server, so its cursor skips past the change and the caller reads a cache
|
||||
// that will never contain it until something else triggers a sync. That is
|
||||
// why a task created from the modal could vanish on the refetch right after
|
||||
// it was created. Waiting for the in-flight run is not enough for the same
|
||||
// reason, so callers that arrive mid-run share one follow-up run instead.
|
||||
async sync() {
|
||||
if (!isOfflineFeatureEnabled()) return false
|
||||
if (this.isSyncing) return false
|
||||
|
||||
if (this.inFlight) {
|
||||
if (!this.queued) {
|
||||
this.queued = this.inFlight
|
||||
.catch(() => false)
|
||||
.then(() => {
|
||||
this.queued = null
|
||||
return this.sync()
|
||||
})
|
||||
}
|
||||
return this.queued
|
||||
}
|
||||
|
||||
this.inFlight = this._runSync().finally(() => {
|
||||
this.inFlight = null
|
||||
})
|
||||
return this.inFlight
|
||||
}
|
||||
|
||||
async _runSync() {
|
||||
this.isSyncing = true
|
||||
this._notify({ syncing: true, error: null })
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ 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 {
|
||||
@@ -138,7 +140,15 @@ const LoginView = () => {
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && user) {
|
||||
Navigate('/chores')
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, user, Navigate])
|
||||
const handleSubmit = async e => {
|
||||
@@ -416,9 +426,11 @@ const LoginView = () => {
|
||||
<AuthShell
|
||||
title={userProfile ? 'Welcome back' : 'Sign in'}
|
||||
subtitle={
|
||||
userProfile
|
||||
? 'Pick up right where you left off.'
|
||||
: 'Sign in to your account to continue.'
|
||||
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.'
|
||||
}
|
||||
logoSize={0}
|
||||
footer={<LegalLinks />}
|
||||
|
||||
@@ -2,8 +2,11 @@ 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 { login, signUp } from '../../utils/Fetcher'
|
||||
import { signUp } from '../../utils/Fetcher'
|
||||
import { getPendingInvite, joinCirclePath } from '../../utils/PendingInvite'
|
||||
import {
|
||||
AuthPasswordField,
|
||||
AuthSubmitButton,
|
||||
@@ -25,28 +28,39 @@ const SignupView = () => {
|
||||
const [displayNameError, setDisplayNameError] = React.useState('')
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const { showError } = useNotification()
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
// Invalidate user profile queries to ensure fresh data
|
||||
queryClient.invalidateQueries(['userProfile'])
|
||||
|
||||
// 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 })
|
||||
})
|
||||
} else {
|
||||
console.log('Login failed', response)
|
||||
// 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('/login')
|
||||
}
|
||||
})
|
||||
// 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 })
|
||||
}
|
||||
const handleSignUpValidation = () => {
|
||||
// Reset errors before validation
|
||||
@@ -132,7 +146,11 @@ const SignupView = () => {
|
||||
return (
|
||||
<AuthShell
|
||||
title='Create your account'
|
||||
subtitle='Track chores and tasks together, in one shared place.'
|
||||
subtitle={
|
||||
getPendingInvite()
|
||||
? 'Create an account and we’ll send your circle join request right after.'
|
||||
: 'Track chores and tasks together, in one shared place.'
|
||||
}
|
||||
footer={<LegalLinks />}
|
||||
logoSize={0}
|
||||
>
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowDropDown,
|
||||
AttachFile,
|
||||
Delete,
|
||||
DocumentScanner,
|
||||
HorizontalRule,
|
||||
Save,
|
||||
UploadFile,
|
||||
@@ -37,9 +38,11 @@ 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,
|
||||
@@ -58,12 +61,13 @@ 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'
|
||||
@@ -84,6 +88,7 @@ const ASSIGN_STRATEGIES = [
|
||||
'round_robin',
|
||||
'no_assignee',
|
||||
]
|
||||
const DEFAULT_ASSIGN_STRATEGY = ASSIGN_STRATEGIES[3] // keep_last_assigned
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
|
||||
@@ -103,7 +108,7 @@ const ChoreEdit = () => {
|
||||
const [anyone, setAnyone] = useState(false)
|
||||
const [assignableTo, setAssignableTo] = useState([])
|
||||
const [performers, setPerformers] = useState([])
|
||||
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
|
||||
const [assignStrategy, setAssignStrategy] = useState(DEFAULT_ASSIGN_STRATEGY)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
@@ -151,7 +156,7 @@ const ChoreEdit = () => {
|
||||
const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects)
|
||||
|
||||
const [projectId, setProjectId] = useState(
|
||||
@@ -170,7 +175,8 @@ const ChoreEdit = () => {
|
||||
} = useChore(choreId)
|
||||
const { data: membersData, isLoading: isMemberDataLoading } =
|
||||
useCircleMembers()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
|
||||
const [userLabels, setUserLabels] = useState([])
|
||||
|
||||
@@ -183,20 +189,26 @@ const ChoreEdit = () => {
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const assignees = anyone ? performers : assignableTo
|
||||
const hasSpecificAssignees = !anyone && assignableTo.length > 0
|
||||
const canPickStrategy = hasSpecificAssignees && assignableTo.length > 1
|
||||
const assignStrategyValue = !hasSpecificAssignees
|
||||
? 'no_assignee'
|
||||
: canPickStrategy
|
||||
? assignStrategy
|
||||
: DEFAULT_ASSIGN_STRATEGY
|
||||
const assignedToValue =
|
||||
!hasSpecificAssignees || assignStrategyValue === 'no_assignee'
|
||||
? null
|
||||
: assignableTo.some(a => a.userId === assignedTo)
|
||||
? assignedTo
|
||||
: assignableTo[0].userId
|
||||
|
||||
const HandleValidateChore = () => {
|
||||
const errors = {}
|
||||
|
||||
if (name.trim() === '') {
|
||||
errors.name = 'Name is required'
|
||||
}
|
||||
if (assignStrategy !== 'no_assignee') {
|
||||
if (assignees.length === 0) {
|
||||
errors.assignees = 'At least 1 assignees is required'
|
||||
}
|
||||
if (assignedTo === null || assignedTo < 0) {
|
||||
errors.assignedTo = 'Assigned to is required'
|
||||
}
|
||||
}
|
||||
if (frequencyType === 'interval' && !frequency > 0) {
|
||||
errors.frequency = `Invalid frequency, the ${frequencyMetadata.unit} should be > 0`
|
||||
}
|
||||
@@ -366,8 +378,8 @@ const ChoreEdit = () => {
|
||||
frequencyType: frequencyType,
|
||||
frequency: Number(frequency),
|
||||
frequencyMetadata: frequencyMetadata,
|
||||
assignedTo: assignStrategy === 'no_assignee' ? null : assignedTo,
|
||||
assignStrategy: assignStrategy,
|
||||
assignedTo: assignedToValue,
|
||||
assignStrategy: assignStrategyValue,
|
||||
isRolling: isRolling,
|
||||
isActive: isActive,
|
||||
notification: isNotificable,
|
||||
@@ -463,6 +475,20 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (choreId || !userProfile?.id) return
|
||||
|
||||
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
const defaultAssigneeSetting = localStorage.getItem(
|
||||
'defaultAssigneeSetting',
|
||||
)
|
||||
|
||||
if (defaultAnyoneSetting === null && defaultAssigneeSetting === null) {
|
||||
setAnyone(false)
|
||||
setAssignableTo([{ userId: userProfile.id }])
|
||||
setAssignedTo(userProfile.id)
|
||||
}
|
||||
}, [choreId, userProfile?.id])
|
||||
useEffect(() => {
|
||||
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
|
||||
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
|
||||
@@ -549,7 +575,7 @@ const ChoreEdit = () => {
|
||||
setAssignStrategy(
|
||||
data.res.assignStrategy
|
||||
? data.res.assignStrategy
|
||||
: ASSIGN_STRATEGIES[2],
|
||||
: DEFAULT_ASSIGN_STRATEGY,
|
||||
)
|
||||
setIsRolling(data.res.isRolling)
|
||||
setIsActive(data.res.isActive)
|
||||
@@ -632,21 +658,6 @@ const ChoreEdit = () => {
|
||||
}
|
||||
}, [frequencyType])
|
||||
|
||||
useEffect(() => {
|
||||
if (anyone || assignableTo.length === 0) {
|
||||
setAssignStrategy('no_assignee')
|
||||
setAssignedTo(null)
|
||||
} else if (assignStrategy === 'no_assignee') {
|
||||
// user explicitly picked no_assignee while having assignees, keep it
|
||||
// but there is nobody currently assigned
|
||||
if (assignedTo !== null) {
|
||||
setAssignedTo(null)
|
||||
}
|
||||
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
|
||||
setAssignedTo(assignableTo[0].userId)
|
||||
}
|
||||
}, [assignStrategy, assignedTo, assignableTo, anyone])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
|
||||
// setAssignees([
|
||||
@@ -664,6 +675,67 @@ 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,
|
||||
@@ -1102,62 +1174,39 @@ const ChoreEdit = () => {
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<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)
|
||||
<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]
|
||||
e.target.value = ''
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
await uploadAttachmentFile(file)
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
{isNativeScanner && (
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
startDecorator={<DocumentScanner />}
|
||||
disabled={isUploadingAttachment}
|
||||
onClick={handleScanAttachment}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -1260,12 +1309,13 @@ const ChoreEdit = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{!anyone && assignableTo.length > 1 && (
|
||||
{canPickStrategy && (
|
||||
<>
|
||||
<Box
|
||||
mb={3}
|
||||
sx={{
|
||||
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
|
||||
display:
|
||||
assignStrategyValue === 'no_assignee' ? 'none' : 'block',
|
||||
}}
|
||||
>
|
||||
<Typography level='h4'>Currently Assigned To</Typography>
|
||||
@@ -1279,7 +1329,7 @@ const ChoreEdit = () => {
|
||||
: 'Select an assignee for this task'
|
||||
}
|
||||
disabled={assignees.length === 0}
|
||||
value={assignedTo > -1 ? assignedTo : null}
|
||||
value={assignedToValue}
|
||||
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
|
||||
>
|
||||
{performers
|
||||
@@ -1309,7 +1359,7 @@ const ChoreEdit = () => {
|
||||
{ASSIGN_STRATEGIES.map((item, idx) => (
|
||||
<ListItem key={item}>
|
||||
<Checkbox
|
||||
checked={assignStrategy === item}
|
||||
checked={assignStrategyValue === item}
|
||||
onClick={() => setAssignStrategy(item)}
|
||||
overlay
|
||||
disableIcon
|
||||
|
||||
@@ -84,9 +84,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
|
||||
.join(', ')
|
||||
|
||||
const timeStr = metadata.time
|
||||
? formatTimeFn(metadata.time)
|
||||
: '6:00 PM'
|
||||
const timeStr = metadata.time ? formatTimeFn(metadata.time) : '6:00 PM'
|
||||
|
||||
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
@@ -109,11 +107,11 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
}
|
||||
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
frequencyMetadata,
|
||||
frequencyType,
|
||||
onFrequencyMetadataUpdate,
|
||||
onFrequencyUpdate,
|
||||
}) => {
|
||||
const { fmt } = useLocalization()
|
||||
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
|
||||
@@ -527,20 +525,21 @@ export const RepeatOnSections = ({
|
||||
}
|
||||
|
||||
const RepeatSection = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyTypeUpdate,
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
frequencyError,
|
||||
allUserThings,
|
||||
onTriggerUpdate,
|
||||
OnTriggerValidate,
|
||||
allUserThings,
|
||||
frequency,
|
||||
frequencyError,
|
||||
frequencyMetadata,
|
||||
frequencyType,
|
||||
isAttemptToSave,
|
||||
onFrequencyMetadataUpdate,
|
||||
onFrequencyTypeUpdate,
|
||||
onFrequencyUpdate,
|
||||
onTriggerUpdate,
|
||||
selectedThing,
|
||||
viewOnly = false,
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||
|
||||
return (
|
||||
<Box mt={2}>
|
||||
|
||||
@@ -28,6 +28,7 @@ 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'
|
||||
@@ -38,9 +39,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'
|
||||
@@ -93,7 +94,7 @@ const applyPendingArchivedState = async chores => {
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { showError, showSuccess } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const queryClient = useQueryClient()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
@@ -200,11 +201,11 @@ const ArchivedTasks = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: finalChores,
|
||||
activeFilters,
|
||||
setFilter,
|
||||
clearAll,
|
||||
filteredData: finalChores,
|
||||
hasActiveFilters,
|
||||
setFilter,
|
||||
} = useFilter(filteredChores, filterDefs)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -253,13 +254,6 @@ 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()
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Repeat,
|
||||
Schedule,
|
||||
ThumbDown,
|
||||
ThumbUp,
|
||||
TimesOneMobiledata,
|
||||
Toll,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
@@ -37,18 +38,18 @@ import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
const ChoreCard = ({
|
||||
chore,
|
||||
performers,
|
||||
sx,
|
||||
viewOnly,
|
||||
showActions = true,
|
||||
onChipClick,
|
||||
onAction,
|
||||
// Multi-select props
|
||||
isMultiSelectMode = false,
|
||||
isSelected = false,
|
||||
onAction,
|
||||
onChipClick,
|
||||
onSelectionToggle,
|
||||
performers,
|
||||
// Multi-select props
|
||||
showActions = true,
|
||||
sx,
|
||||
viewOnly,
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { data: userProfile } = useUserProfile({ enabled: !viewOnly })
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
@@ -359,27 +360,6 @@ const ChoreCard = ({
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 0.75,
|
||||
py: 0.5,
|
||||
minHeight: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Schedule sx={{ fontSize: 16 }} />
|
||||
<Typography level='body-xs'>Pending</Typography>
|
||||
</Chip>
|
||||
)}
|
||||
{showActions && (
|
||||
<Box
|
||||
display='flex'
|
||||
@@ -420,29 +400,31 @@ const ChoreCard = ({
|
||||
>
|
||||
<ThumbUp sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{/* <IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAction('reject', chore)
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
minWidth: 40,
|
||||
height: 40,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThumbDown sx={{ fontSize: 18 }} />
|
||||
</IconButton> */}
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAction('reject', chore)
|
||||
}}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
width: 50,
|
||||
minWidth: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
zIndex: 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
transform: 'scale(1.05)',
|
||||
},
|
||||
'&:active': {
|
||||
transform: 'scale(0.95)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ThumbDown sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<IconButton
|
||||
@@ -515,6 +497,7 @@ const ChoreCard = ({
|
||||
</IconButton>
|
||||
)}
|
||||
<ChoreActionMenu
|
||||
variant='plain'
|
||||
chore={chore}
|
||||
onCompleteWithNote={() =>
|
||||
onAction('completeWithNote', chore)
|
||||
@@ -530,6 +513,16 @@ const ChoreCard = ({
|
||||
onWriteNFC={() => onAction('writeNFC', chore)}
|
||||
onNudge={() => onAction('nudge', chore)}
|
||||
onDelete={() => onAction('delete', chore)}
|
||||
sx={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
color: 'text.tertiary',
|
||||
flexShrink: 0,
|
||||
'&:hover': {
|
||||
color: 'text.secondary',
|
||||
bgcolor: 'background.level1',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -49,6 +49,23 @@ const getTimeFromTemplate = (template, relativeTime) => {
|
||||
}
|
||||
return time
|
||||
}
|
||||
// Decide whether this device's user should be notified about a chore:
|
||||
// - assignedTo set -> only that user
|
||||
// - no assignedTo -> everyone listed in assignees
|
||||
// - no assignees -> "Anyone" mode, notify the whole circle
|
||||
const shouldNotifyUser = (chore, userId) => {
|
||||
if (!userId) {
|
||||
return false
|
||||
}
|
||||
if (chore.assignedTo > 0) {
|
||||
return chore.assignedTo === userId
|
||||
}
|
||||
if (chore.assignees?.length > 0) {
|
||||
return chore.assignees.some(assignee => assignee.userId === userId)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const scheduleNotificationFromTemplate = (
|
||||
chore,
|
||||
userProfile,
|
||||
@@ -193,7 +210,8 @@ const scheduleChoreNotification = async (
|
||||
if (
|
||||
chore.notification === false ||
|
||||
chore.nextDueDate === null ||
|
||||
chore.isActive === false
|
||||
chore.isActive === false ||
|
||||
!shouldNotifyUser(chore, userProfile?.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
CalendarMonth,
|
||||
CloudOff,
|
||||
EditCalendar,
|
||||
SearchOff,
|
||||
ExpandCircleDown,
|
||||
PriorityHigh,
|
||||
SearchOff,
|
||||
Style,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
@@ -20,44 +20,42 @@ import {
|
||||
IconButton,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import IconButtonWithMenu from './IconButtonWithMenu'
|
||||
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import EmptyState from '../../components/common/EmptyState'
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useFilter } from '../../hooks/useFilter'
|
||||
import { useChores } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import {
|
||||
ChoreFilters,
|
||||
ChoresGrouper,
|
||||
ChoreSorter,
|
||||
filterByProject,
|
||||
} from '../../utils/Chores'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
|
||||
import TaskInput from '../components/AddTaskModal'
|
||||
import CalendarDual from '../components/CalendarDual'
|
||||
import CalendarMonthly from '../components/CalendarMonthly.jsx'
|
||||
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import { useProjects } from '../Projects/ProjectQueries.js'
|
||||
import ChoreListView from './ChoreListView.jsx'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import ChoreToolbar from './components/ChoreToolbarPrototype'
|
||||
import {
|
||||
conditionsToSelections,
|
||||
selectionsToConditions,
|
||||
} from './components/FilterBuilderContent'
|
||||
import ChoreModals from './components/ChoreModals'
|
||||
import MultiSelectToolbar from './components/MultiSelectToolbar'
|
||||
import MyChoreHeader from './components/MyChoreHeader'
|
||||
import { useChoreActions } from './hooks/useChoreActions'
|
||||
@@ -79,7 +77,7 @@ const MyChores = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
|
||||
const { showSuccess, showError, showWarning, showUndo } = useNotification()
|
||||
const { showError, showSuccess, showUndo, showWarning } = useNotification()
|
||||
const queryClient = useQueryClient()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const Navigate = useNavigate()
|
||||
@@ -88,20 +86,19 @@ const MyChores = () => {
|
||||
const { data: projects = [], isLoading: projectsLoading } = useProjects()
|
||||
const {
|
||||
data: choresData,
|
||||
isLoading: choresLoading,
|
||||
isError: choresError,
|
||||
error: choresErrorDetails,
|
||||
isError: choresError,
|
||||
isLoading: choresLoading,
|
||||
refetch: refetchChores,
|
||||
} = useChores(false)
|
||||
const {
|
||||
data: membersData,
|
||||
isLoading: membersLoading,
|
||||
isError: membersError,
|
||||
isLoading: membersLoading,
|
||||
} = useCircleMembers()
|
||||
|
||||
const [chores, setChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
const [choreSections, setChoreSections] = useState([])
|
||||
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
|
||||
// 'voice' | 'scan' | null — set by the quick-capture widget deep links
|
||||
const [addTaskInitialMode, setAddTaskInitialMode] = useState(null)
|
||||
@@ -118,6 +115,9 @@ const MyChores = () => {
|
||||
return {}
|
||||
}
|
||||
})
|
||||
const openSectionsInitializedRef = useRef(
|
||||
localStorage.getItem('openChoreSections') !== null,
|
||||
)
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [viewMode, setViewMode] = useState(
|
||||
localStorage.getItem('choreCardViewMode') || 'default',
|
||||
@@ -126,15 +126,15 @@ const MyChores = () => {
|
||||
const menuRef = useRef(null)
|
||||
const [confirmModelConfig, setConfirmModelConfig] = useState({})
|
||||
|
||||
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
|
||||
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
|
||||
useProjectFilter(projects, !projectsLoading)
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
selectedChoreFilter,
|
||||
nonProjectFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
nonProjectFilteredChores,
|
||||
searchTerm,
|
||||
selectedChoreFilter,
|
||||
setSearchTerm,
|
||||
setSelectedChoreFilterWithCache,
|
||||
} = useChoreFilters({
|
||||
@@ -145,37 +145,37 @@ const MyChores = () => {
|
||||
})
|
||||
|
||||
const {
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
toggleMultiSelectMode,
|
||||
toggleChoreSelection,
|
||||
enterMultiSelectWithChore,
|
||||
selectAllVisibleChores,
|
||||
clearSelection,
|
||||
enterMultiSelectWithChore,
|
||||
getSelectedChoresData,
|
||||
isMultiSelectMode,
|
||||
selectAllVisibleChores,
|
||||
selectedChores,
|
||||
toggleChoreSelection,
|
||||
toggleMultiSelectMode,
|
||||
} = useMultiSelect()
|
||||
|
||||
const { activeModal, modalChore, modalData, openModal, closeModal } =
|
||||
const { activeModal, closeModal, modalChore, modalData, openModal } =
|
||||
useChoreModals()
|
||||
|
||||
const {
|
||||
savedFilters,
|
||||
activeFilter,
|
||||
activeFilterId,
|
||||
applyCustomFilter,
|
||||
applyTempFilter,
|
||||
clearActiveFilter,
|
||||
clearTempFilter,
|
||||
createFilterFromCurrentState,
|
||||
deleteFilter,
|
||||
filteredChores: customFilteredChores,
|
||||
hasFilterApplied,
|
||||
hasProjectConditions,
|
||||
pinFilter,
|
||||
saveFilter,
|
||||
savedFilters,
|
||||
tempFilter,
|
||||
tempFilterMeta,
|
||||
filteredChores: customFilteredChores,
|
||||
applyCustomFilter,
|
||||
clearActiveFilter,
|
||||
applyTempFilter,
|
||||
clearTempFilter,
|
||||
saveFilter,
|
||||
updateFilter,
|
||||
deleteFilter,
|
||||
pinFilter,
|
||||
createFilterFromCurrentState,
|
||||
hasProjectConditions,
|
||||
hasFilterApplied,
|
||||
} = useCustomFilters(
|
||||
nonProjectFilteredChores,
|
||||
membersData?.res,
|
||||
@@ -276,10 +276,10 @@ const MyChores = () => {
|
||||
)
|
||||
|
||||
const {
|
||||
filteredData: quickFilteredChores,
|
||||
setFilter: setQuickFilter,
|
||||
clearAll: clearQuickFilters,
|
||||
filteredData: quickFilteredChores,
|
||||
hasActiveFilters: hasQuickFilters,
|
||||
setFilter: setQuickFilter,
|
||||
} = useFilter(projectFilteredChores, quickFilterDefs)
|
||||
|
||||
const processedChores = useMemo(() => {
|
||||
@@ -301,7 +301,7 @@ const MyChores = () => {
|
||||
return sortedChores
|
||||
}, [choresData?.res, impersonatedUser])
|
||||
|
||||
const processedSections = useMemo(() => {
|
||||
const choreSections = useMemo(() => {
|
||||
if (!chores.length || !userProfile?.id) {
|
||||
return []
|
||||
}
|
||||
@@ -372,7 +372,6 @@ const MyChores = () => {
|
||||
}
|
||||
|
||||
processEffectAsync()
|
||||
// throw new Error('Fake Error to test posthog')
|
||||
}
|
||||
}, [
|
||||
membersLoading,
|
||||
@@ -385,26 +384,16 @@ const MyChores = () => {
|
||||
impersonatedUser?.userId,
|
||||
])
|
||||
|
||||
// Auto-update sections when processedSections changes
|
||||
useEffect(() => {
|
||||
// Always update choreSections to match processedSections, even if empty
|
||||
setChoreSections(processedSections)
|
||||
if (openSectionsInitializedRef.current || choreSections.length === 0) return
|
||||
|
||||
// Auto-open sections if needed - only check localStorage once
|
||||
if (processedSections.length > 0) {
|
||||
const storedSections = localStorage.getItem('openChoreSections')
|
||||
if (storedSections === null) {
|
||||
const openSections = processedSections.reduce(
|
||||
(acc, _section, index) => {
|
||||
acc[index] = true
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
setOpenChoreSections(openSections)
|
||||
}
|
||||
}
|
||||
}, [processedSections])
|
||||
openSectionsInitializedRef.current = true
|
||||
const openSections = choreSections.reduce((acc, _section, index) => {
|
||||
acc[index] = true
|
||||
return acc
|
||||
}, {})
|
||||
setOpenChoreSections(openSections)
|
||||
}, [choreSections])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleMenuOutsideClick)
|
||||
@@ -422,14 +411,29 @@ 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 !== selectedProject?.id) {
|
||||
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
|
||||
if (projectIdFromUrl && projectIdFromUrl !== String(selectedProject?.id)) {
|
||||
const project = projectsWithDefault.find(
|
||||
p => String(p.id) === projectIdFromUrl,
|
||||
)
|
||||
if (project) {
|
||||
setSelectedProjectWithCache(project)
|
||||
}
|
||||
@@ -567,17 +571,17 @@ const MyChores = () => {
|
||||
}, [tempFilterMeta?.id, searchParams])
|
||||
|
||||
const {
|
||||
handleChoreAction,
|
||||
handleChangeDueDate,
|
||||
handleCompleteWithPastDate,
|
||||
handleAssigneeChange,
|
||||
handleCompleteWithNote,
|
||||
handleNudge,
|
||||
handleBulkComplete,
|
||||
handleBulkArchive,
|
||||
handleBulkComplete,
|
||||
handleBulkDelete,
|
||||
handleBulkSkip,
|
||||
handleBulkMoveToProject,
|
||||
handleBulkSkip,
|
||||
handleChangeDueDate,
|
||||
handleChoreAction,
|
||||
handleCompleteWithNote,
|
||||
handleCompleteWithPastDate,
|
||||
handleNudge,
|
||||
} = useChoreActions({
|
||||
chores,
|
||||
filteredChores,
|
||||
@@ -603,24 +607,14 @@ const MyChores = () => {
|
||||
return customFilteredChores
|
||||
}
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
return searchFilteredChores
|
||||
}
|
||||
|
||||
const baseChores = hasQuickFilters
|
||||
? quickFilteredChores
|
||||
: projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
return fuse.search(searchTerm).map(result => result.item)
|
||||
}
|
||||
|
||||
return baseChores
|
||||
}, [
|
||||
activeFilterId,
|
||||
@@ -629,6 +623,7 @@ const MyChores = () => {
|
||||
hasQuickFilters,
|
||||
quickFilteredChores,
|
||||
projectFilteredChores,
|
||||
searchFilteredChores,
|
||||
searchTerm,
|
||||
])
|
||||
|
||||
@@ -751,29 +746,10 @@ const MyChores = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const searchOptions = useMemo(
|
||||
() => ({
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
}),
|
||||
[],
|
||||
)
|
||||
|
||||
const processedChoresForSearch = useMemo(
|
||||
() =>
|
||||
chores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
})),
|
||||
[chores],
|
||||
)
|
||||
|
||||
const fuse = useMemo(
|
||||
() => new Fuse(processedChoresForSearch, searchOptions),
|
||||
[processedChoresForSearch, searchOptions],
|
||||
)
|
||||
const clearTempFilterAndUrl = () => {
|
||||
clearTempFilter()
|
||||
updateFilterUrl(null, null)
|
||||
}
|
||||
|
||||
const handleSearchChange = e => {
|
||||
clearActiveFilter()
|
||||
@@ -790,22 +766,6 @@ const MyChores = () => {
|
||||
|
||||
const term = search.toLowerCase()
|
||||
setSearchTerm(term)
|
||||
|
||||
// Use project-filtered chores as base for search
|
||||
const baseChores = selectedProject ? projectFilteredChores : chores
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
|
||||
setFilteredChores(fuse.search(term).map(result => result.item))
|
||||
// Clear selected calendar date when search changes
|
||||
setSelectedCalendarDate(null)
|
||||
}
|
||||
@@ -814,6 +774,11 @@ 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 => {
|
||||
@@ -895,19 +860,15 @@ const MyChores = () => {
|
||||
// )
|
||||
// }
|
||||
|
||||
const getChoresForDate = useCallback(
|
||||
date => {
|
||||
const filteredChoresData = getFilteredChores
|
||||
return filteredChoresData.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
|
||||
const selectedDate = date.toLocaleDateString()
|
||||
return choreDate === selectedDate
|
||||
})
|
||||
},
|
||||
[getFilteredChores],
|
||||
)
|
||||
const selectedDateChores = useMemo(() => {
|
||||
if (!selectedCalendarDate) return []
|
||||
|
||||
const selectedDate = selectedCalendarDate.toLocaleDateString()
|
||||
return getFilteredChores.filter(chore => {
|
||||
if (!chore.nextDueDate) return false
|
||||
return new Date(chore.nextDueDate).toLocaleDateString() === selectedDate
|
||||
})
|
||||
}, [getFilteredChores, selectedCalendarDate])
|
||||
|
||||
// "Narrowed" means the user actively cut the list down (search, quick
|
||||
// filters, a saved filter). Picking a project is not narrowing: an empty
|
||||
@@ -929,7 +890,6 @@ const MyChores = () => {
|
||||
const appendChore = (prev, newChore) => {
|
||||
let newChores = [...prev, newChore]
|
||||
|
||||
|
||||
if (impersonatedUser) {
|
||||
newChores = newChores.filter(
|
||||
chore => chore.assignedTo === impersonatedUser.userId,
|
||||
@@ -1010,7 +970,7 @@ const MyChores = () => {
|
||||
tempFilter={tempFilter}
|
||||
tempFilterMeta={tempFilterMeta}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
clearTempFilter={clearTempFilterAndUrl}
|
||||
saveFilter={saveFilter}
|
||||
updateFilter={updateFilter}
|
||||
onFilterSaved={name =>
|
||||
@@ -1363,7 +1323,7 @@ const MyChores = () => {
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{getChoresForDate(selectedCalendarDate).length === 0 ? (
|
||||
{selectedDateChores.length === 0 ? (
|
||||
<EmptyState
|
||||
size='sm'
|
||||
icon={<EditCalendar />}
|
||||
@@ -1377,7 +1337,7 @@ const MyChores = () => {
|
||||
/>
|
||||
) : (
|
||||
<ChoreListView
|
||||
chores={getChoresForDate(selectedCalendarDate)}
|
||||
chores={selectedDateChores}
|
||||
viewMode={'compact'}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
@@ -1457,18 +1417,20 @@ const MyChores = () => {
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
{openChoreSections[index] && (
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
onLongPressChore={enterMultiSelectWithChore}
|
||||
/>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
@@ -1574,7 +1536,7 @@ const MyChores = () => {
|
||||
allChores={chores}
|
||||
performers={membersData?.res || []}
|
||||
applyTempFilter={applyTempFilter}
|
||||
clearTempFilter={clearTempFilter}
|
||||
clearTempFilter={clearTempFilterAndUrl}
|
||||
tempFilter={tempFilter}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { CancelRounded } from '@mui/icons-material'
|
||||
import { CancelRounded, SearchRounded } from '@mui/icons-material'
|
||||
import { Box, Input } from '@mui/joy'
|
||||
|
||||
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
|
||||
import { useGlobalSearch } from '../../../search/GlobalSearchContext'
|
||||
|
||||
const SearchBar = ({
|
||||
value,
|
||||
inputRef,
|
||||
onChange,
|
||||
onClose,
|
||||
onFocus,
|
||||
showKeyboardShortcuts,
|
||||
inputRef,
|
||||
value,
|
||||
}) => {
|
||||
const { openSearch } = useGlobalSearch()
|
||||
const handleOpen = () => {
|
||||
onFocus?.()
|
||||
openSearch(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
slotProps={{ input: { ref: inputRef } }}
|
||||
placeholder='Search'
|
||||
slotProps={{ input: { ref: inputRef, readOnly: true } }}
|
||||
placeholder='Search Donetick'
|
||||
value={value}
|
||||
onFocus={onFocus}
|
||||
onFocus={handleOpen}
|
||||
onMouseDown={event => {
|
||||
event.preventDefault()
|
||||
handleOpen()
|
||||
}}
|
||||
fullWidth
|
||||
sx={{
|
||||
mt: 1,
|
||||
@@ -24,17 +36,29 @@ const SearchBar = ({
|
||||
height: 24,
|
||||
borderColor: 'text.disabled',
|
||||
padding: 1,
|
||||
cursor: 'pointer',
|
||||
'& input': { cursor: 'pointer' },
|
||||
}}
|
||||
onChange={onChange}
|
||||
startDecorator={
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
<SearchRounded sx={{ fontSize: 18, color: 'text.secondary' }} />
|
||||
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
|
||||
</Box>
|
||||
}
|
||||
endDecorator={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{value && (
|
||||
<>
|
||||
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
|
||||
<CancelRounded onClick={onClose} />
|
||||
<CancelRounded
|
||||
aria-label='Clear task search'
|
||||
onMouseDown={event => event.stopPropagation()}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -22,23 +22,36 @@ export const useChoreFilters = ({
|
||||
return filterByProject(chores, selectedProject.id)
|
||||
}, [chores, selectedProject])
|
||||
|
||||
const hasSearchTerm = searchTerm.length > 0
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
if (!hasSearchTerm) return null
|
||||
|
||||
const searchableChores = chores.map(chore => ({
|
||||
...chore,
|
||||
raw_label: chore.labelsV2?.map(label => label.name).join(' '),
|
||||
}))
|
||||
return new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
}, [chores, hasSearchTerm])
|
||||
|
||||
const projectChoreIds = useMemo(
|
||||
() => new Set(projectFilteredChores.map(chore => chore.id)),
|
||||
[projectFilteredChores],
|
||||
)
|
||||
|
||||
const searchFilteredChores = useMemo(() => {
|
||||
let baseChores = projectFilteredChores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
|
||||
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
|
||||
if (searchIndex) {
|
||||
return searchIndex
|
||||
.search(searchTerm.toLowerCase())
|
||||
.map(result => result.item)
|
||||
.filter(chore => projectChoreIds.has(chore.id))
|
||||
}
|
||||
|
||||
if (impersonatedUser) {
|
||||
@@ -55,6 +68,8 @@ export const useChoreFilters = ({
|
||||
}, [
|
||||
searchTerm,
|
||||
projectFilteredChores,
|
||||
searchIndex,
|
||||
projectChoreIds,
|
||||
impersonatedUser,
|
||||
userProfile?.id,
|
||||
selectedChoreFilter,
|
||||
@@ -64,20 +79,10 @@ export const useChoreFilters = ({
|
||||
const nonProjectFilteredChores = useMemo(() => {
|
||||
let baseChores = chores
|
||||
|
||||
if (searchTerm?.length > 0) {
|
||||
const searchableChores = baseChores.map(c => ({
|
||||
...c,
|
||||
raw_label: c.labelsV2?.map(l => l.name).join(' '),
|
||||
}))
|
||||
|
||||
const fuse = new Fuse(searchableChores, {
|
||||
keys: ['name', 'raw_label'],
|
||||
includeScore: true,
|
||||
isCaseSensitive: false,
|
||||
findAllMatches: true,
|
||||
})
|
||||
|
||||
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
|
||||
if (searchIndex) {
|
||||
return searchIndex
|
||||
.search(searchTerm.toLowerCase())
|
||||
.map(result => result.item)
|
||||
}
|
||||
|
||||
if (impersonatedUser) {
|
||||
@@ -94,6 +99,7 @@ export const useChoreFilters = ({
|
||||
}, [
|
||||
searchTerm,
|
||||
chores,
|
||||
searchIndex,
|
||||
impersonatedUser,
|
||||
userProfile?.id,
|
||||
selectedChoreFilter,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
|
||||
import { useUserProfile } from '../../../queries/UserQueries'
|
||||
import {
|
||||
applyFilter,
|
||||
getFilterCount,
|
||||
getFilterOverdueCount,
|
||||
validateFilter,
|
||||
} from '../../../utils/FilterEngine'
|
||||
import { applyFilter, validateFilter } from '../../../utils/FilterEngine'
|
||||
import {
|
||||
useCreateFilter,
|
||||
useDeleteFilter,
|
||||
@@ -43,12 +39,17 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
|
||||
|
||||
return filtersData.map(filter => {
|
||||
const validation = validateFilter(filter, context)
|
||||
const count = validation.isValid
|
||||
? getFilterCount(chores, filter, context)
|
||||
: 0
|
||||
const overdueCount = validation.isValid
|
||||
? getFilterOverdueCount(chores, filter, context)
|
||||
: 0
|
||||
const matchingChores = validation.isValid
|
||||
? applyFilter(chores, filter, context)
|
||||
: []
|
||||
const count = matchingChores.length
|
||||
const now = new Date()
|
||||
const overdueCount = matchingChores.reduce((total, chore) => {
|
||||
if (!chore.nextDueDate || new Date(chore.nextDueDate) >= now) {
|
||||
return total
|
||||
}
|
||||
return total + 1
|
||||
}, 0)
|
||||
|
||||
const result = {
|
||||
...filter,
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const useKeyboardShortcuts = ({
|
||||
isMultiSelectMode,
|
||||
selectedChores,
|
||||
addTaskModalOpen,
|
||||
searchTerm,
|
||||
searchFilter,
|
||||
filteredChores,
|
||||
choreSections,
|
||||
openChoreSections,
|
||||
filteredChores,
|
||||
handlers,
|
||||
isMultiSelectMode,
|
||||
openChoreSections,
|
||||
searchFilter,
|
||||
searchTerm,
|
||||
selectedChores,
|
||||
}) => {
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
|
||||
@@ -35,10 +35,6 @@ 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,162 +1,260 @@
|
||||
import { Box, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import Logo from '../../Logo'
|
||||
|
||||
import { Button } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { Box, Button, CircularProgress, Input, Typography } from '@mui/joy'
|
||||
import { useCallback, useEffect, useRef, 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 } = useUserProfile()
|
||||
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 { showError } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
let [searchParams, setSearchParams] = useSearchParams()
|
||||
const [searchParams] = 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 (
|
||||
<Container
|
||||
<Box
|
||||
component='main'
|
||||
maxWidth='xs'
|
||||
|
||||
// make content center in the middle of the page:
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 4,
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
my: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Sheet
|
||||
component='form'
|
||||
<Box sx={{ mb: 2, ...enter(0) }}>
|
||||
<CircleVignette />
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
padding: 2,
|
||||
borderRadius: '8px',
|
||||
boxShadow: 'md',
|
||||
textAlign: 'center',
|
||||
gap: 1.5,
|
||||
mb: 4,
|
||||
...enter(60),
|
||||
}}
|
||||
>
|
||||
<Logo />
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
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>
|
||||
</>
|
||||
{subtitle}
|
||||
</Typography>
|
||||
)}
|
||||
{!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>
|
||||
|
||||
<Box sx={{ ...enter(120) }}>{body}</Box>
|
||||
</Box>
|
||||
|
||||
<AcknowledgmentModal config={ackModalConfig} />
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,15 @@ import {
|
||||
SearchOffRounded,
|
||||
} from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useRouteError } from 'react-router-dom'
|
||||
|
||||
import {
|
||||
collectErrorReport,
|
||||
formatErrorReport,
|
||||
} from '../service/ErrorReportService'
|
||||
import ErrorReportModal from './Modals/ErrorReportModal'
|
||||
|
||||
const getErrorKind = error => {
|
||||
if (!error)
|
||||
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
|
||||
@@ -48,27 +54,30 @@ const safeMessage = error => {
|
||||
return msg
|
||||
}
|
||||
|
||||
const buildErrorText = (error, url) => {
|
||||
const lines = [
|
||||
`URL: ${url}`,
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
`Error: ${safeMessage(error) ?? String(error)}`,
|
||||
]
|
||||
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
const Error = () => {
|
||||
const error = useRouteError()
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [reportOpen, setReportOpen] = useState(false)
|
||||
const [reportText, setReportText] = useState('')
|
||||
|
||||
const { color, Icon } = getErrorKind(error)
|
||||
const { Icon, color } = getErrorKind(error)
|
||||
const message = safeMessage(error)
|
||||
const url = window.location.href
|
||||
|
||||
// The same bundle the report modal sends, so what the user copies and what
|
||||
// we receive can never disagree.
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
collectErrorReport({ error }).then(report => {
|
||||
if (active) setReportText(formatErrorReport(report))
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [error])
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
|
||||
navigator.clipboard.writeText(reportText).then(() => {
|
||||
setCopied(true)
|
||||
})
|
||||
}
|
||||
@@ -209,11 +218,24 @@ const Error = () => {
|
||||
size='lg'
|
||||
startDecorator={<RefreshRounded />}
|
||||
onClick={() => window.location.reload()}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
sx={{ width: '100%', mb: 1.5 }}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
|
||||
{/* Reporting is one tap from the failure, where the context is still
|
||||
fresh — asking people to find it in Settings afterwards never works. */}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
startDecorator={<BugReportRounded />}
|
||||
onClick={() => setReportOpen(true)}
|
||||
sx={{ width: '100%', mb: 2 }}
|
||||
>
|
||||
Report this problem
|
||||
</Button>
|
||||
|
||||
{/* Secondary actions */}
|
||||
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
|
||||
<Button
|
||||
@@ -251,16 +273,7 @@ const Error = () => {
|
||||
textAlign='center'
|
||||
sx={{ color: 'text.tertiary', mb: 1.5 }}
|
||||
>
|
||||
If this keeps happening,{' '}
|
||||
<a
|
||||
href='https://github.com/donetick/donetick/issues/new'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
open an issue
|
||||
</a>{' '}
|
||||
and include the error details below.
|
||||
If this keeps happening, send us a report please consider sending us report so we can take a look.
|
||||
</Typography>
|
||||
|
||||
{(error?.stack || message) && (
|
||||
@@ -313,7 +326,7 @@ const Error = () => {
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{buildErrorText(error, url)}
|
||||
{reportText || 'Collecting error details…'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
@@ -321,6 +334,12 @@ const Error = () => {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<ErrorReportModal
|
||||
open={reportOpen}
|
||||
onClose={() => setReportOpen(false)}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
autoHideDuration={2500}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Card, Grid, List, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
|
||||
import HistoryCard from '../History/HistoryCard'
|
||||
|
||||
const DemoHistory = () => {
|
||||
@@ -7,29 +8,32 @@ const DemoHistory = () => {
|
||||
{
|
||||
id: 32,
|
||||
choreId: 12,
|
||||
completedAt: moment().hour(4).format(),
|
||||
performedAt: moment().hour(4).format(),
|
||||
completedBy: 1,
|
||||
assignedTo: 1,
|
||||
notes: null,
|
||||
dueDate: moment().format(),
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
choreId: 12,
|
||||
completedAt: moment().day(-1).format(),
|
||||
performedAt: moment().day(-1).format(),
|
||||
completedBy: 1,
|
||||
assignedTo: 1,
|
||||
notes: 'Need to be replaced with a new one',
|
||||
dueDate: moment().day(-2).format(),
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
choreId: 12,
|
||||
completedAt: moment().day(-10).hour(1).format(),
|
||||
performedAt: moment().day(-10).hour(1).format(),
|
||||
completedBy: 2,
|
||||
assignedTo: 1,
|
||||
notes: null,
|
||||
dueDate: moment().day(-10).format(),
|
||||
status: 1,
|
||||
},
|
||||
]
|
||||
const performers = [
|
||||
@@ -61,6 +65,7 @@ const DemoHistory = () => {
|
||||
key={index}
|
||||
index={index}
|
||||
performers={performers}
|
||||
pendingCommands={[]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Card, Grid, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ChoreCard from '../Chores/ChoreCard'
|
||||
|
||||
const DemoMyChore = () => {
|
||||
@@ -121,7 +122,12 @@ const DemoMyChore = () => {
|
||||
data-aos-anchor='[data-aos-first-tasks-list]'
|
||||
data-aos='fade-up'
|
||||
>
|
||||
<ChoreCard chore={card} performers={users} viewOnly={true} />
|
||||
<ChoreCard
|
||||
chore={card}
|
||||
performers={users}
|
||||
showActions={false}
|
||||
viewOnly
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Grid>
|
||||
@@ -141,10 +147,10 @@ const DemoMyChore = () => {
|
||||
<Typography level='body-lg' textAlign='center' sx={{ mb: 4 }}>
|
||||
Main view prioritize tasks due today, followed by overdue ones, and
|
||||
finally, future tasks or those without due dates. With Donetick, you
|
||||
can view all the tasks you've created (whether assigned to you or
|
||||
not) as well as tasks assigned to you by others. Quickly mark them
|
||||
as done with just one click, ensuring a smooth and efficient task
|
||||
management experience.
|
||||
can view all the tasks you've created (whether assigned to you
|
||||
or not) as well as tasks assigned to you by others. Quickly mark
|
||||
them as done with just one click, ensuring a smooth and efficient
|
||||
task management experience.
|
||||
</Typography>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Box, Card, Grid, Typography } from '@mui/joy'
|
||||
import { useState } from 'react'
|
||||
|
||||
import RepeatSection from '../ChoreEdit/RepeatSection'
|
||||
|
||||
const DemoScheduler = () => {
|
||||
@@ -32,6 +33,7 @@ const DemoScheduler = () => {
|
||||
OnTriggerValidate={() => {}}
|
||||
isAttemptToSave={false}
|
||||
selectedThing={null}
|
||||
viewOnly
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
|
||||
445
src/views/Modals/ErrorReportModal.jsx
Normal file
445
src/views/Modals/ErrorReportModal.jsx
Normal file
@@ -0,0 +1,445 @@
|
||||
import { Browser } from '@capacitor/browser'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import {
|
||||
BugReportRounded,
|
||||
CheckRounded,
|
||||
ContentCopyRounded,
|
||||
ExpandMoreRounded,
|
||||
GitHub,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Input,
|
||||
Link,
|
||||
Stack,
|
||||
Textarea,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal.js'
|
||||
import {
|
||||
collectErrorReport,
|
||||
formatErrorReport,
|
||||
SUBMIT_RESULT,
|
||||
submitErrorReport,
|
||||
} from '../../service/ErrorReportService'
|
||||
|
||||
const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'
|
||||
|
||||
const enter = (delay = 0) => ({
|
||||
animation: `errorReportIn 420ms ${EASE} ${delay}ms both`,
|
||||
'@keyframes errorReportIn': {
|
||||
from: { opacity: 0, transform: 'translateY(10px)' },
|
||||
to: { opacity: 1, transform: 'none' },
|
||||
},
|
||||
'@media (prefers-reduced-motion: reduce)': { animation: 'none' },
|
||||
})
|
||||
|
||||
const STEP = { FORM: 'form', SENT: 'sent', FALLBACK: 'fallback' }
|
||||
|
||||
// Native webviews swallow target="_blank"; route through the system browser.
|
||||
const openUrl = async url => {
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
await Browser.open({ url })
|
||||
} else {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same halo treatment the onboarding views use, so the crash path doesn't
|
||||
* introduce a third visual language for "here's the thing this screen is about".
|
||||
*/
|
||||
const IconHalo = ({ color = 'primary', icon }) => (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
width: 64,
|
||||
height: 64,
|
||||
mx: 'auto',
|
||||
'&::before': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
width: 110,
|
||||
height: 110,
|
||||
borderRadius: '50%',
|
||||
bgcolor: `${color}.softBg`,
|
||||
opacity: 0.6,
|
||||
filter: 'blur(24px)',
|
||||
},
|
||||
'& > *': { position: 'relative' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: '50%',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
bgcolor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
color: `${color}.plainColor`,
|
||||
'& svg': { fontSize: '1.6rem' },
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
|
||||
/**
|
||||
* Collects a crash report from the error screen: one short answer from the
|
||||
* user, everything else gathered automatically. The diagnostics are shown
|
||||
* before sending rather than after — people are more willing to send a report
|
||||
* they can see, and this is the one moment they already distrust the app.
|
||||
*
|
||||
* Also reached deliberately from settings with no error attached, where the
|
||||
* same diagnostics back a bug the user noticed but the app never threw on.
|
||||
*/
|
||||
const ErrorReportModal = ({ error, errorInfo, onClose, open }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isBugReport = !error
|
||||
|
||||
const [report, setReport] = useState(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [githubUrl, setGithubUrl] = useState(null)
|
||||
const [step, setStep] = useState(STEP.FORM)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(STEP.FORM)
|
||||
setDescription('')
|
||||
setShowDetails(false)
|
||||
setCopied(false)
|
||||
setSubmitting(false)
|
||||
setGithubUrl(null)
|
||||
// Snapshot the environment at open time so it reflects the crash, not
|
||||
// whatever the app looks like after the user has poked at it.
|
||||
collectErrorReport({ error, errorInfo }).then(setReport)
|
||||
}, [open, error, errorInfo])
|
||||
|
||||
const reportText = report ? formatErrorReport(report) : ''
|
||||
|
||||
const copyDetails = () => {
|
||||
navigator.clipboard.writeText(reportText).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!report) return
|
||||
setSubmitting(true)
|
||||
const { githubUrl: url, result } = await submitErrorReport({
|
||||
description,
|
||||
contactEmail: email,
|
||||
report,
|
||||
})
|
||||
setSubmitting(false)
|
||||
|
||||
if (result === SUBMIT_RESULT.SENT) {
|
||||
setStep(STEP.SENT)
|
||||
return
|
||||
}
|
||||
// Everything else — self-hosted, unconfigured, offline — hands the user a
|
||||
// pre-filled issue so the report isn't simply lost.
|
||||
setGithubUrl(url)
|
||||
setStep(STEP.FALLBACK)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveModal open={open} onClose={onClose} size='md'>
|
||||
{step === STEP.FORM && (
|
||||
<Stack spacing={2}>
|
||||
<Box sx={{ ...enter(0) }}>
|
||||
<IconHalo
|
||||
icon={<BugReportRounded />}
|
||||
color={isBugReport ? 'warning' : 'danger'}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ textAlign: 'center', ...enter(50) }}>
|
||||
<Typography
|
||||
level='h4'
|
||||
sx={{ fontWeight: 700, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
{isBugReport ? 'Report a bug' : 'Report this problem'}
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||
>
|
||||
{isBugReport
|
||||
? 'Tell us what went wrong and we’ll attach the technical details for you.'
|
||||
: 'A sentence about what you were doing turns this into something we can actually fix.'}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControl sx={{ ...enter(100) }}>
|
||||
<FormLabel sx={{ fontWeight: 600 }}>
|
||||
{isBugReport ? 'What went wrong?' : 'What were you doing?'}
|
||||
</FormLabel>
|
||||
<Textarea
|
||||
minRows={3}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
isBugReport
|
||||
? 'e.g. Completing a chore from the list doesn’t update the due date'
|
||||
: 'e.g. I tapped a chore in My Chores and the screen went blank'
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl sx={{ ...enter(140) }}>
|
||||
<FormLabel sx={{ fontWeight: 600 }}>
|
||||
Email{' '}
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
(optional — only if you want a reply)
|
||||
</Typography>
|
||||
</FormLabel>
|
||||
<Input
|
||||
type='email'
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder='you@example.com'
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<Box sx={{ ...enter(180) }}>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
onClick={() => setShowDetails(v => !v)}
|
||||
endDecorator={
|
||||
<ExpandMoreRounded
|
||||
sx={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: showDetails ? 'rotate(180deg)' : 'none',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
sx={{ px: 0 }}
|
||||
>
|
||||
{showDetails ? 'Hide' : 'Show'} what gets sent
|
||||
</Button>
|
||||
|
||||
{showDetails && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
mt: 1,
|
||||
p: 1.5,
|
||||
pr: 5,
|
||||
borderRadius: '12px',
|
||||
bgcolor: 'background.level2',
|
||||
maxHeight: 180,
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={copied ? 'success' : 'neutral'}
|
||||
onClick={copyDetails}
|
||||
sx={{ position: 'absolute', top: 6, right: 6, minWidth: 0 }}
|
||||
aria-label='Copy diagnostics'
|
||||
>
|
||||
{copied ? (
|
||||
<CheckRounded fontSize='small' />
|
||||
) : (
|
||||
<ContentCopyRounded fontSize='small' />
|
||||
)}
|
||||
</Button>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontFamily:
|
||||
'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
{reportText || 'Collecting diagnostics…'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'text.tertiary', mt: 1, display: 'block' }}
|
||||
>
|
||||
No chore names, notes or attachments are included.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Stack spacing={1} sx={{ ...enter(220) }}>
|
||||
<Button
|
||||
size='lg'
|
||||
fullWidth
|
||||
loading={submitting}
|
||||
// A crash report stands on its own; a manual one is only the
|
||||
// description, so there's nothing to send without it.
|
||||
disabled={!report || (isBugReport && !description.trim())}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
Send report
|
||||
</Button>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
onClick={onClose}
|
||||
>
|
||||
{isBugReport ? 'Cancel' : 'Not now'}
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === STEP.SENT && (
|
||||
<Stack spacing={2} sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ ...enter(0) }}>
|
||||
<IconHalo icon={<CheckRounded />} color='success' />
|
||||
</Box>
|
||||
<Box sx={{ ...enter(50) }}>
|
||||
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||
Report sent
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', mt: 0.5 }}
|
||||
>
|
||||
Thanks — this goes straight to the people who can fix it.
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
...enter(100),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 1.5,
|
||||
px: 2,
|
||||
py: 1.25,
|
||||
borderRadius: '16px',
|
||||
bgcolor: 'background.surface',
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ textAlign: 'left' }}>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
Reference
|
||||
</Typography>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.06em',
|
||||
}}
|
||||
>
|
||||
{report?.reportId}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
variant='soft'
|
||||
color={copied ? 'success' : 'primary'}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(report?.reportId ?? '')
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}}
|
||||
sx={{ minWidth: 0 }}
|
||||
aria-label='Copy reference'
|
||||
>
|
||||
{copied ? <CheckRounded /> : <ContentCopyRounded />}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Button size='lg' fullWidth onClick={onClose} sx={{ ...enter(150) }}>
|
||||
Done
|
||||
</Button>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{step === STEP.FALLBACK && (
|
||||
<Stack spacing={2} sx={{ textAlign: 'center' }}>
|
||||
<Box sx={{ ...enter(0) }}>
|
||||
<IconHalo icon={<GitHub />} color='neutral' />
|
||||
</Box>
|
||||
<Box sx={{ ...enter(50) }}>
|
||||
<Typography level='h4' sx={{ fontWeight: 700 }}>
|
||||
Finish on GitHub
|
||||
</Typography>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'text.secondary', mt: 0.5, textWrap: 'pretty' }}
|
||||
>
|
||||
Nothing has been sent. We've filled in an issue with your
|
||||
notes and the diagnostics — review it and post when you're
|
||||
happy with it.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={1} sx={{ ...enter(100) }}>
|
||||
<Button
|
||||
size='lg'
|
||||
fullWidth
|
||||
startDecorator={<GitHub />}
|
||||
onClick={() => {
|
||||
openUrl(githubUrl)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
Open pre-filled issue
|
||||
</Button>
|
||||
<Button
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
copied ? <CheckRounded /> : <ContentCopyRounded />
|
||||
}
|
||||
onClick={copyDetails}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy details instead'}
|
||||
</Button>
|
||||
<Box sx={{ textAlign: 'center' }}>
|
||||
<Link
|
||||
component='button'
|
||||
type='button'
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
onClick={onClose}
|
||||
>
|
||||
Close
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
export default ErrorReportModal
|
||||
@@ -6,10 +6,12 @@ import {
|
||||
import { Box, Button, IconButton, Input, Link, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import useAcknowledgmentModal from '../../hooks/useAcknowledgmentModal'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { GetUserCircle, JoinCircle } from '../../utils/Fetcher'
|
||||
import { haptic } from '../../utils/Onboarding'
|
||||
import { clearPendingInvite, getPendingInvite } from '../../utils/PendingInvite'
|
||||
import { authButtonSx } from '../Authorization/authStyles'
|
||||
import AcknowledgmentModal from '../Modals/Inputs/AcknowledgmentModal'
|
||||
import { CircleVignette } from './OnboardingVignettes'
|
||||
@@ -81,10 +83,11 @@ const CircleSetupView = () => {
|
||||
const navigate = useNavigate()
|
||||
const { showNotification } = useNotification()
|
||||
const { ackModalConfig, showAcknowledgment } = useAcknowledgmentModal()
|
||||
const pendingInvite = getPendingInvite()
|
||||
|
||||
const [mode, setMode] = useState('invite')
|
||||
const [mode, setMode] = useState(pendingInvite ? 'join' : 'invite')
|
||||
const [inviteCode, setInviteCode] = useState(null)
|
||||
const [joinCode, setJoinCode] = useState('')
|
||||
const [joinCode, setJoinCode] = useState(pendingInvite ?? '')
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -115,6 +118,7 @@ const CircleSetupView = () => {
|
||||
try {
|
||||
const resp = await JoinCircle(joinCode.trim())
|
||||
if (resp.ok) {
|
||||
clearPendingInvite()
|
||||
showAcknowledgment(
|
||||
"Your join request has been sent! The circle owner will need to approve it before you can see their chores. You'll get a notification once you're in.",
|
||||
'Request Sent',
|
||||
@@ -308,7 +312,11 @@ const CircleSetupView = () => {
|
||||
level='body-sm'
|
||||
color='neutral'
|
||||
underline='hover'
|
||||
onClick={() => setMode('invite')}
|
||||
onClick={() => {
|
||||
clearPendingInvite()
|
||||
setJoinCode('')
|
||||
setMode('invite')
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Delete, Refresh } from '@mui/icons-material'
|
||||
import { Share } from '@capacitor/share'
|
||||
import { CopyAll, Delete, IosShare, Refresh } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -9,15 +10,17 @@ import {
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import {
|
||||
AcceptCircleMemberRequest,
|
||||
DeleteCircleMember,
|
||||
@@ -115,6 +118,36 @@ const CircleSettings = () => {
|
||||
}
|
||||
}, [circleMembers, userProfile])
|
||||
|
||||
const inviteCode = userCircles[0]?.invite_code
|
||||
const apiURL = new URL(apiClient.getApiURL(), window.location.origin)
|
||||
const inviteOrigin =
|
||||
apiURL.hostname === 'api.donetick.com'
|
||||
? 'https://app.donetick.com'
|
||||
: `${apiURL.origin}${apiURL.pathname.replace(/\/api\/v1\/?$/, '')}`
|
||||
const inviteLink = inviteCode
|
||||
? `${inviteOrigin.replace(/\/$/, '')}/circle/join?code=${encodeURIComponent(inviteCode)}`
|
||||
: ''
|
||||
|
||||
const shareInvite = async () => {
|
||||
const circleName = userCircles[0]?.name || 'my Circle'
|
||||
|
||||
try {
|
||||
await Share.share({
|
||||
title: `Join ${circleName} on Donetick`,
|
||||
text: `I'd like to invite you to join ${circleName} on Donetick.`,
|
||||
url: inviteLink,
|
||||
dialogTitle: 'Share Circle invite',
|
||||
})
|
||||
} catch (error) {
|
||||
if (error?.message?.toLowerCase().includes('cancel')) return
|
||||
await navigator.clipboard.writeText(inviteLink)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Invite link copied to clipboard',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!userProfile) {
|
||||
return <LoadingComponent />
|
||||
}
|
||||
@@ -128,84 +161,85 @@ const CircleSettings = () => {
|
||||
link below. You'll receive a notification below when someone requests
|
||||
to join your Circle.
|
||||
</Typography>
|
||||
<Typography level='title-sm' mb={-1}>
|
||||
<Box>
|
||||
<Typography level='title-sm' sx={{ mb: 1 }}>
|
||||
{userCircles[0]?.userRole === 'member'
|
||||
? `You part of ${userCircles[0]?.name} `
|
||||
: `You circle code is:`}
|
||||
|
||||
</Typography>
|
||||
<Input
|
||||
value={userCircles[0]?.invite_code}
|
||||
value={inviteCode}
|
||||
disabled
|
||||
size='lg'
|
||||
sx={{
|
||||
width: '220px',
|
||||
width: { xs: '100%', sm: '220px' },
|
||||
mb: 1,
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant='soft'
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
Copy Code
|
||||
</Button>
|
||||
<Button
|
||||
variant='soft'
|
||||
sx={{ ml: 1 }}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
window.location.protocol +
|
||||
'//' +
|
||||
window.location.host +
|
||||
`/circle/join?code=${userCircles[0]?.invite_code}`,
|
||||
)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Link copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Copy Link
|
||||
</Button>
|
||||
{userCircles.length > 0 && userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
sx={{ ml: 1 }}
|
||||
variant='soft'
|
||||
startDecorator={<CopyAll />}
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to leave your circle?',
|
||||
'Leave Circle',
|
||||
() => {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Left circle successfully',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to leave circle',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Leave',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
navigator.clipboard.writeText(userCircles[0]?.invite_code)
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Code copied to clipboard',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
Copy Code
|
||||
</Button>
|
||||
)}
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
disabled={!inviteLink}
|
||||
startDecorator={<IosShare />}
|
||||
onClick={shareInvite}
|
||||
>
|
||||
Share Invite
|
||||
</Button>
|
||||
{userCircles.length > 0 &&
|
||||
userCircles[0]?.userRole === 'member' && (
|
||||
<Button
|
||||
color='danger'
|
||||
variant='outlined'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to leave your circle?',
|
||||
'Leave Circle',
|
||||
() => {
|
||||
LeaveCircle(userCircles[0]?.id).then(resp => {
|
||||
if (resp.ok) {
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Left circle successfully',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: 'Failed to leave circle',
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
'Leave',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
>
|
||||
Leave Circle
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Typography level='title-md'>Circle Members</Typography>
|
||||
{circleMembers.map(member => (
|
||||
@@ -227,8 +261,7 @@ const CircleSettings = () => {
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Request to join{' '}
|
||||
{fmt.date(member.updatedAt)}
|
||||
Request to join {fmt.date(member.updatedAt)}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AccountCircle,
|
||||
Api,
|
||||
BugReport,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
Code,
|
||||
@@ -35,9 +36,11 @@ import {
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { isParentUser } from '../../utils/UserHelpers'
|
||||
import ErrorReportModal from '../Modals/ErrorReportModal'
|
||||
import FeedbackModal from '../Modals/FeedbackModal'
|
||||
|
||||
const SettingsOverview = () => {
|
||||
@@ -45,6 +48,7 @@ const SettingsOverview = () => {
|
||||
const navigate = useNavigate()
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const [feedbackOpen, setFeedbackOpen] = useState(false)
|
||||
const [bugReportOpen, setBugReportOpen] = useState(false)
|
||||
|
||||
const settingsCards = [
|
||||
{
|
||||
@@ -133,6 +137,13 @@ const SettingsOverview = () => {
|
||||
icon: <Feedback />,
|
||||
onSelect: () => setFeedbackOpen(true),
|
||||
},
|
||||
{
|
||||
id: 'bugreport',
|
||||
title: t('overview.sections.bugReport.title'),
|
||||
description: t('overview.sections.bugReport.description'),
|
||||
icon: <BugReport />,
|
||||
onSelect: () => setBugReportOpen(true),
|
||||
},
|
||||
]
|
||||
|
||||
const handleCardClick = setting => {
|
||||
@@ -387,6 +398,13 @@ const SettingsOverview = () => {
|
||||
open={feedbackOpen}
|
||||
onClose={() => setFeedbackOpen(false)}
|
||||
/>
|
||||
|
||||
{/* No error to pass: the report is about something the user saw, not
|
||||
something the app threw, so the modal collects diagnostics only. */}
|
||||
<ErrorReportModal
|
||||
open={bugReportOpen}
|
||||
onClose={() => setBugReportOpen(false)}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,16 @@ import { Add } from '@mui/icons-material'
|
||||
import { Divider, Menu, MenuItem } from '@mui/joy'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
|
||||
const AutocompleteDropdown = ({
|
||||
currentValue,
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelectSuggestion,
|
||||
onMouseEnterSuggestion, // Added for hover selection
|
||||
onCreateSuggestion, // Called when the "Create new" row is chosen
|
||||
onMouseEnterSuggestion, // Added for hover selection
|
||||
onSelectSuggestion,
|
||||
parentRefer, // Ref to the dropdown element
|
||||
selectedIndex,
|
||||
suggestions,
|
||||
}) => {
|
||||
// Scroll selected item into view
|
||||
const dropdownMenuRef = React.useRef(null)
|
||||
@@ -60,7 +62,7 @@ const AutocompleteDropdown = ({
|
||||
position: 'relative',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
zIndex: 1300,
|
||||
zIndex: Z_INDEX.MODAL_POPOVER,
|
||||
}}
|
||||
>
|
||||
{filteredOptions.map((option, index) => (
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { useCreateChore } from '../../queries/ChoreQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import { CreateLabel } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { generateUUID } from '../../utils/UUID'
|
||||
import { useLabels } from '../Labels/LabelQueries'
|
||||
import { useProjects } from '../Projects/ProjectQueries'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import {
|
||||
parseAssignees,
|
||||
parseDueDate,
|
||||
@@ -21,49 +36,139 @@ import {
|
||||
parsePriority,
|
||||
parseRepeatV2,
|
||||
} from './CustomParsers'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import ModalActions from '../../components/common/ModalActions'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { localAIService } from '../../service/LocalAIService'
|
||||
import { voiceInputService } from '../../service/VoiceInputService'
|
||||
import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors'
|
||||
import AdvancedOptionsSection, {
|
||||
AdvancedOptionsTrigger,
|
||||
} from './AdvancedOptionsSection'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import ScanPanel from './ScanToTask/ScanPanel'
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
import SubTasks from './SubTask'
|
||||
import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
|
||||
import VoicePanel from './VoiceToTask/VoicePanel'
|
||||
// Canonical reminder template shape, shared with NotificationTemplate and
|
||||
// LocalNotificationScheduler: a signed value plus 'm' | 'h' | 'd'. Negative is
|
||||
// before due, positive is after, zero is on due.
|
||||
const DEFAULT_NOTIFICATION_TEMPLATES = [
|
||||
{ value: -1, unit: 'd' },
|
||||
{ value: 0, unit: 'm' },
|
||||
{ value: 1, unit: 'd' },
|
||||
]
|
||||
|
||||
const UNIT_ALIASES = {
|
||||
minute: 'm',
|
||||
minutes: 'm',
|
||||
hour: 'h',
|
||||
hours: 'h',
|
||||
day: 'd',
|
||||
days: 'd',
|
||||
}
|
||||
|
||||
// Earlier builds stored {value: 1, unit: 'days', type: 'before'}. Nothing reads
|
||||
// `type`, and the scheduler's unit switch falls through on 'days', so those
|
||||
// entries fired at the due time (or collided on id) instead of offsetting.
|
||||
const normalizeTemplate = template => {
|
||||
const unit = UNIT_ALIASES[template.unit] || template.unit
|
||||
const value = Number(template.value) || 0
|
||||
if (!template.type) return { value, unit }
|
||||
if (template.type === 'ondue') return { value: 0, unit }
|
||||
return {
|
||||
value: template.type === 'before' ? -Math.abs(value) : Math.abs(value),
|
||||
unit,
|
||||
}
|
||||
}
|
||||
|
||||
const getDefaultNotification = () => {
|
||||
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
|
||||
if (storedDefault) {
|
||||
return JSON.parse(storedDefault)
|
||||
try {
|
||||
const parsed = JSON.parse(storedDefault)
|
||||
if (Array.isArray(parsed)) {
|
||||
// An empty list is a deliberate "no reminders by default", not a
|
||||
// missing value — respect it instead of re-seeding.
|
||||
const normalized = parsed.map(normalizeTemplate)
|
||||
if (JSON.stringify(normalized) !== storedDefault) {
|
||||
localStorage.setItem(
|
||||
'defaultNotificationTemplate',
|
||||
JSON.stringify(normalized),
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
} catch {
|
||||
// fall through and reset to the defaults below
|
||||
}
|
||||
}
|
||||
const defaultNotification = [
|
||||
{ value: 1, unit: 'days', type: 'before' },
|
||||
{ value: 0, unit: 'minutes', type: 'ondue' },
|
||||
{ value: 1, unit: 'days', type: 'after' },
|
||||
]
|
||||
|
||||
localStorage.setItem(
|
||||
'defaultNotificationTemplate',
|
||||
JSON.stringify(defaultNotification),
|
||||
JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES),
|
||||
)
|
||||
return defaultNotification
|
||||
return DEFAULT_NOTIFICATION_TEMPLATES
|
||||
}
|
||||
|
||||
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
if (saved) {
|
||||
try {
|
||||
const project = JSON.parse(saved)
|
||||
return project?.id || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const PRIORITY_LABELS = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// Static option sets for the smart input's trigger suggestions
|
||||
const PRIORITY_SUGGESTIONS = {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
}
|
||||
|
||||
const POINTS_SUGGESTIONS = {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
}
|
||||
|
||||
// Delay between the last keystroke and the smart-input parse. Parsing (chrono
|
||||
// especially) is too heavy to run per keystroke; submitChore flushes a pending
|
||||
// parse so a fast type-then-Enter never creates from stale parsed state.
|
||||
const PARSE_DEBOUNCE_MS = 150
|
||||
|
||||
const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
@@ -93,38 +198,85 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
[queryClient],
|
||||
)
|
||||
|
||||
// Get initial project from localStorage (current active project)
|
||||
const getInitialProject = () => {
|
||||
const saved = localStorage.getItem('selectedProject')
|
||||
if (saved) {
|
||||
try {
|
||||
const project = JSON.parse(saved)
|
||||
return project?.id || 'default'
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
return 'default'
|
||||
}
|
||||
const smartInputSuggestions = useMemo(
|
||||
() => ({
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels || [],
|
||||
creatable: true,
|
||||
onCreate: handleCreateLabel,
|
||||
},
|
||||
'!': PRIORITY_SUGGESTIONS,
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': POINTS_SUGGESTIONS,
|
||||
}),
|
||||
[userLabels, circleMembers, handleCreateLabel],
|
||||
)
|
||||
|
||||
const [taskText, setTaskText] = useState('')
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
// Highlight spans paired with the text they were computed from: the parse
|
||||
// is debounced, so while typing these lag behind taskText
|
||||
const [renderedParts, setRenderedParts] = useState({ text: '', parts: [] })
|
||||
|
||||
// What the smart input overlay shows. While a parse is pending, keep every
|
||||
// highlight span that precedes the edit point and render the rest as plain
|
||||
// text — existing token styles must not flicker away on each keystroke.
|
||||
const displayedParts = useMemo(() => {
|
||||
const { parts, text } = renderedParts
|
||||
if (text === taskText) return parts
|
||||
|
||||
let prefixLen = 0
|
||||
const max = Math.min(text.length, taskText.length)
|
||||
while (prefixLen < max && text[prefixLen] === taskText[prefixLen]) {
|
||||
prefixLen++
|
||||
}
|
||||
|
||||
const kept = []
|
||||
let consumed = 0
|
||||
for (const part of parts) {
|
||||
const partText = typeof part === 'string' ? part : part.props.children
|
||||
if (consumed + partText.length > prefixLen) break
|
||||
kept.push(part)
|
||||
consumed += partText.length
|
||||
}
|
||||
kept.push(taskText.slice(consumed))
|
||||
return kept
|
||||
}, [renderedParts, taskText])
|
||||
|
||||
const richTextEditorRef = useRef(null)
|
||||
const latestRef = useRef({})
|
||||
// Picker edits made on a voice task card, applied once after the reparse
|
||||
// that follows landing the spoken text in the smart input
|
||||
const pendingVoiceOverridesRef = useRef(null)
|
||||
// True while the current assignees came from an @mention in the text, so a
|
||||
// reparse without mentions only resets what a mention set — never a
|
||||
// selection made directly in the assignee picker
|
||||
const assigneesFromMentionRef = useRef(false)
|
||||
// Pending debounced parse of the smart input text, if any
|
||||
const parseTimerRef = useRef(null)
|
||||
// Identities (type + text) of the highlights from the previous parse, so
|
||||
// the appear animation only plays for tokens detected just now
|
||||
const prevHighlightKeysRef = useRef(new Set())
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
const [assignees, setAssignees] = useState([])
|
||||
const [labelsV2, setLabelsV2] = useState([])
|
||||
const [frequency, setFrequency] = useState(null)
|
||||
const [notificationMetadata, setNotificationMetadata] = useState({
|
||||
// Lazy initializers: these read localStorage, which must not happen on
|
||||
// every render
|
||||
const [notificationMetadata, setNotificationMetadata] = useState(() => ({
|
||||
templates: getDefaultNotification(),
|
||||
})
|
||||
}))
|
||||
const [subTasks, setSubTasks] = useState(null)
|
||||
const [points, setPoints] = useState(-1)
|
||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||
@@ -140,13 +292,14 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [projectId, setProjectId] = useState(getInitialProject)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
const [draftId, setDraftId] = useState(() => generateUUID())
|
||||
const [showScan, setShowScan] = useState(false)
|
||||
const [scanAutoCapture, setScanAutoCapture] = useState(false)
|
||||
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
|
||||
const [isAttachingScan, setIsAttachingScan] = useState(false)
|
||||
const [llmAvailable, setLlmAvailable] = useState(false)
|
||||
const [showVoice, setShowVoice] = useState(false)
|
||||
const [voiceAvailable, setVoiceAvailable] = useState(false)
|
||||
@@ -157,6 +310,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
isListening: false,
|
||||
})
|
||||
const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false)
|
||||
// Reminder default the voice cards start from — read once so the array
|
||||
// identity stays stable across renders of the panel
|
||||
const voiceDefaultNotificationTemplates = useMemo(
|
||||
() => getDefaultNotification(),
|
||||
[],
|
||||
)
|
||||
// Same arrangement for the scan panel: it reports the action for its
|
||||
// current phase and the modal footer renders it
|
||||
const [scanState, setScanState] = useState({
|
||||
@@ -164,6 +323,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
primaryAction: null,
|
||||
})
|
||||
const { isNativeScanner } = useDocumentScanner()
|
||||
const { uploadFile } = useFileUpload({
|
||||
entityType: 'chore_attachment_draft',
|
||||
draftId,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
localAIService.isAvailable().then(setLlmAvailable)
|
||||
@@ -195,23 +358,6 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
}, [isModalOpen, initialMode, voiceAvailable, llmAvailable])
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
if (hasDescription && richTextEditorRef.current) {
|
||||
@@ -225,11 +371,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
handleCloseModal,
|
||||
hasDescription,
|
||||
isModalOpen,
|
||||
submitChore,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
@@ -267,7 +413,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
isModalOpen
|
||||
) {
|
||||
event.preventDefault()
|
||||
createChore()
|
||||
submitChore()
|
||||
return
|
||||
}
|
||||
if (event.key === 'Escape' && isModalOpen) {
|
||||
@@ -355,6 +501,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const seenHighlightKeys = new Set()
|
||||
for (const highlight of resolvedHighlights) {
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
@@ -390,10 +537,13 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
)
|
||||
const highlightKey = `${highlight.type}:${highlightedText.toLowerCase()}`
|
||||
const isNewHighlight = !prevHighlightKeysRef.current.has(highlightKey)
|
||||
seenHighlightKeys.add(highlightKey)
|
||||
parts.push(
|
||||
<span
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
className={`${className}${isNewHighlight ? ' highlight-appear' : ''}`}
|
||||
style={{
|
||||
textDecoration: 'underline',
|
||||
textDecorationThickness: '2px',
|
||||
@@ -406,6 +556,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
prevHighlightKeysRef.current = seenHighlightKeys
|
||||
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
@@ -421,14 +572,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
[],
|
||||
)
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
// Rebuilt only when the member list actually changes, so a query refetch
|
||||
// with identical data doesn't re-trigger the parse effect below
|
||||
const assigneesForParsing = useMemo(
|
||||
() =>
|
||||
(circleMembers?.res || []).map(member => ({
|
||||
userId: member.userId,
|
||||
username:
|
||||
member.username ||
|
||||
@@ -436,7 +584,15 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
displayName: member.displayName,
|
||||
name: member.displayName,
|
||||
id: member.userId,
|
||||
}))
|
||||
})),
|
||||
[circleMembers],
|
||||
)
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
const assigneesResult = parseAssignees(sentence, assigneesForParsing)
|
||||
const repeat = parseRepeatV2(sentence)
|
||||
@@ -454,14 +610,18 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||
setIsAnyoneTask(true)
|
||||
setAssignees([])
|
||||
assigneesFromMentionRef.current = true
|
||||
} else if (assigneesResult.result && assigneesResult.result.length > 0) {
|
||||
setIsAnyoneTask(false)
|
||||
const parsedAssignees = assigneesResult.result.map(assignee => ({
|
||||
userId: assignee.userId,
|
||||
}))
|
||||
setAssignees(parsedAssignees)
|
||||
} else {
|
||||
// Only assign to current user if no @ mentions found and userProfile exists
|
||||
assigneesFromMentionRef.current = true
|
||||
} else if (assigneesFromMentionRef.current) {
|
||||
// The @mention that set the current assignees was deleted — fall back
|
||||
// to the implicit self default. Picker selections stay untouched.
|
||||
assigneesFromMentionRef.current = false
|
||||
setIsAnyoneTask(false)
|
||||
if (userProfile?.id) {
|
||||
setAssignees([
|
||||
@@ -499,39 +659,47 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
syncDueDateStates(repeat.dueDate)
|
||||
}
|
||||
|
||||
// Create the cleaned sentence by sequentially applying all cleanups
|
||||
// Create the cleaned sentence by sequentially applying all cleanups.
|
||||
// Each stage only needs a reparse when an earlier cleanup actually
|
||||
// changed the sentence; otherwise the first-pass result (computed on the
|
||||
// identical string) is reused as-is.
|
||||
let cleanedSentence = sentence
|
||||
if (priority.result) cleanedSentence = priority.cleanedSentence
|
||||
if (pointsParsed.result) {
|
||||
// Apply points cleaning to the current cleaned sentence
|
||||
const pointsReparse = parsePoints(cleanedSentence)
|
||||
const pointsReparse =
|
||||
cleanedSentence === sentence
|
||||
? pointsParsed
|
||||
: parsePoints(cleanedSentence)
|
||||
if (pointsReparse.result)
|
||||
cleanedSentence = pointsReparse.cleanedSentence
|
||||
}
|
||||
if (labels.result) {
|
||||
// Apply labels cleaning to the current cleaned sentence
|
||||
const labelsReparse = parseLabels(cleanedSentence, userLabels || [])
|
||||
const labelsReparse =
|
||||
cleanedSentence === sentence
|
||||
? labels
|
||||
: parseLabels(cleanedSentence, userLabels || [])
|
||||
if (labelsReparse.result)
|
||||
cleanedSentence = labelsReparse.cleanedSentence
|
||||
}
|
||||
if (assigneesResult.result) {
|
||||
// Apply assignees cleaning to the current cleaned sentence
|
||||
const assigneesReparse = parseAssignees(
|
||||
cleanedSentence,
|
||||
assigneesForParsing,
|
||||
)
|
||||
const assigneesReparse =
|
||||
cleanedSentence === sentence
|
||||
? assigneesResult
|
||||
: parseAssignees(cleanedSentence, assigneesForParsing)
|
||||
if (assigneesReparse.result)
|
||||
cleanedSentence = assigneesReparse.cleanedSentence
|
||||
}
|
||||
if (repeat.result) {
|
||||
// Apply repeat cleaning to the current cleaned sentence
|
||||
const repeatReparse = parseRepeatV2(cleanedSentence)
|
||||
const repeatReparse =
|
||||
cleanedSentence === sentence ? repeat : parseRepeatV2(cleanedSentence)
|
||||
if (repeatReparse.result)
|
||||
cleanedSentence = repeatReparse.cleanedSentence
|
||||
}
|
||||
if (dueDateParsed.result) {
|
||||
// Apply date cleaning to the current cleaned sentence
|
||||
const dueDateReparse = parseDueDate(cleanedSentence, chrono)
|
||||
const dueDateReparse =
|
||||
cleanedSentence === sentence
|
||||
? dueDateParsed
|
||||
: parseDueDate(cleanedSentence, chrono)
|
||||
if (dueDateReparse.result)
|
||||
cleanedSentence = dueDateReparse.cleanedSentence
|
||||
}
|
||||
@@ -550,7 +718,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
assigneesResult.highlight,
|
||||
)
|
||||
|
||||
setRenderedParts(parts)
|
||||
setRenderedParts({ text: sentence, parts })
|
||||
|
||||
const overrides = pendingVoiceOverridesRef.current
|
||||
if (overrides) {
|
||||
@@ -558,9 +726,15 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
if ('priority' in overrides) setPriority(overrides.priority || 0)
|
||||
if ('frequency' in overrides) setFrequency(overrides.frequency)
|
||||
if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || [])
|
||||
if ('notificationMetadata' in overrides) {
|
||||
setNotificationMetadata(
|
||||
overrides.notificationMetadata || { templates: [] },
|
||||
)
|
||||
}
|
||||
if ('assignees' in overrides || 'isAnyone' in overrides) {
|
||||
setIsAnyoneTask(!!overrides.isAnyone)
|
||||
setAssignees(overrides.assignees || [])
|
||||
assigneesFromMentionRef.current = false
|
||||
}
|
||||
if ('dueDate' in overrides) {
|
||||
if (overrides.dueDate) {
|
||||
@@ -574,7 +748,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
[userLabels, renderHighlightedSentence, circleMembers, userProfile],
|
||||
[userLabels, renderHighlightedSentence, assigneesForParsing, userProfile],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -587,7 +761,16 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
return
|
||||
}
|
||||
|
||||
processText(taskText)
|
||||
// Debounced so fast typing doesn't run the full parse pipeline per
|
||||
// keystroke; submitChore flushes a pending parse before creating.
|
||||
parseTimerRef.current = setTimeout(() => {
|
||||
parseTimerRef.current = null
|
||||
processText(taskText)
|
||||
}, PARSE_DEBOUNCE_MS)
|
||||
return () => {
|
||||
clearTimeout(parseTimerRef.current)
|
||||
parseTimerRef.current = null
|
||||
}
|
||||
}, [
|
||||
taskText,
|
||||
userLabelsLoading,
|
||||
@@ -652,14 +835,41 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
|
||||
const handleEnterPressed = () => {
|
||||
createChore()
|
||||
submitChore()
|
||||
}
|
||||
|
||||
// The scan keeps its source image when asked: upload it against the draft so
|
||||
// the server promotes it onto the chore the same way manual uploads are.
|
||||
const attachScannedImage = async imageSource => {
|
||||
// Creating the chore promotes whatever draft attachments exist at that
|
||||
// moment, so Create waits on this upload rather than orphaning it.
|
||||
setIsAttachingScan(true)
|
||||
try {
|
||||
const file = await imageSourceToFile(
|
||||
imageSource,
|
||||
`scan-${Date.now()}.jpg`,
|
||||
)
|
||||
if (!file) return
|
||||
const uploaded = await uploadFile(file)
|
||||
if (!uploaded) return
|
||||
setAttachments(prev => [
|
||||
...prev,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
} finally {
|
||||
setIsAttachingScan(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskExtracted = ({
|
||||
taskName,
|
||||
attachmentImage,
|
||||
description: extractedDesc,
|
||||
dueDate: extractedDue,
|
||||
taskName,
|
||||
}) => {
|
||||
if (attachmentImage) {
|
||||
attachScannedImage(attachmentImage)
|
||||
}
|
||||
if (taskName) {
|
||||
processText(taskName)
|
||||
}
|
||||
@@ -747,6 +957,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
setVoiceState({ segments: [], isListening: false })
|
||||
setScanState({ phase: 'idle', primaryAction: null })
|
||||
setCreatingVoiceTasks(false)
|
||||
setIsAttachingScan(false)
|
||||
setTaskText('')
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
@@ -760,6 +971,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
setHasSubTasks(false)
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
assigneesFromMentionRef.current = false
|
||||
// The modal closes without a final parse, so drop the highlight identities
|
||||
// here or nothing would animate on the next open
|
||||
prevHighlightKeysRef.current = new Set()
|
||||
setProjectId(getInitialProject())
|
||||
setDeadlineOffset(-1)
|
||||
setRequireApproval(false)
|
||||
@@ -775,6 +990,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
|
||||
const createChore = () => {
|
||||
// A scanned attachment still uploading would be orphaned by the create
|
||||
if (isAttachingScan) return
|
||||
|
||||
// Handle different assignee scenarios
|
||||
let finalAssignees = assignees
|
||||
let finalAssignedTo = null
|
||||
@@ -812,24 +1030,29 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
frequencyMetadata: {},
|
||||
notification: false,
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: draftId,
|
||||
}
|
||||
|
||||
// Reminders are a Plus feature and only make sense when the user kept at
|
||||
// least one template; without the flag the backend never schedules them.
|
||||
const hasReminders =
|
||||
isPlusAccount(userProfile) && notificationMetadata?.templates?.length > 0
|
||||
|
||||
if (frequency) {
|
||||
chore.frequencyType = frequency.frequencyType
|
||||
chore.frequencyMetadata = frequency.frequencyMetadata
|
||||
chore.frequency = frequency.frequency
|
||||
if (isPlusAccount(userProfile)) {
|
||||
chore.notification = true
|
||||
chore.notificationMetadata = notificationMetadata
|
||||
}
|
||||
}
|
||||
if (!frequency && dueDate) {
|
||||
// Use RFC3339/ISO-8601 format expected by backend.
|
||||
chore.nextDueDate = new Date(dueDate).toISOString()
|
||||
}
|
||||
if (hasReminders && (frequency || dueDate)) {
|
||||
chore.notification = true
|
||||
chore.notificationMetadata = notificationMetadata
|
||||
}
|
||||
|
||||
@@ -857,11 +1080,26 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
handleCloseModal(false)
|
||||
}
|
||||
|
||||
// All submit paths (Enter, Cmd+Enter, footer button) go through here: a
|
||||
// debounce may still be holding the parse of the latest text, and creating
|
||||
// from pre-parse state would drop the tail of what the user typed.
|
||||
const submitChore = () => {
|
||||
if (parseTimerRef.current) {
|
||||
clearTimeout(parseTimerRef.current)
|
||||
parseTimerRef.current = null
|
||||
flushSync(() => processText(taskText))
|
||||
}
|
||||
// Read through latestRef: after the flush, this render's createChore
|
||||
// closure is stale
|
||||
latestRef.current.createChore()
|
||||
}
|
||||
|
||||
latestRef.current = {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
submitChore,
|
||||
handleCloseModal,
|
||||
}
|
||||
|
||||
@@ -929,8 +1167,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
onClick={createChore}
|
||||
loading={isAttachingScan}
|
||||
disabled={!taskTitle.trim() || isAttachingScan}
|
||||
onClick={submitChore}
|
||||
>
|
||||
Create
|
||||
{showKeyboardShortcuts && (
|
||||
@@ -943,8 +1182,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
>
|
||||
{!showScan && !showVoice && (
|
||||
<>
|
||||
<Box>
|
||||
<Box
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{/* <Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
@@ -995,7 +1234,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</Box> */}
|
||||
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
@@ -1026,7 +1265,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
setTaskText(text)
|
||||
if (!text) setTaskTitle('')
|
||||
}}
|
||||
customRenderer={renderedParts}
|
||||
customRenderer={displayedParts}
|
||||
onEnterPressed={handleEnterPressed}
|
||||
onShiftEnterPressed={() => {
|
||||
if (!hasDescription) {
|
||||
@@ -1034,45 +1273,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}
|
||||
setTimeout(() => richTextEditorRef.current?.focus(), 50)
|
||||
}}
|
||||
suggestions={{
|
||||
'#': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: userLabels ? userLabels : [],
|
||||
creatable: true,
|
||||
onCreate: handleCreateLabel,
|
||||
},
|
||||
'!': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: 'P1' },
|
||||
{ id: '2', name: 'P2' },
|
||||
{ id: '3', name: 'P3' },
|
||||
{ id: '4', name: 'P4' },
|
||||
],
|
||||
},
|
||||
'@': {
|
||||
value: 'userId',
|
||||
display: 'displayName',
|
||||
options: [
|
||||
{ userId: 'anyone', displayName: 'Anyone' },
|
||||
...(circleMembers?.res || []),
|
||||
],
|
||||
},
|
||||
'*': {
|
||||
value: 'id',
|
||||
display: 'name',
|
||||
options: [
|
||||
{ id: '1', name: '1 point' },
|
||||
{ id: '5', name: '5 points' },
|
||||
{ id: '10', name: '10 points' },
|
||||
{ id: '25', name: '25 points' },
|
||||
{ id: '50', name: '50 points' },
|
||||
{ id: '100', name: '100 points' },
|
||||
],
|
||||
},
|
||||
}}
|
||||
suggestions={smartInputSuggestions}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1114,8 +1315,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
priorityColors={PRIORITY_COLORS}
|
||||
priorityLabels={PRIORITY_LABELS}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
@@ -1194,9 +1395,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Description
|
||||
</Typography>
|
||||
<Typography level='body-sm'>Description</Typography>
|
||||
</Button>
|
||||
)}
|
||||
{!hasSubTasks && (
|
||||
@@ -1219,9 +1418,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
}}
|
||||
>
|
||||
<Add sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-sm' sx={{ color: 'inherit' }}>
|
||||
Subtasks
|
||||
</Typography>
|
||||
<Typography level='body-sm'>Subtasks</Typography>
|
||||
</Button>
|
||||
)}
|
||||
<AdvancedOptionsTrigger
|
||||
@@ -1253,7 +1450,9 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
onAssignStrategyChange={setAssignStrategy}
|
||||
hasDueDate={!!dueDate}
|
||||
hasMultipleAssignees={assignees.length > 1}
|
||||
hasAssignees={assignees.length > 0}
|
||||
// Empty assignees still implicitly assigns the current user at
|
||||
// create time; only an "Anyone" task truly has no assignee
|
||||
hasAssignees={!isAnyoneTask}
|
||||
isPrivate={isPrivate}
|
||||
onIsPrivateChange={setIsPrivate}
|
||||
/>
|
||||
@@ -1291,6 +1490,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
userLabels={voiceLabels}
|
||||
members={voiceMembers}
|
||||
userProfile={userProfile}
|
||||
defaultNotificationTemplates={voiceDefaultNotificationTemplates}
|
||||
onStateChange={setVoiceState}
|
||||
/>
|
||||
)}
|
||||
@@ -1299,6 +1499,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
|
||||
<ScanPanel
|
||||
open
|
||||
autoCapture={scanAutoCapture}
|
||||
canKeepImage={isPlusAccount(userProfile)}
|
||||
onTaskExtracted={handleTaskExtracted}
|
||||
initialImageUrl={pendingPhotoUrl}
|
||||
onStateChange={setScanState}
|
||||
|
||||
@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
import { Person } from '@mui/icons-material'
|
||||
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ANYONE = 'anyone'
|
||||
|
||||
const AssigneePickerField = ({
|
||||
value = null,
|
||||
currentUserId = null,
|
||||
emptyDisplay,
|
||||
includeAnyone = true,
|
||||
isAnyone = false,
|
||||
members = [],
|
||||
onChange,
|
||||
onClear,
|
||||
members = [],
|
||||
includeAnyone = true,
|
||||
emptyDisplay,
|
||||
currentUserId = null,
|
||||
values = [],
|
||||
}) => {
|
||||
const options = [
|
||||
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
|
||||
...(includeAnyone ? [{ userId: ANYONE, displayName: 'Anyone' }] : []),
|
||||
...members.map(member => ({
|
||||
userId: member.userId,
|
||||
displayName: member.displayName || member.username || 'Unknown',
|
||||
})),
|
||||
]
|
||||
|
||||
const displayValue = currentUserId && value === currentUserId ? null : value
|
||||
// An implicit self-assignment is shown as "unset" so the chip stays empty
|
||||
// until the user picks someone explicitly.
|
||||
const isImplicitSelf =
|
||||
!isAnyone &&
|
||||
currentUserId &&
|
||||
values.length === 1 &&
|
||||
values[0] === currentUserId
|
||||
|
||||
const displayValues = isAnyone ? [ANYONE] : isImplicitSelf ? [] : values
|
||||
|
||||
const handleValuesChange = nextValues => {
|
||||
const wasAnyone = displayValues.includes(ANYONE)
|
||||
const hasAnyone = nextValues.includes(ANYONE)
|
||||
|
||||
if (hasAnyone && !wasAnyone) {
|
||||
onChange?.([ANYONE])
|
||||
return
|
||||
}
|
||||
|
||||
onChange?.(nextValues.filter(userId => userId !== ANYONE))
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={displayValue}
|
||||
onChange={onChange}
|
||||
multiple
|
||||
values={displayValues}
|
||||
onValuesChange={handleValuesChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Assignee'
|
||||
@@ -32,9 +57,11 @@ const AssigneePickerField = ({
|
||||
getItemLabel={item => item.displayName}
|
||||
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Assignee' : selectedItems[0].displayName
|
||||
}
|
||||
getTriggerText={({ isEmpty, selectedItems }) => {
|
||||
if (isEmpty) return 'Assignee'
|
||||
if (selectedItems.length === 1) return selectedItems[0].displayName
|
||||
return `${selectedItems.length} assignees`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
|
||||
import {
|
||||
AttachFile,
|
||||
Close,
|
||||
DeleteOutline,
|
||||
DocumentScanner,
|
||||
Image,
|
||||
InsertDriveFile,
|
||||
PhotoCamera,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -9,23 +17,41 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { ClickAwayListener, Popper } from '@mui/material'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Z_INDEX } from '../../constants/zIndex'
|
||||
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
|
||||
import { useFileUpload } from '../../hooks/useFileUpload'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { DeleteDraftAttachment } from '../../utils/Fetcher'
|
||||
import { imageSourceToFile } from '../../utils/FileConvert'
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
|
||||
|
||||
const isImageAttachment = attachment => {
|
||||
const ext = (attachment?.name || '').split('.').pop()?.toLowerCase()
|
||||
return IMAGE_EXTENSIONS.includes(ext)
|
||||
}
|
||||
|
||||
const AttachmentPickerField = ({
|
||||
attachments = [],
|
||||
draftId,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityId,
|
||||
entityType = 'chore_attachment',
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
entityType = 'chore_attachment',
|
||||
entityId,
|
||||
draftId,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
|
||||
const { isNativeScanner, scanDocument } = useDocumentScanner()
|
||||
const { showError } = useNotification()
|
||||
|
||||
// Without a native scanner, `capture` asks a phone for its camera directly.
|
||||
// Desktop browsers ignore it and fall back to the file picker, which would
|
||||
// duplicate "Image", so the button only appears on touch devices.
|
||||
const canTakePhoto = isNativeScanner || navigator.maxTouchPoints > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
@@ -36,27 +62,60 @@ const AttachmentPickerField = ({
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [isOpen])
|
||||
|
||||
const handleAddFile = () => {
|
||||
const upload = async file => {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePickFile = ({ accept, capture } = {}) => {
|
||||
const input = document.createElement('input')
|
||||
input.setAttribute('type', 'file')
|
||||
input.setAttribute('accept', 'image/*')
|
||||
input.click()
|
||||
input.onchange = async () => {
|
||||
if (accept) input.setAttribute('accept', accept)
|
||||
if (capture) input.setAttribute('capture', capture)
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
const uploaded = await uploadFile(file)
|
||||
if (uploaded) {
|
||||
onChange([
|
||||
...attachments,
|
||||
{ url: uploaded.url, path: uploaded.path, name: uploaded.fileName },
|
||||
])
|
||||
}
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
if (file) upload(file)
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Native builds get the OS document scanner (edge detection + perspective
|
||||
// correction); everywhere else "take photo" is the camera roll shortcut.
|
||||
const handleScan = async () => {
|
||||
if (!isNativeScanner) {
|
||||
handlePickFile({ accept: 'image/*', capture: 'environment' })
|
||||
return
|
||||
}
|
||||
const { cancelled, error, image } = await scanDocument()
|
||||
if (cancelled) return
|
||||
if (error || !image) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: error || 'Could not scan the document.',
|
||||
})
|
||||
return
|
||||
}
|
||||
setIsUploading(true)
|
||||
const file = await imageSourceToFile(image, `scan-${Date.now()}.jpg`)
|
||||
setIsUploading(false)
|
||||
if (!file) {
|
||||
showError({
|
||||
title: 'Scan Failed',
|
||||
message: 'Could not read the scanned image.',
|
||||
})
|
||||
return
|
||||
}
|
||||
await upload(file)
|
||||
}
|
||||
|
||||
const handleRemove = async index => {
|
||||
@@ -199,26 +258,30 @@ const AttachmentPickerField = ({
|
||||
'&:hover': { bgcolor: 'background.level1' },
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
{isImageAttachment(attachment) && (
|
||||
<Box
|
||||
component='img'
|
||||
src={attachment.url}
|
||||
alt={attachment.name}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'sm',
|
||||
flexShrink: 0,
|
||||
bgcolor: 'background.level2',
|
||||
}}
|
||||
onError={e => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'none',
|
||||
display: isImageAttachment(attachment)
|
||||
? 'none'
|
||||
: 'flex',
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignItems: 'center',
|
||||
@@ -228,7 +291,15 @@ const AttachmentPickerField = ({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
|
||||
{isImageAttachment(attachment) ? (
|
||||
<Image
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
) : (
|
||||
<InsertDriveFile
|
||||
sx={{ fontSize: 20, color: 'text.tertiary' }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
@@ -255,26 +326,64 @@ const AttachmentPickerField = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
startDecorator={
|
||||
isUploading ? (
|
||||
{isUploading ? (
|
||||
<Button
|
||||
fullWidth
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
disabled
|
||||
startDecorator={
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{ '--CircularProgress-size': '14px' }}
|
||||
/>
|
||||
) : (
|
||||
<AttachFile sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleAddFile}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? 'Uploading…' : 'Add image'}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Uploading…
|
||||
</Button>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
{canTakePhoto && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={
|
||||
isNativeScanner ? (
|
||||
<DocumentScanner sx={{ fontSize: 16 }} />
|
||||
) : (
|
||||
<PhotoCamera sx={{ fontSize: 16 }} />
|
||||
)
|
||||
}
|
||||
onClick={handleScan}
|
||||
>
|
||||
{isNativeScanner ? 'Scan' : 'Photo'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<Image sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile({ accept: 'image/*' })}
|
||||
>
|
||||
Image
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
sx={{ flex: 1 }}
|
||||
startDecorator={<AttachFile sx={{ fontSize: 16 }} />}
|
||||
onClick={() => handlePickFile()}
|
||||
>
|
||||
File
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Sheet>
|
||||
</ClickAwayListener>
|
||||
</Popper>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ListAlt,
|
||||
Logout,
|
||||
MenuRounded,
|
||||
SearchRounded,
|
||||
SettingsOutlined,
|
||||
Toll,
|
||||
Widgets,
|
||||
@@ -23,30 +24,36 @@ import {
|
||||
ListItemDecorator,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useGlobalSearch } from '../../search/GlobalSearchContext'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
|
||||
const publicPages = ['/landing', '/privacy', '/terms']
|
||||
const NavBar = () => {
|
||||
const { t } = useTranslation('common')
|
||||
const { isRTL } = useLocalization()
|
||||
const { data: resource } = useResource()
|
||||
const { openSearch } = useGlobalSearch()
|
||||
|
||||
const navigate = useNavigate()
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: t('navigation.search'),
|
||||
icon: <SearchRounded />,
|
||||
onClick: () => openSearch(),
|
||||
},
|
||||
{
|
||||
to: '/chores',
|
||||
label: t('navigation.allTasks'),
|
||||
@@ -105,6 +112,22 @@ const NavBar = () => {
|
||||
<MenuRounded />
|
||||
</IconButton>
|
||||
)
|
||||
if (location.pathname === '/search') {
|
||||
return (
|
||||
<IconButton
|
||||
size='md'
|
||||
variant='plain'
|
||||
onClick={() => {
|
||||
if (window.history.state?.idx > 0) navigate(-1)
|
||||
else navigate('/chores', { replace: true })
|
||||
}}
|
||||
aria-label='Back from search'
|
||||
title={t('back')}
|
||||
>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
if (!Capacitor.isNativePlatform()) {
|
||||
return menuRounded
|
||||
}
|
||||
@@ -150,6 +173,9 @@ const NavBar = () => {
|
||||
'/onboarding',
|
||||
'/get-started',
|
||||
'/ready',
|
||||
// Reached from an invite link, often signed out: it owns its own shell
|
||||
// and must not mount the avatar's authenticated queries.
|
||||
'/circle/join',
|
||||
].includes(location.pathname)
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -7,13 +7,12 @@ import {
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
const NavBarLink = ({ link }) => {
|
||||
const { to, icon, label } = link
|
||||
const { to, icon, label, onClick } = link
|
||||
return (
|
||||
<ListItem>
|
||||
<ListItemButton
|
||||
key={to}
|
||||
component={Link}
|
||||
to={to}
|
||||
{...(onClick ? { onClick } : { component: Link, to })}
|
||||
variant='plain'
|
||||
color='neutral'
|
||||
sx={{
|
||||
|
||||
@@ -135,10 +135,11 @@ const NotificationPickerField = ({
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={value}
|
||||
minNotifications={0}
|
||||
onChange={({ notifications }) => {
|
||||
latestTemplatesRef.current = notifications
|
||||
}}
|
||||
showTimeline
|
||||
showTimeline={false}
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { useScanToTask } from './useScanToTask'
|
||||
|
||||
/**
|
||||
@@ -27,34 +29,39 @@ import { useScanToTask } from './useScanToTask'
|
||||
* belongs to the capture surface and drives a hidden input in this subtree.
|
||||
*/
|
||||
const ScanPanel = ({
|
||||
open,
|
||||
onTaskExtracted,
|
||||
autoCapture,
|
||||
canKeepImage = false,
|
||||
initialImageUrl,
|
||||
onClose,
|
||||
onStateChange,
|
||||
initialImageUrl,
|
||||
autoCapture,
|
||||
onTaskExtracted,
|
||||
open,
|
||||
}) => {
|
||||
const {
|
||||
isNativeScanner,
|
||||
phase,
|
||||
capturedImage,
|
||||
ocrProgress,
|
||||
taskResult,
|
||||
errorMsg,
|
||||
activate,
|
||||
cameraAvailable,
|
||||
videoRef,
|
||||
canvasRef,
|
||||
fileInputRef,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
capture,
|
||||
capturedImage,
|
||||
errorMsg,
|
||||
fileInputRef,
|
||||
handleFileSelect,
|
||||
handleNativeScan,
|
||||
retake,
|
||||
activate,
|
||||
isNativeScanner,
|
||||
ocrProgress,
|
||||
phase,
|
||||
reset,
|
||||
retake,
|
||||
startCamera,
|
||||
stopCamera,
|
||||
taskResult,
|
||||
videoRef,
|
||||
} = useScanToTask()
|
||||
|
||||
// The scanned page is usually the task's source of truth (the bill, the
|
||||
// notice), so keeping it is the default — the OCR text alone loses it.
|
||||
const [keepImage, setKeepImage] = useState(false)
|
||||
|
||||
// Start/stop based on open state
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -82,7 +89,10 @@ const ScanPanel = ({
|
||||
// Auto-close and populate when done
|
||||
useEffect(() => {
|
||||
if (phase === 'done' && taskResult) {
|
||||
onTaskExtracted(taskResult)
|
||||
onTaskExtracted({
|
||||
...taskResult,
|
||||
attachmentImage: canKeepImage && keepImage ? capturedImage : null,
|
||||
})
|
||||
onClose()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -137,6 +147,18 @@ const ScanPanel = ({
|
||||
|
||||
const isProcessing = phase === 'processing'
|
||||
|
||||
// Attachments are a Plus feature; without it the upload would only ever
|
||||
// surface an upgrade error, so the choice isn't offered at all.
|
||||
const keepImageToggle = !canKeepImage ? null : (
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={keepImage}
|
||||
onChange={e => setKeepImage(e.target.checked)}
|
||||
label='Keep photo as attachment'
|
||||
sx={{ '--Checkbox-size': '18px' }}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{/* ── Capture phase ── */}
|
||||
@@ -201,16 +223,18 @@ const ScanPanel = ({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{/* Hidden when Upload is already the footer's primary action */}
|
||||
{(isNativeScanner || cameraAvailable) && (
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
@@ -220,8 +244,9 @@ const ScanPanel = ({
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
)}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -285,6 +310,9 @@ const ScanPanel = ({
|
||||
sx={{ width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Still editable here — the choice is only read once the task lands */}
|
||||
{keepImageToggle}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -57,6 +57,34 @@
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
/* Played once when a token is first detected: the dashed underline fades in
|
||||
while a soft tint of the token's own color flashes and clears. Keyframe
|
||||
values override the span's inline text-decoration while running. */
|
||||
@keyframes smart-highlight-in {
|
||||
from {
|
||||
text-decoration-color: transparent;
|
||||
background-color: color-mix(in srgb, currentColor 22%, transparent);
|
||||
}
|
||||
60% {
|
||||
background-color: color-mix(in srgb, currentColor 12%, transparent);
|
||||
}
|
||||
to {
|
||||
text-decoration-color: currentColor;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.highlight-appear {
|
||||
border-radius: 4px;
|
||||
animation: smart-highlight-in 450ms ease-out;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.highlight-appear {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.task-input {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -89,17 +89,14 @@ const SmartTaskTitleInput = ({
|
||||
}
|
||||
}, [])
|
||||
const handleSuggestionChange = text => {
|
||||
// if the last word start with '@' or '#' or 'P':
|
||||
const lastWord = text.split(' ').pop()
|
||||
if (
|
||||
lastWord.startsWith('@') ||
|
||||
lastWord.startsWith('#') ||
|
||||
lastWord.startsWith('!')
|
||||
) {
|
||||
// show the menu when the last word starts with a configured trigger
|
||||
// character (e.g. '@', '#', '!', '*')
|
||||
const lastWord = text.split(/\s+/).pop()
|
||||
if (lastWord && suggestions?.[lastWord[0]]) {
|
||||
setSuggestionTrigger(lastWord[0])
|
||||
// last word without the first character:
|
||||
setLastWord(lastWord.slice(1))
|
||||
|
||||
setSelectedSuggestionIndex(0)
|
||||
setShowSuggestions(true)
|
||||
} else {
|
||||
setShowSuggestions(false)
|
||||
@@ -121,6 +118,7 @@ const SmartTaskTitleInput = ({
|
||||
|
||||
const newCursorPosition =
|
||||
cursorPosition - lastWord.length + suggestionValue.length + 1
|
||||
setCursorPosition(newCursorPosition)
|
||||
titleInputRef.current.setSelectionRange(
|
||||
newCursorPosition,
|
||||
newCursorPosition,
|
||||
@@ -376,18 +374,10 @@ const SmartTaskTitleInput = ({
|
||||
const suggestionValue = suggestions[suggestionTrigger].display
|
||||
? suggestion[suggestions[suggestionTrigger].display]
|
||||
: suggestion
|
||||
const newValue = `${value.slice(0, cursorPosition)}${suggestionValue}${value.slice(cursorPosition)}`
|
||||
|
||||
onChange(newValue)
|
||||
// Same insertion path as keyboard selection: replace the partial
|
||||
// word typed after the trigger instead of inserting alongside it
|
||||
titleInputRef?.current?.focus()
|
||||
|
||||
setCursorPosition(cursorPosition + suggestion.length)
|
||||
titleInputRef.current.value = newValue
|
||||
titleInputRef.current.setSelectionRange(
|
||||
cursorPosition + suggestionValue.length,
|
||||
cursorPosition + suggestionValue.length,
|
||||
)
|
||||
setShowSuggestions(false)
|
||||
selectSuggestionText(suggestionValue)
|
||||
}}
|
||||
onCreateSuggestion={name => {
|
||||
selectSuggestionText(name)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GraphicEq,
|
||||
Lock,
|
||||
Mic,
|
||||
NotificationsNone,
|
||||
Person,
|
||||
Repeat,
|
||||
Sell,
|
||||
@@ -16,9 +17,11 @@ import { Box, Button, Chip, IconButton, Input, Typography } from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { TASK_COLOR } from '../../../utils/Colors'
|
||||
import { isPlusAccount } from '../../../utils/Helpers'
|
||||
import AssigneePickerField from '../AssigneePickerField'
|
||||
import DueDatePickerField from '../DueDatePickerField'
|
||||
import LabelsPickerField from '../LabelsPickerField'
|
||||
import NotificationPickerField from '../NotificationPickerField'
|
||||
import PriorityPickerField from '../PriorityPickerField'
|
||||
import RepeatPickerField from '../RepeatPickerField'
|
||||
import { parseVoiceTask } from './parseVoiceTask'
|
||||
@@ -102,7 +105,11 @@ const describeFrequency = f => {
|
||||
return names[f.frequencyType] || 'Repeats'
|
||||
}
|
||||
|
||||
const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
|
||||
const buildChips = (
|
||||
effective,
|
||||
frequencyLabel,
|
||||
{ members, currentUserId, canRemind },
|
||||
) => {
|
||||
const chips = []
|
||||
if (effective.dueDate) {
|
||||
chips.push({
|
||||
@@ -128,6 +135,21 @@ const buildChips = (effective, frequencyLabel, { members, currentUserId }) => {
|
||||
label: `P${effective.priority}`,
|
||||
})
|
||||
}
|
||||
// Mirrors buildChorePayload: reminders only reach the backend for Plus
|
||||
// accounts on a task that has something to remind against
|
||||
const reminderCount = effective.notificationMetadata?.templates?.length || 0
|
||||
if (
|
||||
canRemind &&
|
||||
reminderCount > 0 &&
|
||||
(effective.dueDate || effective.frequency)
|
||||
) {
|
||||
chips.push({
|
||||
key: 'reminders',
|
||||
color: 'neutral',
|
||||
icon: <NotificationsNone sx={{ fontSize: 12 }} />,
|
||||
label: reminderCount > 1 ? `${reminderCount} reminders` : '1 reminder',
|
||||
})
|
||||
}
|
||||
if (effective.points != null) {
|
||||
chips.push({
|
||||
key: 'points',
|
||||
@@ -184,9 +206,17 @@ const TaskPreviewCard = ({
|
||||
[segment.text, parseCtx],
|
||||
)
|
||||
const overrides = useMemo(() => segment.overrides || {}, [segment.overrides])
|
||||
// The parser has no notion of reminders, so the account default stands in
|
||||
// until the card overrides it — same fallback buildChorePayload applies
|
||||
const effective = useMemo(
|
||||
() => ({ ...parsed, ...overrides }),
|
||||
[parsed, overrides],
|
||||
() => ({
|
||||
...parsed,
|
||||
notificationMetadata: {
|
||||
templates: parseCtx.defaultNotificationTemplates || [],
|
||||
},
|
||||
...overrides,
|
||||
}),
|
||||
[parsed, overrides, parseCtx.defaultNotificationTemplates],
|
||||
)
|
||||
|
||||
const frequencyLabel =
|
||||
@@ -329,6 +359,7 @@ const TaskPreviewCard = ({
|
||||
{expanded && (
|
||||
<Box
|
||||
sx={{
|
||||
mt: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1,
|
||||
@@ -392,6 +423,16 @@ const TaskPreviewCard = ({
|
||||
onClear={() => onPatch({ labelIds: [] })}
|
||||
labels={parseCtx.userLabels}
|
||||
/>
|
||||
{parseCtx.canRemind && (
|
||||
<NotificationPickerField
|
||||
emptyDisplay='icon'
|
||||
value={effective.notificationMetadata}
|
||||
onChange={metadata => onPatch({ notificationMetadata: metadata })}
|
||||
onClear={() =>
|
||||
onPatch({ notificationMetadata: { templates: [] } })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -412,6 +453,7 @@ const VoicePanel = ({
|
||||
userLabels = [],
|
||||
members = [],
|
||||
userProfile,
|
||||
defaultNotificationTemplates = [],
|
||||
onStateChange,
|
||||
}) => {
|
||||
const {
|
||||
@@ -430,8 +472,14 @@ const VoicePanel = ({
|
||||
const segmentsScrollRef = useRef(null)
|
||||
|
||||
const parseCtx = useMemo(
|
||||
() => ({ userLabels, members, currentUserId: userProfile?.id }),
|
||||
[userLabels, members, userProfile?.id],
|
||||
() => ({
|
||||
userLabels,
|
||||
members,
|
||||
currentUserId: userProfile?.id,
|
||||
canRemind: isPlusAccount(userProfile),
|
||||
defaultNotificationTemplates,
|
||||
}),
|
||||
[userLabels, members, userProfile, defaultNotificationTemplates],
|
||||
)
|
||||
|
||||
const partialParsed = useMemo(
|
||||
|
||||
@@ -177,24 +177,33 @@ export const buildChorePayload = (
|
||||
status: 0,
|
||||
frequencyType: 'once',
|
||||
frequencyMetadata: {},
|
||||
notification: false,
|
||||
notificationMetadata: {},
|
||||
subTasks: null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
draftId: generateUUID(),
|
||||
}
|
||||
|
||||
// A per-task override from the voice card wins over the account default;
|
||||
// an override of [] is a deliberate "no reminders", not a missing value.
|
||||
const templates =
|
||||
parsed.notificationMetadata?.templates ?? notificationTemplates
|
||||
|
||||
// Reminders are a Plus feature; the flag is what makes the backend schedule
|
||||
// them, so metadata alone is not enough.
|
||||
const hasReminders = isPlusAccount(userProfile) && templates?.length > 0
|
||||
|
||||
if (parsed.frequency) {
|
||||
chore.frequencyType = parsed.frequency.frequencyType
|
||||
chore.frequencyMetadata = parsed.frequency.frequencyMetadata
|
||||
chore.frequency = parsed.frequency.frequency
|
||||
if (isPlusAccount(userProfile)) {
|
||||
chore.notification = true
|
||||
chore.notificationMetadata = { templates: notificationTemplates }
|
||||
}
|
||||
}
|
||||
if (!parsed.frequency && parsed.dueDate) {
|
||||
chore.nextDueDate = new Date(parsed.dueDate).toISOString()
|
||||
chore.notificationMetadata = { templates: notificationTemplates }
|
||||
}
|
||||
if (hasReminders && (parsed.frequency || parsed.dueDate)) {
|
||||
chore.notification = true
|
||||
chore.notificationMetadata = { templates }
|
||||
}
|
||||
|
||||
return chore
|
||||
|
||||
Reference in New Issue
Block a user