33 Commits

Author SHA1 Message Date
Mo Tarbin
c474041c7c Release 1.2.45
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-10 01:40:04 -04:00
Mohamad Tarbin
c649e051d8 Merge pull request #201 from donetick/search-improvments
Search improvments
2026-08-10 01:19:43 -04:00
Mohamad Tarbin
207888affe Merge pull request #198 from donetick/share-with
Share with for circle
2026-08-10 01:19:33 -04:00
Mo Tarbin
5f0c5cdc5d Implement onboarding flow and circle invite handling with new utilities 2026-08-10 01:14:31 -04:00
Mo Tarbin
5b7594ac64 Add apple-app-site-association and assetlinks configuration for app linking 2026-08-10 01:13:06 -04:00
Mo Tarbin
90a38bf091 Add Capacitor Share integration and implement Circle invite sharing feature 2026-08-10 01:13:06 -04:00
Mo Tarbin
eecfc98ebc Refactor ArchivedTasks component: reorder imports, adjust notification hooks, and clean up unused code 2026-08-10 00:55:31 -04:00
Mohamad Tarbin
6428239be2 Merge pull request #200 from donetick/0809-fixes
Fix autocomplete issues and enhance task input handling
2026-08-10 00:54:52 -04:00
Mo Tarbin
4c6e244069 fix: clean up unused imports and simplify component structure in AddTaskModal 2026-08-10 00:53:40 -04:00
Mo Tarbin
af926b033e improve keyboard shortcuts handling 2026-08-10 00:19:57 -04:00
Mo Tarbin
b7bdfa7885 Implement global search feature with context, UI components, and routing updates 2026-08-10 00:16:38 -04:00
Mo Tarbin
309fd32d50 Enhance task input highlighting with smooth appearance animation and improved CSS keyframes for better visual feedback. 2026-08-09 13:19:32 -04:00
Mo Tarbin
2442b0c441 Fix: autocomplete not visible. refactor: streamline task input handling and improve smart input suggestions and and it's performance 2026-08-09 13:19:16 -04:00
Mohamad Tarbin
5d8095bd9c Merge pull request #199 from donetick/0809-fixes
0809 fixes
2026-08-09 13:17:08 -04:00
Mo Tarbin
1a8aeb84cc fix: if user type @Ni and then click Nick then what display was NiNick
fix: if user select assignee keep then if we don't see assignee in the title
2026-08-09 12:28:12 -04:00
Mo Tarbin
b2529a0091 fix autocomplete in addtaskmodal not displaying 2026-08-09 12:22:30 -04:00
Mo Tarbin
841f83d6ae Enhance file upload functionality with document scanning support and improved error handling 2026-08-09 12:00:30 -04:00
Mo Tarbin
731c46b26a Refactor AssigneePickerField to improve value handling and enhance UI/UX for assignee selection 2026-08-09 11:50:51 -04:00
Mo Tarbin
612924e7c3 Add theme background support for light and dark modes in StatusBarManager and ThemeContext
Fix: https://github.com/donetick/donetick/issues/759
2026-08-09 11:50:51 -04:00
Mo Tarbin
78760ceebb Refactor status bar theme handling, attempt to remove the black spaces 2026-08-09 11:50:51 -04:00
Mo Tarbin
e05aa2d169 JoinCircleView: enhance UI/UX with improved messaging, animations, and layout adjustments 2026-08-08 16:01:10 -04:00
Mo Tarbin
1ddf2539e0 Improve circle joining experiance 2026-08-08 13:40:21 -04:00
Mo Tarbin
83a5760959 Enhance error reporting functionality: add bug report option in settings, update error report modal, and refine feedback descriptions 2026-08-07 20:25:06 -04:00
Mo Tarbin
0215a78404 Release 1.2.38
Some checks failed
Build validation / build (push) Has been cancelled
2026-08-07 00:43:15 -04:00
Mohamad Tarbin
475806ac29 Error reporting (#196)
* implement error reporting modal and service integration

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

[ci skip]

* New translations settings.json (French)

[ci skip]

* New translations common.json (Spanish)

[ci skip]

* New translations settings.json (Spanish)

[ci skip]

* New translations common.json (Arabic)

[ci skip]

* New translations settings.json (Arabic)

[ci skip]

* New translations settings.json (German)

[ci skip]

* New translations common.json (Japanese)

[ci skip]

* New translations settings.json (Japanese)

[ci skip]

* New translations common.json (Dutch)

[ci skip]

* New translations settings.json (Dutch)

[ci skip]

* New translations common.json (Chinese Simplified)

[ci skip]

* New translations settings.json (Chinese Simplified)

[ci skip]

* New translations common.json (Portuguese, Brazilian)

[ci skip]

* New translations settings.json (Portuguese, Brazilian)

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

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

* making sure the filter clear when it supposed to

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

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

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

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

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

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

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

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

View File

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

View File

@@ -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')

View File

@@ -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" />

View File

@@ -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')

View File

@@ -462,12 +462,12 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 57;
CURRENT_PROJECT_VERSION = 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;

View File

@@ -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>

View File

@@ -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'

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "6UJJ78R3BS.com.donetick.app",
"paths": ["/circle/join*"]
}
]
}
}

View File

@@ -0,0 +1,11 @@
{
"applinks": {
"apps": [],
"details": [
{
"appID": "6UJJ78R3BS.com.donetick.app",
"paths": ["/circle/join*"]
}
]
}
}

View 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
View 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
View File

@@ -0,0 +1 @@
/.well-known/apple-app-site-association /.well-known/apple-app-site-association.json 200

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@
"logout": "Logout",
"version": "Version",
"navigation": {
"search": "Search",
"allTasks": "All Tasks",
"archived": "Archived",
"things": "Things",

View File

@@ -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."
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,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
View File

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

View File

@@ -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
View File

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

View File

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

View File

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

View File

@@ -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',
}

View File

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

View File

@@ -1,3 +1,5 @@
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import Error from '@/views/Error'
@@ -10,7 +12,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 />,

View File

@@ -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,
},
},

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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) {

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
GetAllCircleMembers,
GetAllUsers,
@@ -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,

View 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
}

View 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

View 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

View 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}`,
}),
),
})

View File

@@ -0,0 +1,189 @@
/**
* Ambient session state worth having the moment something breaks: how long the
* user has been in the app, how they got to the screen that failed, which
* server they were talking to and what it had already refused.
*
* Deliberately dependency-free — ApiClient imports it on the request path, so
* anything imported here would risk a module cycle. Everything is in memory
* and dies with the tab; nothing is persisted.
*/
const SESSION_STARTED_AT = Date.now()
const SESSION_ID = `${SESSION_STARTED_AT.toString(36)}-${Math.random()
.toString(36)
.slice(2, 8)}`
// A cold start (vs. a reload of an already-running app) changes what a crash
// means: a reload loop looks very different from a first-launch failure.
const NAVIGATION_TYPE =
performance.getEntriesByType?.('navigation')?.[0]?.type ?? 'unknown'
const MAX_ROUTES = 10
const MAX_API_FAILURES = 8
const routeTrail = []
const apiFailures = []
let backgroundedCount = 0
let serverVersion = null
// ---------------------------------------------------------------------------
// Route trail
// ---------------------------------------------------------------------------
/**
* Records a navigation and closes out the dwell time on the previous screen.
* "The crash happened 400ms after landing on /chores/12 from /chores" is a
* far better bug report than "the crash happened on /chores/12".
*/
export const recordRoute = path => {
if (!path) return
const now = Date.now()
const previous = routeTrail[routeTrail.length - 1]
if (previous) {
if (previous.path === path) return
previous.dwellMs = now - previous.at
}
routeTrail.push({ path, at: now })
if (routeTrail.length > MAX_ROUTES) routeTrail.shift()
}
export const getRouteTrail = () => {
const now = Date.now()
return routeTrail.map((entry, index) => ({
path: entry.path,
// The current screen has no closing dwell yet; measure it up to now.
dwellMs:
entry.dwellMs ??
(index === routeTrail.length - 1 ? now - entry.at : null),
msAgo: now - entry.at,
}))
}
/** The screen the user came from, which is usually where the bug was planted. */
export const getPreviousRoute = () =>
routeTrail.length > 1 ? routeTrail[routeTrail.length - 2].path : null
// ---------------------------------------------------------------------------
// Server identity
// ---------------------------------------------------------------------------
/**
* Picks the server build out of response headers. Costs nothing when the
* server doesn't send them — the field simply stays null.
*/
export const recordServerVersionFromResponse = response => {
if (serverVersion) return
try {
serverVersion =
response?.headers?.get?.('x-donetick-version') ||
response?.headers?.get?.('x-api-version') ||
null
} catch {
// headers may be inaccessible on opaque responses; not worth reporting
}
}
export const setServerVersion = version => {
if (version) serverVersion = version
}
export const getServerVersion = () => serverVersion
// ---------------------------------------------------------------------------
// API failures
// ---------------------------------------------------------------------------
/** Strips ids and query strings so failures group by endpoint, not by row. */
const normalizeEndpoint = endpoint =>
String(endpoint || '')
.split('?')[0]
.replace(/\/\d+/g, '/:id')
export const recordApiFailure = ({ endpoint, method, status }) => {
apiFailures.push({
at: Date.now(),
method: method || 'GET',
endpoint: normalizeEndpoint(endpoint),
status: status ?? 'network',
})
if (apiFailures.length > MAX_API_FAILURES) apiFailures.shift()
}
export const getApiFailures = () => {
const now = Date.now()
return apiFailures.map(failure => ({
method: failure.method,
endpoint: failure.endpoint,
status: failure.status,
msAgo: now - failure.at,
}))
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') backgroundedCount += 1
})
}
// ---------------------------------------------------------------------------
// Snapshot
// ---------------------------------------------------------------------------
const getServiceWorkerState = async () => {
if (!('serviceWorker' in navigator)) return { supported: false }
try {
const registration = await navigator.serviceWorker.getRegistration()
return {
supported: true,
controlled: Boolean(navigator.serviceWorker.controller),
// A waiting worker means the user is running a stale bundle against a
// newer deploy — the usual cause of chunk-load failures after a release.
updateWaiting: Boolean(registration?.waiting),
}
} catch {
return { supported: true, controlled: null, updateWaiting: null }
}
}
const getStorageState = async () => {
try {
const { quota, usage } = await navigator.storage.estimate()
return {
usageMb: Math.round((usage / 1048576) * 10) / 10,
quotaMb: Math.round(quota / 1048576),
// Storage pressure produces failures that look like anything but.
pressure: quota ? Math.round((usage / quota) * 100) : null,
}
} catch {
return null
}
}
export const getSessionDiagnostics = async () => {
const [serviceWorker, storage] = await Promise.all([
getServiceWorkerState(),
getStorageState(),
])
return {
sessionId: SESSION_ID,
sessionStartedAt: new Date(SESSION_STARTED_AT).toISOString(),
sessionDurationMs: Date.now() - SESSION_STARTED_AT,
navigationType: NAVIGATION_TYPE,
backgroundedCount,
serverVersion,
previousRoute: getPreviousRoute(),
routeTrail: getRouteTrail(),
apiFailures: getApiFailures(),
// Chromium only; absent elsewhere rather than faked.
heapUsedMb: performance.memory
? Math.round(performance.memory.usedJSHeapSize / 1048576)
: null,
storage,
serviceWorker,
}
}

View File

@@ -0,0 +1,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 }),
}
}

View File

@@ -1,6 +1,11 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import {
recordApiFailure,
recordServerVersionFromResponse,
} from '../service/DiagnosticsSession'
import { logout, RefreshToken } from './Fetcher'
import { isOAuthExchangeInProgress } from './OAuthExchangeState'
import { offlineDB } from './OfflineDB'
@@ -129,7 +134,7 @@ class ApiClient {
// Process queued requests after refresh attempt
processQueue(error, token = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
this.failedQueue.forEach(({ reject, resolve }) => {
if (error) {
reject(error)
} else {
@@ -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

View File

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

17
src/utils/FileConvert.js Normal file
View 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
}
}

View 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)
}
}

View File

@@ -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)
}
/**

View File

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

View File

@@ -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 well 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 />}

View File

@@ -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 well send your circle join request right after.'
: 'Track chores and tasks together, in one shared place.'
}
footer={<LegalLinks />}
logoSize={0}
>

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,9 +4,9 @@ import {
CalendarMonth,
CloudOff,
EditCalendar,
SearchOff,
ExpandCircleDown,
PriorityHigh,
SearchOff,
Style,
} from '@mui/icons-material'
import {
@@ -20,44 +20,42 @@ import {
IconButton,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import EmptyState from '../../components/common/EmptyState'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import {
ChoreFilters,
ChoresGrouper,
ChoreSorter,
filterByProject,
} from '../../utils/Chores'
import Priorities from '../../utils/Priorities'
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import FeedbackPrompt from '../components/FeedbackPrompt.jsx'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreModals from './components/ChoreModals'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import {
conditionsToSelections,
selectionsToConditions,
} from './components/FilterBuilderContent'
import ChoreModals from './components/ChoreModals'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import { useChoreActions } from './hooks/useChoreActions'
@@ -79,7 +77,7 @@ const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning, showUndo } = useNotification()
const { showError, showSuccess, showUndo, showWarning } = useNotification()
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const Navigate = useNavigate()
@@ -88,20 +86,19 @@ const MyChores = () => {
const { data: projects = [], isLoading: projectsLoading } = useProjects()
const {
data: choresData,
isLoading: choresLoading,
isError: choresError,
error: choresErrorDetails,
isError: choresError,
isLoading: choresLoading,
refetch: refetchChores,
} = useChores(false)
const {
data: membersData,
isLoading: membersLoading,
isError: membersError,
isLoading: membersLoading,
} = useCircleMembers()
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
// 'voice' | 'scan' | null — set by the quick-capture widget deep links
const [addTaskInitialMode, setAddTaskInitialMode] = useState(null)
@@ -118,6 +115,9 @@ const MyChores = () => {
return {}
}
})
const openSectionsInitializedRef = useRef(
localStorage.getItem('openChoreSections') !== null,
)
const [anchorEl, setAnchorEl] = useState(null)
const [viewMode, setViewMode] = useState(
localStorage.getItem('choreCardViewMode') || 'default',
@@ -126,15 +126,15 @@ const MyChores = () => {
const menuRef = useRef(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
const { projectsWithDefault, selectedProject, setSelectedProjectWithCache } =
useProjectFilter(projects, !projectsLoading)
const {
searchTerm,
selectedChoreFilter,
nonProjectFilteredChores,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
searchTerm,
selectedChoreFilter,
setSearchTerm,
setSelectedChoreFilterWithCache,
} = useChoreFilters({
@@ -145,37 +145,37 @@ const MyChores = () => {
})
const {
isMultiSelectMode,
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores,
clearSelection,
enterMultiSelectWithChore,
getSelectedChoresData,
isMultiSelectMode,
selectAllVisibleChores,
selectedChores,
toggleChoreSelection,
toggleMultiSelectMode,
} = useMultiSelect()
const { activeModal, modalChore, modalData, openModal, closeModal } =
const { activeModal, closeModal, modalChore, modalData, openModal } =
useChoreModals()
const {
savedFilters,
activeFilter,
activeFilterId,
applyCustomFilter,
applyTempFilter,
clearActiveFilter,
clearTempFilter,
createFilterFromCurrentState,
deleteFilter,
filteredChores: customFilteredChores,
hasFilterApplied,
hasProjectConditions,
pinFilter,
saveFilter,
savedFilters,
tempFilter,
tempFilterMeta,
filteredChores: customFilteredChores,
applyCustomFilter,
clearActiveFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
hasFilterApplied,
} = useCustomFilters(
nonProjectFilteredChores,
membersData?.res,
@@ -276,10 +276,10 @@ const MyChores = () => {
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
filteredData: quickFilteredChores,
hasActiveFilters: hasQuickFilters,
setFilter: setQuickFilter,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
@@ -301,7 +301,7 @@ const MyChores = () => {
return sortedChores
}, [choresData?.res, impersonatedUser])
const processedSections = useMemo(() => {
const choreSections = useMemo(() => {
if (!chores.length || !userProfile?.id) {
return []
}
@@ -372,7 +372,6 @@ const MyChores = () => {
}
processEffectAsync()
// throw new Error('Fake Error to test posthog')
}
}, [
membersLoading,
@@ -385,26 +384,16 @@ const MyChores = () => {
impersonatedUser?.userId,
])
// Auto-update sections when processedSections changes
useEffect(() => {
// Always update choreSections to match processedSections, even if empty
setChoreSections(processedSections)
if (openSectionsInitializedRef.current || choreSections.length === 0) return
// Auto-open sections if needed - only check localStorage once
if (processedSections.length > 0) {
const storedSections = localStorage.getItem('openChoreSections')
if (storedSections === null) {
const openSections = processedSections.reduce(
(acc, _section, index) => {
acc[index] = true
return acc
},
{},
)
setOpenChoreSections(openSections)
}
}
}, [processedSections])
openSectionsInitializedRef.current = true
const openSections = choreSections.reduce((acc, _section, index) => {
acc[index] = true
return acc
}, {})
setOpenChoreSections(openSections)
}, [choreSections])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
@@ -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}
/>

View File

@@ -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>

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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>
)
}

View File

@@ -10,9 +10,15 @@ import {
SearchOffRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { Link, useRouteError } from 'react-router-dom'
import {
collectErrorReport,
formatErrorReport,
} from '../service/ErrorReportService'
import ErrorReportModal from './Modals/ErrorReportModal'
const getErrorKind = error => {
if (!error)
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
@@ -48,27 +54,30 @@ const safeMessage = error => {
return msg
}
const buildErrorText = (error, url) => {
const lines = [
`URL: ${url}`,
`Time: ${new Date().toISOString()}`,
`Error: ${safeMessage(error) ?? String(error)}`,
]
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
return lines.join('\n')
}
const Error = () => {
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const [reportOpen, setReportOpen] = useState(false)
const [reportText, setReportText] = useState('')
const { color, Icon } = getErrorKind(error)
const { Icon, color } = getErrorKind(error)
const message = safeMessage(error)
const url = window.location.href
// The same bundle the report modal sends, so what the user copies and what
// we receive can never disagree.
useEffect(() => {
let active = true
collectErrorReport({ error }).then(report => {
if (active) setReportText(formatErrorReport(report))
})
return () => {
active = false
}
}, [error])
const handleCopy = () => {
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
navigator.clipboard.writeText(reportText).then(() => {
setCopied(true)
})
}
@@ -209,11 +218,24 @@ const Error = () => {
size='lg'
startDecorator={<RefreshRounded />}
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 2 }}
sx={{ width: '100%', mb: 1.5 }}
>
Try again
</Button>
{/* Reporting is one tap from the failure, where the context is still
fresh — asking people to find it in Settings afterwards never works. */}
<Button
variant='outlined'
color='neutral'
size='lg'
startDecorator={<BugReportRounded />}
onClick={() => setReportOpen(true)}
sx={{ width: '100%', mb: 2 }}
>
Report this problem
</Button>
{/* Secondary actions */}
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
<Button
@@ -251,16 +273,7 @@ const Error = () => {
textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }}
>
If this keeps happening,{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
target='_blank'
rel='noopener noreferrer'
style={{ textDecoration: 'underline' }}
>
open an issue
</a>{' '}
and include the error details below.
If this keeps happening, send us a report please consider sending us report so we can take a look.
</Typography>
{(error?.stack || message) && (
@@ -313,7 +326,7 @@ const Error = () => {
color: 'text.secondary',
}}
>
{buildErrorText(error, url)}
{reportText || 'Collecting error details…'}
</Typography>
</Box>
)}
@@ -321,6 +334,12 @@ const Error = () => {
)}
</Box>
<ErrorReportModal
open={reportOpen}
onClose={() => setReportOpen(false)}
error={error}
/>
<Snackbar
open={copied}
autoHideDuration={2500}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 well 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 doesnt 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&apos;ve filled in an issue with your
notes and the diagnostics review it and post when you&apos;re
happy with it.
</Typography>
</Box>
<Stack spacing={1} sx={{ ...enter(100) }}>
<Button
size='lg'
fullWidth
startDecorator={<GitHub />}
onClick={() => {
openUrl(githubUrl)
onClose()
}}
>
Open pre-filled issue
</Button>
<Button
variant='plain'
color='neutral'
startDecorator={
copied ? <CheckRounded /> : <ContentCopyRounded />
}
onClick={copyDetails}
>
{copied ? 'Copied' : 'Copy details instead'}
</Button>
<Box sx={{ textAlign: 'center' }}>
<Link
component='button'
type='button'
level='body-sm'
color='neutral'
underline='hover'
onClick={onClose}
>
Close
</Link>
</Box>
</Stack>
</Stack>
)}
</ResponsiveModal>
)
}
export default ErrorReportModal

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)
}

View File

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

View File

@@ -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}

View File

@@ -90,7 +90,6 @@ export const AdvancedOptionsTrigger = ({
<Typography
level='body-sm'
sx={{
color: 'inherit',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',

View File

@@ -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}
/>
)

View File

@@ -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>

View File

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

View File

@@ -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={{

View File

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

View File

@@ -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>
)}

View File

@@ -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%;

View File

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

View File

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

View File

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