Merge branch 'main' into fix-notification-typo

This commit is contained in:
Mohamad Tarbin
2026-01-25 19:58:31 -05:00
committed by GitHub
193 changed files with 39749 additions and 11104 deletions

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
fund=false
audit=false

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

View File

@@ -7,8 +7,8 @@ android {
applicationId "com.donetick.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode 13
versionName "1.0.4"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -9,13 +9,18 @@ android {
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-community-sqlite')
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-device')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-social-login')
implementation project(':revenuecat-purchases-capacitor')
implementation project(':revenuecat-purchases-capacitor-ui')
implementation project(':capacitor-plugin-safe-area')
}

View File

@@ -6,7 +6,8 @@
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
@@ -30,6 +31,10 @@
</application>
<!-- Permissions -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -2,15 +2,24 @@
include ':capacitor-android'
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
include ':capacitor-community-sqlite'
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
include ':capacitor-app'
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
include ':capacitor-browser'
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
include ':capacitor-network'
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
include ':capacitor-preferences'
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
@@ -23,5 +32,11 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
include ':capgo-capacitor-social-login'
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
include ':revenuecat-purchases-capacitor'
project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android')
include ':revenuecat-purchases-capacitor-ui'
project(':revenuecat-purchases-capacitor-ui').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor-ui/android')
include ':capacitor-plugin-safe-area'
project(':capacitor-plugin-safe-area').projectDir = new File('../node_modules/capacitor-plugin-safe-area/android')

View File

@@ -1,9 +1,9 @@
ext {
minSdkVersion = 22
compileSdkVersion = 34
targetSdkVersion = 34
androidxActivityVersion = '1.8.0'
androidxAppCompatVersion = '1.6.1'
minSdkVersion = 24
compileSdkVersion = 35
targetSdkVersion = 35
androidxActivityVersion = '1.9.2'
androidxAppCompatVersion = '1.7.0'
androidxCoordinatorLayoutVersion = '1.2.0'
androidxCoreVersion = '1.12.0'
androidxFragmentVersion = '1.6.2'

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

View File

@@ -4,7 +4,9 @@ const config: CapacitorConfig = {
appId: 'com.donetick.app',
appName: 'Donetick',
webDir: 'dist',
android: {
allowMixedContent: true,
},
plugins: {
PushNotifications: {
presentationOptions: ['badge', 'sound', 'alert'],

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<!-- <link rel="icon" type="image/svg+xml" href="/logo.svg" /> -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- <title>FE Template</title> -->
<title>Donetick</title>
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />

View File

@@ -355,16 +355,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
MARKETING_VERSION = 1.0.4;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
@@ -377,12 +374,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
CURRENT_PROJECT_VERSION = 13;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.4;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
</dict>
</plist>

View File

@@ -1,6 +1,7 @@
import UIKit
import Capacitor
import FirebaseCore
import FirebaseMessaging
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {
@@ -8,6 +9,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
FirebaseApp.configure()
return true
}
@@ -46,10 +48,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
return ApplicationDelegateProxy.shared.application(application, continue: userActivity, restorationHandler: restorationHandler)
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: deviceToken)
Messaging.messaging().apnsToken = deviceToken
Messaging.messaging().token(completion: { (token, error) in
if let error = error {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
} else if let token = token {
NotificationCenter.default.post(name: .capacitorDidRegisterForRemoteNotifications, object: token)
}
})
}
func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
NotificationCenter.default.post(name: .capacitorDidFailToRegisterForRemoteNotifications, object: error)
}
}

56
ios/App/App/Info.plist Normal file
View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>DoneTick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>armv7</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
</dict>
</array>
<key>NSCameraUsageDescription</key>
<string>This app needs access to camera to take photos to attach to task or use as profile photo</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library to select images to attach to task or use as profile photo</string>
</dict>
</plist>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>DoneTick Widget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>

View File

@@ -1,6 +1,6 @@
require_relative '../../node_modules/@capacitor/ios/scripts/pods_helpers'
platform :ios, '13.0'
platform :ios, '15.0'
use_frameworks!
# workaround to avoid Xcode caching of Pods that requires
@@ -11,19 +11,25 @@ install! 'cocoapods', :disable_input_output_paths => true
def capacitor_pods
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
pod 'CapacitorCommunitySqlite', :path => '../../node_modules/@capacitor-community/sqlite'
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
pod 'CapacitorDevice', :path => '../../node_modules/@capacitor/device'
pod 'CapacitorLocalNotifications', :path => '../../node_modules/@capacitor/local-notifications'
pod 'CapacitorNetwork', :path => '../../node_modules/@capacitor/network'
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapgoCapacitorSocialLogin', :path => '../../node_modules/@capgo/capacitor-social-login'
pod 'RevenuecatPurchasesCapacitor', :path => '../../node_modules/@revenuecat/purchases-capacitor'
pod 'RevenuecatPurchasesCapacitorUi', :path => '../../node_modules/@revenuecat/purchases-capacitor-ui'
pod 'CapacitorPluginSafeArea', :path => '../../node_modules/capacitor-plugin-safe-area'
end
target 'App' do
capacitor_pods
# Add your Pods here
pod 'FirebaseMessaging' # Add this line
end
post_install do |installer|

View File

@@ -1,81 +1,142 @@
PODS:
- Alamofire (5.10.2)
- AppAuth (1.7.6):
- AppAuth/Core (= 1.7.6)
- AppAuth/ExternalUserAgent (= 1.7.6)
- AppAuth/Core (1.7.6)
- AppAuth/ExternalUserAgent (1.7.6):
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- AppCheckCore (11.2.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- Capacitor (7.2.0):
- Capacitor (7.4.3):
- CapacitorCordova
- CapacitorApp (7.0.1):
- CapacitorApp (7.0.2):
- Capacitor
- CapacitorCommunitySqlite (7.0.0):
- CapacitorBrowser (7.0.2):
- Capacitor
- CapacitorCommunitySqlite (7.0.1):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (7.2.0)
- CapacitorDevice (7.0.1):
- CapacitorCordova (7.4.3)
- CapacitorDevice (7.0.2):
- Capacitor
- CapacitorLocalNotifications (7.0.1):
- CapacitorLocalNotifications (7.0.2):
- Capacitor
- CapacitorNetwork (7.0.1):
- CapacitorNetwork (7.0.2):
- Capacitor
- CapacitorPluginSafeArea (4.0.0):
- Capacitor
- CapacitorPreferences (7.0.1):
- CapacitorPreferences (7.0.2):
- Capacitor
- CapacitorPushNotifications (7.0.1):
- CapacitorPushNotifications (7.0.2):
- Capacitor
- CapacitorStatusBar (7.0.1):
- CapacitorStatusBar (7.0.2):
- Capacitor
- CapgoCapacitorSocialLogin (7.5.3):
- CapgoCapacitorSocialLogin (7.11.2):
- Alamofire (~> 5.10.2)
- Capacitor
- FBSDKCoreKit (= 17.4.0)
- FBSDKLoginKit (= 17.4.0)
- GoogleSignIn (~> 8.0.0)
- FBAEMKit (17.4.0):
- FBSDKCoreKit_Basics (= 17.4.0)
- FBSDKCoreKit (17.4.0):
- FBAEMKit (= 17.4.0)
- FBSDKCoreKit_Basics (= 17.4.0)
- FBSDKCoreKit_Basics (17.4.0)
- FBSDKLoginKit (17.4.0):
- FBSDKCoreKit (= 17.4.0)
- GoogleSignIn (8.0.0):
- AppAuth (< 2.0, >= 1.7.3)
- FBSDKCoreKit (= 18.0.0)
- FBSDKLoginKit (= 18.0.0)
- GoogleSignIn (~> 9.0.0)
- FBAEMKit (18.0.0):
- FBSDKCoreKit_Basics (= 18.0.0)
- FBSDKCoreKit (18.0.0):
- FBAEMKit (= 18.0.0)
- FBSDKCoreKit_Basics (= 18.0.0)
- FBSDKCoreKit_Basics (18.0.0)
- FBSDKLoginKit (18.0.0):
- FBSDKCoreKit (= 18.0.0)
- FirebaseCore (12.3.0):
- FirebaseCoreInternal (~> 12.3.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.3.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.3.0):
- FirebaseCore (~> 12.3.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.3.0):
- FirebaseCore (~> 12.3.0)
- FirebaseInstallations (~> 12.3.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleSignIn (9.0.0):
- AppAuth (~> 2.0)
- AppCheckCore (~> 11.0)
- GTMAppAuth (< 5.0, >= 4.1.1)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/Environment (8.0.2):
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.0.2):
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.0.2)
- GoogleUtilities/UserDefaults (8.0.2):
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (4.1.1):
- AppAuth/Core (~> 1.7)
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SQLCipher (4.7.0):
- SQLCipher/standard (= 4.7.0)
- SQLCipher/common (4.7.0)
- SQLCipher/standard (4.7.0):
- PurchasesHybridCommon (17.0.0):
- RevenueCat (= 5.35.1)
- PurchasesHybridCommonUI (17.0.0):
- PurchasesHybridCommon (= 17.0.0)
- RevenueCatUI (= 5.35.1)
- RevenueCat (5.35.1)
- RevenuecatPurchasesCapacitor (11.1.2):
- Capacitor
- PurchasesHybridCommon (= 17.0.0)
- RevenuecatPurchasesCapacitorUi (11.1.2):
- Capacitor
- PurchasesHybridCommonUI (= 17.0.0)
- RevenueCatUI (5.35.1):
- RevenueCat (= 5.35.1)
- SQLCipher (4.10.0):
- SQLCipher/standard (= 4.10.0)
- SQLCipher/common (4.10.0)
- SQLCipher/standard (4.10.0):
- SQLCipher/common
- ZIPFoundation (0.9.19)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
@@ -85,6 +146,9 @@ DEPENDENCIES:
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)"
- FirebaseMessaging
- "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)"
- "RevenuecatPurchasesCapacitorUi (from `../../node_modules/@revenuecat/purchases-capacitor-ui`)"
SPEC REPOS:
trunk:
@@ -95,11 +159,21 @@ SPEC REPOS:
- FBSDKCoreKit
- FBSDKCoreKit_Basics
- FBSDKLoginKit
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- nanopb
- PromisesObjC
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RevenueCat
- RevenueCatUI
- SQLCipher
- ZIPFoundation
@@ -108,6 +182,10 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorApp:
:path: "../../node_modules/@capacitor/app"
CapacitorBrowser:
:path: "../../node_modules/@capacitor/browser"
CapacitorCommunitySqlite:
:path: "../../node_modules/@capacitor-community/sqlite"
CapacitorCordova:
:path: "../../node_modules/@capacitor/ios"
CapacitorDevice:
@@ -126,35 +204,52 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/status-bar"
CapgoCapacitorSocialLogin:
:path: "../../node_modules/@capgo/capacitor-social-login"
RevenuecatPurchasesCapacitor:
:path: "../../node_modules/@revenuecat/purchases-capacitor"
RevenuecatPurchasesCapacitorUi:
:path: "../../node_modules/@revenuecat/purchases-capacitor-ui"
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
Capacitor: 03bc7cbdde6a629a8b910a9d7d78c3cc7ed09ea7
CapacitorApp: febecbb9582cb353aed037e18ec765141f880fe9
CapacitorCommunitySqlite: b11e556cf5d149f9e0b6fbbb47959a6cd718c3d0
CapacitorCordova: 5967b9ba03915ef1d585469d6e31f31dc49be96f
CapacitorDevice: c6f6d587dd310527f8a48bf09c4e7b4a4cf14329
CapacitorLocalNotifications: c2212755b33d2513b8bb325096ce3a64d4039ae3
CapacitorNetwork: 15cb4385f0913a8ceb5e9a4d7af1ec554bdb8de8
Capacitor: 28d6c01026a9a3f7156529498ec1f389a2a28dbc
CapacitorApp: 1f6922c9c5c8b1c538d7fbe92ebe44a81b34bed3
CapacitorBrowser: 22541e48442de44dc629c214388290d6eecc6ae9
CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b
CapacitorCordova: 435121e81a2df4d0034f0fb11fcefab5104cfdb5
CapacitorDevice: 81ae78d5d1942707caad79276badd458bf6ec603
CapacitorLocalNotifications: 665188ae8accd40806129073896fb2b39322d858
CapacitorNetwork: 695069886b3c5ed514db69aa3d026b8dc3c03a6b
CapacitorPluginSafeArea: 22031c3436269ca80fac90ec2c94bc7c1e59a81d
CapacitorPreferences: 6c98117d4d7508034a4af9db64d6b26fc75d7b94
CapacitorPushNotifications: 6a2794788c583dd89215f1805ca4bced1b13dbdf
CapacitorStatusBar: 6e7af040d8fc4dd655999819625cae9c2d74c36f
CapgoCapacitorSocialLogin: 0daa087f32775c656fbfba09b8eae7c089b85ce2
FBAEMKit: 58cb5f302cdd715a56d4c1d0dfdd2e423ac1421a
FBSDKCoreKit: 94d7461d0cecf441b1ba7c41acfff41daa8ccd41
FBSDKCoreKit_Basics: 151b43db8b834d3f0e02f95d36a44ffd36265e45
FBSDKLoginKit: 5c1cd53c91a2282b3a4fe6e6d3dcf2b8b0d33d55
GoogleSignIn: ce8c89bb9b37fb624b92e7514cc67335d1e277e4
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
CapacitorPreferences: 65107ed7437d96ee72583df5763985e3c0ff2bc2
CapacitorPushNotifications: 7c0659b349b149ee3936a682da31a9d269de9582
CapacitorStatusBar: e04d05e121d5a5979c29eb4249186a4e4a84cacb
CapgoCapacitorSocialLogin: a320106e1d032da88576cf8b31a336e971f5acad
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f
FBSDKCoreKit_Basics: 017b6dc2a1862024815a8229e75661e627ac1e29
FBSDKLoginKit: 5875762d1fe09ddcb05d03365d4f5dc34413843d
FirebaseCore: ff47fe1ad3ab9ef66edd3e8bc4647b493d2067f8
FirebaseCoreInternal: a9e1ff270f217489d9258563b693d11a312903bf
FirebaseInstallations: ca48ec60ea51b66b9f214a91847ea3720cde97f5
FirebaseMessaging: 919ce76cb353f0c36d463f5461d8ab584e6ed765
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SQLCipher: ba9d0076041ed767c5bd3d3f77098318d04a403c
PurchasesHybridCommon: 34d2f73712f4c02f2963fcfb62d43757a9136860
PurchasesHybridCommonUI: 16e162423c0010f9a6f2c1d6505dcc075f49dcbe
RevenueCat: 47d52620419dcf3dce4e724eade9d390e16ab4b0
RevenuecatPurchasesCapacitor: 51cd81cec7db693a2552b45c36261d849e1cf574
RevenuecatPurchasesCapacitorUi: 267758d61aba42a0a3f035e6ba3c4630112357e4
RevenueCatUI: 3c72ef61b54c3f4787afab6a3b0f00cbec192063
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
PODFILE CHECKSUM: 54ec5ec72c1c5e3e8d03bd96cc1ec0deaf577d9e
PODFILE CHECKSUM: bb6dcef70c8edc058fe3ba311f08a223fd7dbc66
COCOAPODS: 1.15.2
COCOAPODS: 1.16.2

207
ios/Pods/Pods.xcodeproj/project.pbxproj generated Normal file
View File

@@ -0,0 +1,207 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 46;
objects = {
/* Begin PBXFileReference section */
9D940727FF8FB9C785EB98E56350EF41 /* Podfile */ = {isa = PBXFileReference; explicitFileType = text.script.ruby; includeInIndex = 1; indentWidth = 2; lastKnownFileType = text; name = Podfile; path = ../Podfile; sourceTree = SOURCE_ROOT; tabWidth = 2; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
/* End PBXFileReference section */
/* Begin PBXGroup section */
93BAFDF14BA360977052E7B9CCC18E94 /* Products */ = {
isa = PBXGroup;
children = (
);
name = Products;
sourceTree = "<group>";
};
CF1408CF629C7361332E53B88F7BD30C = {
isa = PBXGroup;
children = (
9D940727FF8FB9C785EB98E56350EF41 /* Podfile */,
D89477F20FB1DE18A04690586D7808C4 /* Frameworks */,
93BAFDF14BA360977052E7B9CCC18E94 /* Products */,
);
sourceTree = "<group>";
};
D89477F20FB1DE18A04690586D7808C4 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXProject section */
BFDFE7DC352907FC980B868725387E98 /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
};
buildConfigurationList = 4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */;
compatibilityVersion = "Xcode 3.2";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Base,
en,
);
mainGroup = CF1408CF629C7361332E53B88F7BD30C;
minimizedProjectReferenceProxies = 0;
preferredProjectObjectVersion = 77;
productRefGroup = 93BAFDF14BA360977052E7B9CCC18E94 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
);
};
/* End PBXProject section */
/* Begin XCBuildConfiguration section */
2426522C419C0DE4525883EA7AABC025 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_NO_COMMON_BLOCKS = YES;
GCC_PREPROCESSOR_DEFINITIONS = (
"POD_CONFIGURATION_RELEASE=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRIP_INSTALLED_PRODUCT = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
SYMROOT = "${SRCROOT}/../build";
};
name = Release;
};
8F9FEBB7130A3DF8746ED9AA46FE33FA /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
CLANG_CXX_LIBRARY = "libc++";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
GCC_C_LANGUAGE_STANDARD = gnu11;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"POD_CONFIGURATION_DEBUG=1",
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRIP_INSTALLED_PRODUCT = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
SYMROOT = "${SRCROOT}/../build";
};
name = Debug;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
4821239608C13582E20E6DA73FD5F1F9 /* Build configuration list for PBXProject "Pods" */ = {
isa = XCConfigurationList;
buildConfigurations = (
8F9FEBB7130A3DF8746ED9AA46FE33FA /* Debug */,
2426522C419C0DE4525883EA7AABC025 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = BFDFE7DC352907FC980B868725387E98 /* Project object */;
}

6062
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,12 @@
{
"name": "donetick",
"private": true,
"version": "0.1.102",
"version": "0.1.120",
"type": "module",
"engines": {
"node": ">=20.0.0",
"npm": ">=9.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix",
@@ -13,18 +17,25 @@
"start": "vite --host",
"dev": "vite",
"build": "vite build",
"build-selfhosted": "vite build --mode selfhosted",
"build-cf": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
"build-selfhosted": "rm -rf package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build --mode selfhosted",
"build-win": "del package-lock.json && npm install && npm install --force @rollup/rollup-win32-x64-msvc @swc/core-win32-x64-msvc && vite build --mode selfhosted",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"setup-m1": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
"setup-apple-silicon": "npm install --no-optional && npm install --force @rollup/rollup-darwin-arm64 @swc/core-darwin-arm64",
"test-cloudflare-build": "rm -rf node_modules package-lock.json && npm install && npm install --force @rollup/rollup-linux-x64-gnu@4.34.9 @swc/core-linux-x64-gnu && vite build",
"release": "npm version patch && npm run build && git push origin develop --tags",
"merge": "git checkout main && git merge develop && git push origin main && git checkout develop",
"ionic:build": "npm run build",
"ionic:serve": "npm run start"
"ionic:serve": "npm run start",
"android": "npm run build && npx cap sync android"
},
"dependencies": {
"@capacitor-community/sqlite": "^7.0.0",
"@capacitor/android": "^7.0.0",
"@capacitor/app": "^7.0.0",
"@capacitor/browser": "^7.0.2",
"@capacitor/core": "^7.0.0",
"@capacitor/device": "^7.0.0",
"@capacitor/ios": "^7.0.0",
@@ -39,10 +50,14 @@
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@hello-pangea/dnd": "^18.0.1",
"@mui/icons-material": "^5.16.13",
"@mui/joy": "^5.0.0-beta.20",
"@mui/material": "^5.15.2",
"@openreplay/tracker": "^14.0.4",
"@revenuecat/purchases-capacitor": "^11.1.0",
"@revenuecat/purchases-capacitor-ui": "^11.1.0",
"@swc/core": "^1.12.5",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"browser-image-compression": "^2.0.2",
@@ -50,7 +65,7 @@
"chrono-node": "^2.7.7",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
"farmhash": "^4.0.1",
"event-source-polyfill": "^1.0.31",
"fuse.js": "^7.0.0",
"js-cookie": "^3.0.5",
"moment": "^2.30.1",
@@ -79,6 +94,7 @@
"@vite-pwa/assets-generator": "^0.2.4",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.9.16",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",

View File

@@ -1,19 +1,18 @@
import NavBar from '@/views/components/NavBar'
import { Button, Snackbar, Typography, useColorScheme } from '@mui/joy'
import { Button, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useCallback, useEffect } from 'react'
import { Outlet, useNavigate } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { AuthProvider } from './hooks/useAuth.jsx'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import { UserContext } from './contexts/UserContext'
import { useResource } from './queries/ResourceQueries'
import { AuthenticationProvider } from './service/AuthenticationService'
import { ErrorProvider } from './service/ErrorProvider'
import { GetUserProfile } from './utils/Fetcher'
import { apiManager, isTokenValid } from './utils/TokenManager'
import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider'
import NetworkBanner from './views/components/NetworkBanner'
const add = className => {
document.getElementById('root').classList.add(className)
}
@@ -21,26 +20,27 @@ const add = className => {
const remove = className => {
document.getElementById('root').classList.remove(className)
}
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
function App() {
const resource = useResource()
const navigate = useNavigate()
startApiManager(navigate)
startOpenReplay()
const queryClient = new QueryClient()
const { mode, systemMode } = useColorScheme()
const [userProfile, setUserProfile] = useState(null)
const [showUpdateSnackbar, setShowUpdateSnackbar] = useState(true)
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
const AppContent = () => {
const { showNotification } = useNotification()
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
onRegistered(r) {
// eslint-disable-next-line prefer-template
console.log('SW Registered: ' + r)
r &&
setInterval(() => {
@@ -51,12 +51,54 @@ function App() {
console.log('SW registration error', error)
},
})
const close = () => {
setOfflineReady(false)
setNeedRefresh(false)
}
const setThemeClass = () => {
useEffect(() => {
if (needRefresh) {
showNotification({
type: 'custom',
component: (
<div>
<Typography level='body-md'>
A new version is now available. Click on reload button to update.
</Typography>
<Button
color='secondary'
size='small'
onClick={() => {
updateServiceWorker(true)
setNeedRefresh(false)
}}
sx={{ ml: 2 }}
>
Refresh
</Button>
</div>
),
snackbarProps: {
autoHideDuration: null, // Persistent until user action
},
})
}
}, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh])
return (
<div>
<ImpersonateUserProvider>
<NavBar />
<PageTransition>
<Outlet />
</PageTransition>
</ImpersonateUserProvider>
</div>
)
}
function App() {
// startOpenReplay()
const { mode, systemMode } = useColorScheme()
const setThemeClass = useCallback(() => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
if (value === 'system') {
@@ -71,75 +113,27 @@ function App() {
}
return remove('dark')
}
const getUserProfile = () => {
GetUserProfile()
.then(res => {
res.json().then(data => {
setUserProfile(data.res)
})
})
.catch(error => {})
}
}, [mode, systemMode])
useEffect(() => {
setThemeClass()
}, [mode, systemMode])
}, [setThemeClass])
useEffect(() => {
registerCapacitorListeners()
if (isTokenValid()) {
if (!userProfile) getUserProfile()
}
}, [])
return (
<div className='min-h-screen'>
<>
<NetworkBanner />
<QueryClientProvider client={queryClient}>
<AuthenticationProvider />
<ErrorProvider>
<ImpersonateUserProvider>
<UserContext.Provider value={{ userProfile, setUserProfile }}>
<NavBar />
<Outlet />
</UserContext.Provider>
</ImpersonateUserProvider>
</ErrorProvider>
{needRefresh && (
<Snackbar open={showUpdateSnackbar}>
<Typography level='body-md'>
A new version is now available.Click on reload button to update.
</Typography>
<Button
color='secondary'
size='small'
onClick={() => {
updateServiceWorker(true)
setShowUpdateSnackbar(false)
}}
>
Refresh
</Button>
</Snackbar>
)}
</QueryClientProvider>
</div>
<AuthProvider>
<SSEProvider>
<AppContent />
</SSEProvider>
</AuthProvider>
</>
)
}
const startOpenReplay = () => {
if (!import.meta.env.VITE_OPENREPLAY_PROJECT_KEY) return
const tracker = new Tracker({
projectKey: import.meta.env.VITE_OPENREPLAY_PROJECT_KEY,
})
tracker.start()
}
export default App
const startApiManager = navigate => {
apiManager.init()
apiManager.setNavigateToLogin(() => {
navigate('/login')
})
}

View File

@@ -1,78 +1,195 @@
import { LocalNotifications } from '@capacitor/local-notifications';
import { App as mobileApp } from '@capacitor/app';
import { App as mobileApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
import { LocalNotifications } from '@capacitor/local-notifications'
import { Preferences } from '@capacitor/preferences'
import { PushNotifications } from '@capacitor/push-notifications'
import { PutNotificationTarget } from './utils/Fetcher';
import { Capacitor } from '@capacitor/core';
import { RegisterDeviceToken } from './utils/Fetcher'
const localNotificationListenerRegistration = () => {
LocalNotifications.addListener('localNotificationReceived', (notification) => {
console.log('Notification received', notification);
});
LocalNotifications.addListener('localNotificationActionPerformed', (event) => {
console.log('Notification action performed', event);
if (event.actionId === 'tap') {
console.log('Notification opened, navigate to chore', event.notification.extra.choreId);
window.location.href = `/chores/${event.notification.extra.choreId}`
}
});
}
const pushNotificationListenerRegistration = () => {
PushNotifications.register();
PushNotifications.addListener('registration', (token) => {
if (Capacitor.isNativePlatform()) {
const type = Capacitor.getPlatform() === 'android' ? 1 : 2; // 1 for android, 2 for ios
PutNotificationTarget(type, token.value).then((response) => {
console.log('Notification target updated', response);
}
).catch((error) => {
console.error('Error updating notification target', error);
}
);
LocalNotifications.addListener('localNotificationReceived', notification => {
console.log('Notification received', notification)
})
LocalNotifications.addListener('localNotificationActionPerformed', event => {
console.log('Notification action performed', event)
if (event.actionId === 'tap') {
console.log(
'Notification opened, navigate to chore',
event.notification.extra.choreId,
)
window.location.href = `/chores/${event.notification.extra.choreId}`
}
})
}
// TODO save the token in preferences and only send it if it has changed:
console.log('Push registration success, token: ' + token.value);
}
const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => {
try {
// const stored = await Preferences.get({ key: 'deviceRegistration' })
// const lastReg = stored.value ? JSON.parse(stored.value) : null
const current = {
token: token.value,
deviceId: deviceId.identifier,
platform,
appVersion: deviceInfo.appVersion,
registeredAt: Date.now(),
}
);
PushNotifications.addListener('registrationError', (error) => {
console.error('Error on registration: ' + JSON.stringify(error));
// const shouldRegister =
// !lastReg ||
// lastReg.token !== current.token ||
// lastReg.appVersion !== current.appVersion ||
// Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000
// console.log('Registering device token:', {
// reason: !lastReg
// ? 'first_time'
// : lastReg.token !== current.token
// ? 'token_changed'
// : lastReg.appVersion !== current.appVersion
// ? 'app_updated'
// : 'periodic_refresh',
// })
const result = await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
if (result && result.ok) {
await Preferences.set({
key: 'deviceRegistration',
value: JSON.stringify(current),
})
console.log('Device token registered successfully')
// Emit event to notify UI components of successful registration
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
} else if (result) {
// Handle registration errors
console.error('Device registration failed:', result.status)
// Emit event with error details for UI to handle
window.dispatchEvent(
new CustomEvent('deviceTokenRegistrationFailed', {
detail: {
status: result.status,
error: await result.text().catch(() => 'Unknown error'),
},
}),
)
}
);
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if(fcmEvent.actionId === 'tap') {
if (fcmEvent.notification.data.type === 'chore_due') {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
}
else {
window.location.href = `/my/chores`
}
} catch (error) {
console.error(
'Error in token registration check, registering anyway:',
error,
)
const fallbackResult = await RegisterDeviceToken(
token.value,
deviceId.identifier,
platform,
deviceInfo.appVersion,
deviceInfo.model,
)
if (fallbackResult && fallbackResult.ok) {
// Emit event to notify UI components of successful registration
window.dispatchEvent(new CustomEvent('deviceTokenRegistered'))
} else if (fallbackResult) {
// Handle registration errors
console.error(
'Fallback device registration failed:',
fallbackResult.status,
)
// Emit event with error details for UI to handle
window.dispatchEvent(
new CustomEvent('deviceTokenRegistrationFailed', {
detail: {
status: fallbackResult.status,
error: await fallbackResult.text().catch(() => 'Unknown error'),
},
}),
)
}
}
);
}
}
const pushNotificationListenerRegistration = async () => {
// Check and request permissions for Android 13+
if (Capacitor.isNativePlatform()) {
let permStatus = await PushNotifications.checkPermissions()
const registerCapacitorListeners = () => {
if(!Capacitor.isNativePlatform()) {
console.log('Not a native platform, skipping registration of native listeners');
return
if (permStatus.receive === 'prompt') {
permStatus = await PushNotifications.requestPermissions()
}
localNotificationListenerRegistration();
pushNotificationListenerRegistration();
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back();
} else {
mobileApp.exitApp();
}
});
if (permStatus.receive !== 'granted') {
console.warn('Push notification permission not granted')
return
}
}
export { registerCapacitorListeners }
await PushNotifications.register()
PushNotifications.addListener('registration', async token => {
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
const deviceId = await Device.getId()
const platform =
Capacitor.getPlatform() === 'android' ? 'android' : 'ios'
await registerTokenIfNeeded(token, deviceInfo, deviceId, platform)
} catch (error) {
console.error('Error registering device token', error)
}
}
})
PushNotifications.addListener('registrationError', error => {
console.error('Error on registration: ' + JSON.stringify(error))
})
PushNotifications.addListener('pushNotificationReceived', notification => {
console.log('Push notification received: ', notification)
})
PushNotifications.addListener('pushNotificationActionPerformed', fcmEvent => {
if (fcmEvent.actionId === 'tap') {
if (
fcmEvent.notification.data.type === 'chore_due' ||
fcmEvent.notification.data.type === 'nudge'
) {
window.location.href = `/chores/${fcmEvent.notification.data.choreId}`
} else {
window.location.href = `/chores`
}
}
})
}
const registerCapacitorListeners = () => {
if (!Capacitor.isNativePlatform()) {
console.log(
'Not a native platform, skipping registration of native listeners',
)
return
}
localNotificationListenerRegistration()
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else {
mobileApp.exitApp()
}
})
}
export {
registerCapacitorListeners,
pushNotificationListenerRegistration as registerPushNotifications,
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -0,0 +1,744 @@
import { Save } from '@mui/icons-material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import InfoIcon from '@mui/icons-material/Info'
import NotificationsIcon from '@mui/icons-material/Notifications'
import Alert from '@mui/joy/Alert'
import Badge from '@mui/joy/Badge'
import Box from '@mui/joy/Box'
import Button from '@mui/joy/Button'
import IconButton from '@mui/joy/IconButton'
import Input from '@mui/joy/Input'
import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useState } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
const timeUnits = [
{ label: 'Mins', value: 'm' },
{ label: 'Hours', value: 'h' },
{ label: 'Days', value: 'd' },
]
const timingOptions = [
{ label: 'Before', value: 'before' },
{ label: 'On Due', value: 'ondue' },
{ label: 'After', value: 'after' },
]
function getRelativeLabel(notification) {
const { value, unit } = notification
const numericValue = Number(value)
if (numericValue === 0) {
return 'On due date'
}
const unitName = unit === 'm' ? 'minutes' : unit === 'h' ? 'hours' : 'days'
const absValue = Math.abs(numericValue)
return `${absValue} ${unitName} ${numericValue < 0 ? 'before' : 'after'} due`
}
// Helper functions to convert between internal value and UI representation
function getUIRepresentation(notification) {
const numericValue = Number(notification.value)
if (numericValue === 0) {
return { timing: 'ondue', displayValue: 0, unit: notification.unit }
} else if (numericValue < 0) {
return {
timing: 'before',
displayValue: Math.abs(numericValue),
unit: notification.unit,
}
} else {
return {
timing: 'after',
displayValue: numericValue,
unit: notification.unit,
}
}
}
function getInternalValue(timing, displayValue) {
if (timing === 'ondue') return 0
if (timing === 'before') return -Math.abs(displayValue)
return Math.abs(displayValue) // 'after'
}
const NotificationTemplate = ({
maxNotifications = 5,
onChange,
value,
showTimeline = true,
}) => {
const [notifications, setNotifications] = useState(
value?.templates ||
JSON.parse(localStorage.getItem('defaultNotificationTemplate')) ||
[],
)
const [error, setError] = useState(null)
const [showSaveDefault, setShowSaveDefault] = useState(false)
// Create a map of notification indices for timeline display
const [notificationIndexMap, setNotificationIndexMap] = useState({})
const updateNotificationIndices = useCallback(() => {
// Convert notifications to minutes for proper chronological sorting
const convertToMinutes = (value, unit) => {
const numericValue = Number(value)
if (numericValue === 0) return 0
let minutes = Math.abs(numericValue)
if (unit === 'h') minutes *= 60
if (unit === 'd') minutes *= 24 * 60
return numericValue < 0 ? -minutes : minutes
}
// Sort notifications for consistent ordering by actual time duration
const sorted = [...notifications].sort((a, b) => {
const aMinutes = convertToMinutes(a.value, a.unit)
const bMinutes = convertToMinutes(b.value, b.unit)
return aMinutes - bMinutes
})
const indexMap = {}
// Map original array indices to their chronological position numbers
notifications.forEach((originalNotification, originalIdx) => {
const chronologicalPosition = sorted.findIndex(
sortedNotification =>
Number(sortedNotification.value) ===
Number(originalNotification.value) &&
sortedNotification.unit === originalNotification.unit,
)
indexMap[originalIdx] = chronologicalPosition + 1
})
setNotificationIndexMap(indexMap)
}, [notifications])
// Sort notifications and update the index mapping
useEffect(() => {
updateNotificationIndices()
setError(null)
}, [updateNotificationIndices])
// Notify parent component of changes including the template name
useEffect(() => {
if (onChange) {
onChange({ notifications })
}
}, [notifications, onChange])
// Validates if a notification configuration already exists
const isDuplicate = (notification, currentIdx = -1) => {
return notifications.some((n, idx) => {
if (idx === currentIdx) return false
return (
Number(n.value) === Number(notification.value) &&
n.unit === notification.unit
)
})
}
const handleChange = (idx, field, value) => {
const currentNotification = notifications[idx]
const uiRep = getUIRepresentation(currentNotification)
let updatedUIRep = { ...uiRep }
let updatedNotification = { ...currentNotification }
// Update the UI representation based on the field being changed
if (field === 'timing') {
updatedUIRep.timing = value
// Reset display value when switching to "On Due"
if (value === 'ondue') {
updatedUIRep.displayValue = 0
}
} else if (field === 'displayValue') {
updatedUIRep.displayValue = Math.max(0, Number(value))
} else if (field === 'unit') {
updatedUIRep.unit = value
updatedNotification.unit = value
}
// Convert back to internal representation
const newInternalValue = getInternalValue(
updatedUIRep.timing,
updatedUIRep.displayValue,
)
updatedNotification = {
...updatedNotification,
value: newInternalValue,
unit: updatedUIRep.unit,
}
// Check if another notification is already "On Due" (value = 0)
if (newInternalValue === 0) {
const existingOnDue = notifications.findIndex(
(n, i) => i !== idx && Number(n.value) === 0,
)
if (existingOnDue !== -1) {
setError(
'Only one notification can be set to "On Due". Please choose a different timing.',
)
return
}
}
if (isDuplicate(updatedNotification, idx)) {
setError(
'This notification setting already exists. Please use a different timing.',
)
return
}
const updated = notifications.map((n, i) =>
i === idx ? updatedNotification : n,
)
setNotifications(updated)
setError(null)
}
const addSmartNotification = type => {
if (notifications.length >= maxNotifications) return
setShowSaveDefault(true)
let newNotification
let suggestions = []
switch (type) {
case 'reminder':
// Suggest common reminder times that don't exist
suggestions = [
{ value: -1, unit: 'd' }, // 1 day before
{ value: -3, unit: 'h' }, // 3 hours before
{ value: -30, unit: 'm' }, // 3 days before
]
break
case 'due':
if (notifications.some(n => Number(n.value) === 0)) {
setError('Only one "Due Alert" notification is allowed.')
return
}
newNotification = { value: 0, unit: 'm' }
break
case 'followup':
suggestions = [
{ value: 1, unit: 'd' }, // 1 day after
{ value: 3, unit: 'd' }, // 3 days after
{ value: 7, unit: 'd' }, // 1 week after
]
break
}
// For reminder/followup, find first non-duplicate suggestion
if (suggestions.length > 0) {
newNotification = suggestions.find(suggestion => !isDuplicate(suggestion))
if (!newNotification) {
setError(`All common ${type} times are already configured.`)
return
}
}
// Add the new notification to the end (don't sort, keep form order)
const updatedNotifications = [...notifications, newNotification]
setNotifications(updatedNotifications)
setError(null)
}
const removeNotification = idx => {
const updated = notifications.filter((_, i) => i !== idx)
setNotifications(updated)
onChange && onChange(updated)
setShowSaveDefault(true)
}
const renderTimeline = () => {
// Convert notifications to minutes for proper chronological sorting
const convertToMinutes = (value, unit) => {
const numericValue = Number(value)
if (numericValue === 0) return 0
let minutes = Math.abs(numericValue)
if (unit === 'h') minutes *= 60
if (unit === 'd') minutes *= 24 * 60
return numericValue < 0 ? -minutes : minutes
}
// Sort notifications chronologically by actual time (in minutes)
const sorted = [...notifications].sort((a, b) => {
const aMinutes = convertToMinutes(a.value, a.unit)
const bMinutes = convertToMinutes(b.value, b.unit)
return aMinutes - bMinutes
})
// Get min and max notification times in minutes for dynamic scaling
const minutesValues = sorted.map(n => convertToMinutes(n.value, n.unit))
const minBefore = Math.min(0, ...minutesValues) // Default to 0 if no "before" notifications
const maxAfter = Math.max(0, ...minutesValues) // Default to 0 if no "after" notifications
const getPositionPercent = (value, unit) => {
const minutes = convertToMinutes(value, unit)
// Due date is always at center (50%)
if (minutes === 0) return 50
// For notifications before due date (negative values)
if (minutes < 0) {
if (minBefore === 0) return 30 // Default position if no before notifications
// Scale between 10% (furthest left) and 45% (closest to due)
return 45 - (Math.abs(minutes) / Math.abs(minBefore)) * 35
}
// For notifications after due date (positive values)
if (maxAfter === 0) return 70 // Default position if no after notifications
// Scale between 55% (closest to due) and 90% (furthest right)
return 55 + (minutes / maxAfter) * 35
}
return (
<Box sx={{ mt: 3, mb: 2 }}>
<Typography level={'body-md'} sx={{ mb: 1 }}>
Notification Timeline
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
position: 'relative',
height: 90,
bgcolor: 'background.surface',
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
p: 2,
transition: 'height 0.3s ease',
// '&:hover': {
// height: 130,
// },
}}
>
{/* Timeline line */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 3,
bgcolor: 'neutral.outlinedBorder',
mt: 2,
}}
>
{/* Due date marker */}
<Box
sx={{
position: 'absolute',
left: '50%',
height: 16,
width: 3,
bgcolor: 'warning.500',
top: -8,
transform: 'translateX(-50%)',
borderRadius: 'sm',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level={'body-xs'}
sx={{
mt: 4,
fontWeight: 'md',
color: 'warning.700',
fontSize: '0.6rem',
}}
>
Due Date
</Typography>
</Box>
{/* Notification markers */}
{sorted.map((n, i) => {
// Calculate position based on actual time duration
const percent = getPositionPercent(n.value, n.unit)
return (
<Box
key={i}
sx={{
position: 'absolute',
left: `${percent}%`,
transform: 'translateX(-50%)',
color:
convertToMinutes(n.value, n.unit) < 0
? TASK_COLOR.SCHEDULED
: convertToMinutes(n.value, n.unit) === 0
? TASK_COLOR.TODAY
: TASK_COLOR.LATE,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transition: 'all 0.3s ease',
cursor: 'pointer',
opacity: 0.85,
'&:hover': {
opacity: 1,
transform: 'translateX(-50%) scale(1.1)',
zIndex: 10,
},
}}
title={getRelativeLabel(n)}
>
<Badge
badgeContent={
notificationIndexMap[
notifications.findIndex(
original =>
Number(original.value) === Number(n.value) &&
original.unit === n.unit,
)
] || i + 1
}
size={'sm'}
variant={'solid'}
sx={{
'--Badge-paddingX': '4px',
'--Badge-minHeight': '16px',
'--Badge-fontSize': '0.65rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'& .MuiBadge-badge': {
background:
convertToMinutes(n.value, n.unit) < 0
? NOTIFICATION_TYPE.PREDUE
: convertToMinutes(n.value, n.unit) === 0
? NOTIFICATION_TYPE.DUE_DATE
: NOTIFICATION_TYPE.POSTDUE,
color: 'white',
},
}}
>
<NotificationsIcon
fontSize={'small'}
sx={{
height: 18,
width: 18,
}}
/>
</Badge>
</Box>
)
})}
</Box>
</Box>
</Box>
)
}
return (
<Box>
{error && (
<Alert
variant='soft'
color='danger'
sx={{ mb: 2 }}
startDecorator={<InfoIcon />}
>
{error}
</Alert>
)}
{notifications
.map((n, idx) => ({ notification: n, originalIndex: idx }))
.sort((a, b) => {
const aBadgeNumber = notificationIndexMap[a.originalIndex] || 0
const bBadgeNumber = notificationIndexMap[b.originalIndex] || 0
return aBadgeNumber - bBadgeNumber
})
.map(({ notification: n, originalIndex: idx }) => {
// Get ordered badge number from timeline sorting
const badgeNumber = notificationIndexMap[idx]
const uiRep = getUIRepresentation(n)
const getNotificationColors = value => {
if (Number(value) < 0) {
return {
bgColor: NOTIFICATION_TYPE.PREDUE,
lightBg: `${NOTIFICATION_TYPE.PREDUE}20`,
borderColor: `${NOTIFICATION_TYPE.PREDUE}40`,
textColor: NOTIFICATION_TYPE.PREDUE,
}
} else if (Number(value) === 0) {
return {
bgColor: NOTIFICATION_TYPE.DUE_DATE,
lightBg: `${NOTIFICATION_TYPE.DUE_DATE}20`,
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}40`,
textColor: NOTIFICATION_TYPE.DUE_DATE,
}
} else {
return {
bgColor: NOTIFICATION_TYPE.POSTDUE,
lightBg: `${NOTIFICATION_TYPE.POSTDUE}20`,
borderColor: `${NOTIFICATION_TYPE.POSTDUE}40`,
textColor: NOTIFICATION_TYPE.POSTDUE,
}
}
}
const colors = getNotificationColors(n.value)
return (
<Box
key={idx}
sx={{
mb: 1.5,
p: 2,
borderRadius: 8,
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
background: 'background.surface',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: 1.5,
}}
>
<Box
className='notification-icon'
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: 6,
background: `${colors.bgColor}15`,
color: colors.textColor,
position: 'relative',
flexShrink: 0,
'& svg': {
fontSize: 16,
},
}}
>
<Badge
badgeContent={badgeNumber}
size={'sm'}
sx={{
'--Badge-minHeight': '16px',
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
position: 'absolute',
top: -6,
right: -6,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',
},
}}
/>
<NotificationsIcon />
</Box>
<Box
sx={{
minWidth: 0,
flex: '1',
display: { xs: 'none', md: 'flex' }, // Show only on md and up
}}
>
<Typography
level='body-sm'
sx={{
fontWeight: 600,
color: 'text.primary',
fontSize: 14,
}}
>
{getRelativeLabel(n)}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexShrink: 0,
}}
>
<Select
value={uiRep.timing}
onChange={(_, value) => handleChange(idx, 'timing', value)}
sx={{ minWidth: 80 }}
size={'sm'}
>
{timingOptions.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<Input
type={'number'}
min={0}
value={uiRep.displayValue}
disabled={uiRep.timing === 'ondue'}
onChange={e =>
handleChange(idx, 'displayValue', e.target.value)
}
sx={{
width: 60,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
}}
size={'sm'}
placeholder='0'
/>
<Select
value={n.unit}
disabled={uiRep.timing === 'ondue'}
onChange={(_, value) => handleChange(idx, 'unit', value)}
sx={{
minWidth: 70,
opacity: uiRep.timing === 'ondue' ? 0.6 : 1,
}}
size={'sm'}
>
{timeUnits.map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<IconButton
onClick={() => removeNotification(idx)}
disabled={notifications.length === 1}
color={'danger'}
size={'sm'}
variant={'soft'}
sx={{
transition: 'all 0.2s ease',
'&:hover': {
transform: 'scale(1.1)',
},
}}
>
<DeleteIcon fontSize={'small'} />
</IconButton>
</Box>
</Box>
)
})}
<Box
sx={{
display: 'flex',
gap: 1.5,
mt: 1,
mb: 2,
flexWrap: 'wrap',
}}
>
<Button
onClick={() => addSmartNotification('reminder')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${TASK_COLOR.SCHEDULED}60`,
color: TASK_COLOR.SCHEDULED,
'&:hover': {
borderColor: TASK_COLOR.SCHEDULED,
background: `${TASK_COLOR.SCHEDULED}10`,
},
}}
>
Reminder
</Button>
<Button
onClick={() => addSmartNotification('due')}
disabled={
notifications.length >= maxNotifications ||
notifications.some(n => Number(n.value) === 0)
}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.DUE_DATE}60`,
color: NOTIFICATION_TYPE.DUE_DATE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.DUE_DATE,
background: `${NOTIFICATION_TYPE.DUE_DATE}10`,
},
}}
>
Due Alert
</Button>
<Button
onClick={() => addSmartNotification('followup')}
disabled={notifications.length >= maxNotifications}
startDecorator={<AddIcon />}
size={'sm'}
variant={'outlined'}
sx={{
borderRadius: 6,
fontWeight: 500,
borderColor: `${NOTIFICATION_TYPE.POSTDUE}60`,
color: NOTIFICATION_TYPE.POSTDUE,
'&:hover': {
borderColor: NOTIFICATION_TYPE.POSTDUE,
background: `${NOTIFICATION_TYPE.POSTDUE}10`,
},
}}
>
Follow-up
</Button>
</Box>
{showSaveDefault && (
<Box
sx={{
mt: 1,
display: 'flex',
justifyContent: 'start',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='outlined'
size='sm'
color='neutral'
startDecorator={<Save />}
sx={{
borderRadius: 6,
fontWeight: 500,
'&:hover': {
background: 'neutral.softHoverBg',
},
}}
onClick={() => {
localStorage.setItem(
'defaultNotificationTemplate',
JSON.stringify(notifications),
)
setShowSaveDefault(false)
}}
>
Remember for Future Tasks
</Button>
</Box>
)}
{showTimeline && renderTimeline()}
</Box>
)
}
export default NotificationTemplate

View File

@@ -0,0 +1,192 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import { Box, Card, Chip, FormHelperText, Switch, Typography } from '@mui/joy'
import { useState } from 'react'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const REALTIME_TYPES = {
DISABLED: 'disabled',
SSE: 'sse',
}
const RealTimeSettings = () => {
const { data: userProfile } = useUserProfile()
// SSE context
const sseContext = useSSEContext()
// Get current realtime type from localStorage
const getCurrentRealtimeType = () => {
const sseEnabled = localStorage.getItem('sse_enabled') === 'true'
return sseEnabled ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED
}
const [realtimeType, setRealtimeType] = useState(getCurrentRealtimeType())
const handleRealtimeTypeChange = (event, newValue) => {
if (!isPlusAccount(userProfile)) {
return // Don't allow changes for non-Plus users
}
setRealtimeType(newValue)
// Update localStorage and toggle connections
switch (newValue) {
case REALTIME_TYPES.DISABLED:
localStorage.setItem('sse_enabled', 'false')
sseContext.disconnect()
break
case REALTIME_TYPES.SSE:
localStorage.setItem('sse_enabled', 'true')
sseContext.connect()
break
}
}
const getCurrentContext = () => {
switch (realtimeType) {
case REALTIME_TYPES.SSE:
return sseContext
default:
return {
isConnected: false,
isConnecting: false,
error: null,
getConnectionStatus: () => 'disabled',
}
}
}
const context = getCurrentContext()
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates are not available in the Basic plan. Upgrade to Plus to receive instant notifications when tasks are updated.'
}
if (realtimeType === REALTIME_TYPES.DISABLED) {
return 'Real-time updates are disabled. Enable them to see live changes when you or other circle members complete, skip, or modify tasks.'
}
if (context.isConnected) {
return "Real-time updates are working. You'll see live changes when you or other circle members complete, skip, or modify tasks."
}
if (context.isConnecting) {
return 'Connecting to real-time updates...'
}
if (context.error) {
return `Real-time updates are enabled but not working: ${context.error}`
}
return 'Real-time updates are enabled but not currently connected.'
}
const getConnectionStatusComponent = () => {
switch (realtimeType) {
case REALTIME_TYPES.SSE:
return <SSEConnectionStatus variant='chip' />
default:
return null
}
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 2, mb: 2 }}>
<Switch
checked={realtimeType !== REALTIME_TYPES.DISABLED}
onChange={e => {
handleRealtimeTypeChange(
null,
e.target.checked ? REALTIME_TYPES.SSE : REALTIME_TYPES.DISABLED,
)
}}
color={
realtimeType !== REALTIME_TYPES.DISABLED ? 'success' : 'neutral'
}
disabled={!isPlusAccount(userProfile)}
inputProps={{ 'aria-label': 'Enable Real-time Updates' }}
/>
<Box sx={{ flex: 1 }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 0.5,
}}
>
<Typography level='title-md'>
Real-time Updates
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) ? (
<Sync color={context.isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
</Box>
<Typography level='body-sm' color='neutral'>
Get instant notifications when tasks are updated
</Typography>
</Box>
</Box>
{/* <FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Real-time Connection Type</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
Choose how to receive real-time updates
</FormHelperText>
</Box>
<Select
value={realtimeType}
onChange={handleRealtimeTypeChange}
disabled={!isPlusAccount(userProfile)}
sx={{ minWidth: 140 }}
>
<Option value={REALTIME_TYPES.DISABLED}>Disabled</Option>
<Option value={REALTIME_TYPES.WEBSOCKET}>WebSocket</Option>
<Option value={REALTIME_TYPES.SSE}>SSE</Option>
</Select>
</FormControl> */}
<FormHelperText sx={{ mb: 2 }}>{getStatusDescription()}</FormHelperText>
{realtimeType !== REALTIME_TYPES.DISABLED &&
isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
{getConnectionStatusComponent()}
{context.error && (
<Typography level='body-xs' color='danger'>
{context.error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates are not available in the Basic plan. Upgrade to Plus
to receive instant notifications when you or other circle members
complete, skip, or modify tasks.
</Typography>
)}
</Card>
)
}
export default RealTimeSettings

View File

@@ -0,0 +1,106 @@
import { Circle, SignalWifi4Bar, SignalWifiOff } from '@mui/icons-material'
import { Box, Chip, Tooltip, Typography } from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
const SSEConnectionStatus = ({
variant = 'minimal',
showError = false,
sx = {},
}) => {
const { isConnected, isConnecting, error, getConnectionStatus } =
useSSEContext()
const getStatusColor = () => {
if (isConnected) return 'success'
if (isConnecting) return 'warning'
return 'danger'
}
const getStatusIcon = () => {
if (isConnected) return <SignalWifi4Bar />
if (isConnecting) return <Circle />
return <SignalWifiOff />
}
const getStatusText = () => {
if (isConnected) return 'Connected'
if (isConnecting) return 'Connecting...'
return 'Disconnected'
}
const getTooltipText = () => {
const status = getConnectionStatus()
if (error) return `Real-time updates (SSE): ${status} - ${error}`
if (!isConnected && !isConnecting) {
return `Real-time updates (SSE): ${status} - Join a circle to enable real-time updates`
}
return `Real-time updates (SSE): ${status}`
}
if (variant === 'minimal') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
...sx,
}}
>
<Circle
sx={{
fontSize: 8,
color:
getStatusColor() === 'success'
? 'success.main'
: getStatusColor() === 'warning'
? 'warning.main'
: 'danger.main',
}}
/>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
</Tooltip>
)
}
if (variant === 'chip') {
return (
<Tooltip title={getTooltipText()} size='sm'>
<Chip
color={getStatusColor()}
size='sm'
variant='soft'
startDecorator={getStatusIcon()}
sx={sx}
>
{getStatusText()}
</Chip>
</Tooltip>
)
}
// Full variant
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ...sx }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{getStatusIcon()}
<Typography level='body-sm' color={getStatusColor()}>
{getStatusText()}
</Typography>
</Box>
{showError && error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)
}
export default SSEConnectionStatus

View File

@@ -0,0 +1,149 @@
import { Sync, SyncDisabled } from '@mui/icons-material'
import {
Box,
Card,
Chip,
FormControl,
FormHelperText,
FormLabel,
Switch,
Typography,
} from '@mui/joy'
import { useSSEContext } from '../hooks/useSSEContext'
import { useUserProfile } from '../queries/UserQueries'
import { isPlusAccount } from '../utils/Helpers'
import SSEConnectionStatus from './SSEConnectionStatus'
const SSESettings = () => {
const { data: userProfile } = useUserProfile()
const {
isConnected,
isConnecting,
error,
getConnectionStatus,
toggleSSEEnabled,
isSSEEnabled,
} = useSSEContext()
const handleToggle = () => {
console.log('=== TOGGLE CLICKED ===')
if (!isPlusAccount(userProfile)) {
console.log('Not a Plus account, returning early')
return // Don't allow toggle for non-Plus users
}
const currentlyEnabled = isSSEEnabled()
console.log('SSE Settings - Toggle clicked:', {
currentlyEnabled,
newState: !currentlyEnabled,
userProfile,
isPlusAccount: isPlusAccount(userProfile),
})
toggleSSEEnabled(!currentlyEnabled)
}
const getStatusDescription = () => {
if (!isPlusAccount(userProfile)) {
return 'Real-time updates (SSE) are not available in the Basic plan. Upgrade to Plus to receive instant notifications when chores are updated.'
}
if (!isSSEEnabled()) {
return 'Real-time updates (SSE) are disabled. Enable to see live changes when you or other circle members complete, skip, or modify chores.'
}
if (isConnected) {
return "Real-time updates (SSE) are working. You'll see live changes when you or other circle members complete, skip, or modify chores."
}
if (isConnecting) {
return 'Connecting to real-time updates (SSE)...'
}
if (error) {
return `Real-time updates (SSE) are enabled but not working: ${error}`
}
return 'Real-time updates (SSE) are enabled but not currently connected.'
}
return (
<Card sx={{ mt: 2, p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{isSSEEnabled() && isPlusAccount(userProfile) ? (
<Sync color={isConnected ? 'success' : 'disabled'} />
) : (
<SyncDisabled color='disabled' />
)}
<Box sx={{ flex: 1 }}>
<Typography level='title-md'>
Real-time Updates (SSE)
{!isPlusAccount(userProfile) && (
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
Plus Feature
</Chip>
)}
</Typography>
<Typography level='body-sm' color='neutral'>
Get instant notifications via Server-Sent Events
</Typography>
</Box>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<SSEConnectionStatus variant='chip' />
)}
</Box>
<FormControl orientation='horizontal' sx={{ mb: 2 }}>
<Box sx={{ flex: 1 }}>
<FormLabel>Enable Real-time Updates (SSE)</FormLabel>
<FormHelperText sx={{ mt: 0 }}>
{getStatusDescription()}
</FormHelperText>
</Box>
<Switch
checked={isSSEEnabled() && isPlusAccount(userProfile)}
onChange={handleToggle}
disabled={!isPlusAccount(userProfile)}
color={
isSSEEnabled() && isPlusAccount(userProfile) ? 'success' : 'neutral'
}
variant='solid'
endDecorator={
isSSEEnabled() && isPlusAccount(userProfile) ? 'On' : 'Off'
}
slotProps={{ endDecorator: { sx: { minWidth: 24 } } }}
/>
</FormControl>
{isSSEEnabled() && isPlusAccount(userProfile) && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 1 }}>
<Typography level='body-xs' color='neutral'>
Status:
</Typography>
<Chip
size='sm'
variant='soft'
color={
isConnected ? 'success' : isConnecting ? 'warning' : 'danger'
}
>
{getConnectionStatus()}
</Chip>
{error && (
<Typography level='body-xs' color='danger'>
{error}
</Typography>
)}
</Box>
)}
{!isPlusAccount(userProfile) && (
<Typography level='body-sm' color='warning' sx={{ mt: 1 }}>
Real-time updates (SSE) are not available in the Basic plan. Upgrade
to Plus to receive instant notifications when you or other circle
members complete, skip, or modify chores.
</Typography>
)}
</Card>
)
}
export default SSESettings

View File

@@ -0,0 +1,259 @@
import { Check, Star } from '@mui/icons-material'
import {
Box,
Button,
Card,
Chip,
Divider,
Modal,
ModalDialog,
Radio,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNotification } from '../service/NotificationProvider'
import { GetSubscriptionSession } from '../utils/Fetcher'
const SubscriptionModal = ({ open, onClose }) => {
const [selectedPlan, setSelectedPlan] = useState('yearly')
const [isLoading, setIsLoading] = useState(false)
const { showError } = useNotification()
const plans = {
yearly: {
price: '$39.00',
period: 'year',
total: '$39.00/year',
// savings: 'Save $20.88',
// popular: true,
},
// monthly: {
// price: '$4.99',
// period: 'month',
// total: '$4.99/month',
// savings: null,
// },
}
const features = [
'Task notifications and reminders',
'Rich text descriptions with images uploads',
'Thing-based task triggers',
'API tokens for integrations',
'Image uploads in descriptions',
'Advanced task automation',
// 'Unlimited task history',
// 'Unlimited things history',
]
const handleSubscribe = async () => {
setIsLoading(true)
try {
// Call the backend with the selected plan
const response = await GetSubscriptionSession()
if (!response.ok) {
throw new Error('Failed to create subscription session')
}
const data = await response.json()
// Redirect to Stripe
if (data.sessionURL) {
window.location.href = data.sessionURL
} else {
throw new Error('No session URL received')
}
} catch (error) {
console.error('Subscription error:', error)
showError({
title: 'Subscription Error',
message: 'Failed to start subscription process. Please try again.',
})
} finally {
setIsLoading(false)
}
}
return (
<Modal open={open} onClose={onClose}>
<ModalDialog
layout='center'
sx={{
width: 600,
maxWidth: '95vw',
maxHeight: '95vh',
overflow: 'auto',
p: 0,
}}
>
<Box sx={{ p: 4 }}>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography level='h3' sx={{ mb: 1 }}>
Upgrade to Plus
</Typography>
</Box>
{/* Features List */}
<Box sx={{ mb: 2 }}>
<Typography level='title-lg' sx={{ mb: 2 }}>
What&apos;s included:
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{features.map((feature, index) => (
<Box
key={index}
sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
>
<Check color='success' sx={{ fontSize: 20 }} />
<Typography level='body-md'>{feature}</Typography>
</Box>
))}
</Box>
</Box>
<Divider sx={{ my: 3 }} />
{/* Plan Selection */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mb: 4 }}
>
{Object.entries(plans).map(([key, plan]) => (
<Card
key={key}
color={selectedPlan === key ? 'primary' : 'neutral'}
onClick={() => setSelectedPlan(key)}
sx={{
width: '100%',
minHeight: 48,
maxHeight: 64,
cursor: 'pointer',
transition: 'all 0.2s',
mb: 0.2,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 2.5,
py: 1.2,
position: 'relative',
overflow: 'visible',
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
justifyContent: 'flex-start',
width: '100%',
}}
>
<Radio
checked={selectedPlan === key}
onChange={() => setSelectedPlan(key)}
value={key}
name='subscription-plan'
color='primary'
sx={{ mr: 1 }}
/>
<Typography level='body-md' sx={{ fontWeight: 600 }}>
{key.charAt(0).toUpperCase() + key.slice(1)}
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 500, ml: 1 }}>
{plan.price}
<span style={{ color: '#888', fontWeight: 400 }}>
{' '}
/ {plan.period}
</span>
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
position: 'absolute',
right: 16,
top: -18,
}}
>
{plan.popular && (
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Star />}
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
Most Popular
</Chip>
)}
{plan.savings && (
<Chip
variant='soft'
color='success'
size='sm'
sx={{
fontWeight: 600,
fontSize: 12,
px: 1,
py: 0.1,
boxShadow: 2,
mt: 0.8,
}}
>
{plan.savings}
</Chip>
)}
</Box>
</Card>
))}
</Box>
{/* Action Buttons */}
<Box
sx={{ display: 'flex', flexDirection: 'column', gap: 1.2, mt: 2 }}
>
<Button
variant='solid'
color='primary'
onClick={handleSubscribe}
loading={isLoading}
fullWidth
size='lg'
sx={{ mb: 1 }}
>
Subscribe
</Button>
<Button
variant='plain'
onClick={onClose}
disabled={isLoading}
fullWidth
>
Cancel
</Button>
</Box>
{/* Footer */}
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 3 }}
>
Cancel anytime. No hidden fees. Secure payment powered by Stripe.
</Typography>
</Box>
</ModalDialog>
</Modal>
)
}
export default SubscriptionModal

View File

@@ -0,0 +1,539 @@
import {
AdminPanelSettings,
DarkModeOutlined,
GroupAdd,
LightModeOutlined,
Logout,
Person,
Settings,
SwapHoriz,
Tune,
WorkspacePremium,
} from '@mui/icons-material'
import {
Avatar,
Box,
Divider,
Dropdown,
ListItemContent,
ListItemDecorator,
Menu,
MenuButton,
MenuItem,
Sheet,
Typography,
useColorScheme,
} from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import moment from 'moment'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useImpersonateUser } from '../contexts/ImpersonateUserContext'
import useStickyState from '../hooks/useStickyState'
import { useCircleMembers, useUserProfile } from '../queries/UserQueries'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
const UserProfileAvatar = () => {
const navigate = useNavigate()
const { mode, setMode } = useColorScheme()
const { data: userProfile } = useUserProfile()
const {
isImpersonating,
startImpersonation,
stopImpersonation,
canImpersonate,
getEffectiveUser,
} = useImpersonateUser()
const { data: circleMembersData } = useCircleMembers()
const [isModalOpen, setIsModalOpen] = useState(false)
const [isSubscriptionModalOpen, setIsSubscriptionModalOpen] = useState(false)
const [themeMode, setThemeMode] = useStickyState(mode, 'themeMode')
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('lg'))
if (!userProfile) return null
const currentUser = getEffectiveUser(userProfile)
const isAdmin = canImpersonate(userProfile, circleMembersData?.res)
const isPlusUser = isPlusAccount(userProfile)
const getSubscriptionStatus = () => {
if (!userProfile) return 'Free'
if (userProfile.subscription === 'active') {
return 'Plus'
}
if (
userProfile.subscription === 'cancelled' &&
moment().isBefore(userProfile.expiration)
) {
return 'Plus (expires soon)'
}
return 'Free'
}
const handleLogout = () => {
apiClient.handleLogout()
}
const handleSupportEmail = () => {
window.location.href = 'mailto:support@donetick.com'
}
const isDarkMode = themeMode === 'dark'
const handleThemeToggle = () => {
const newThemeMode = isDarkMode ? 'light' : 'dark'
setThemeMode(newThemeMode)
setMode(newThemeMode)
}
return (
<>
<Dropdown>
<MenuButton
variant='plain'
sx={{
p: 0,
border: 'none',
backgroundColor: 'transparent',
borderRadius: '50%',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
// transform: 'scale(1.05)',
// transition: 'all 0.2s ease',
},
'&:active': {
// transform: 'scale(0.95)',
},
}}
>
<Box sx={{ position: 'relative' }}>
{isImpersonating ? (
<Box sx={{ position: 'relative' }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
width: 36,
height: 36,
border: '2px solid var(--joy-palette-background-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
<Avatar
src={userProfile?.image || userProfile?.avatar}
alt={userProfile?.displayName || userProfile?.name}
size='sm'
sx={{
position: 'absolute',
bottom: -2,
left: -2,
width: 18,
height: 18,
border: '2px solid var(--joy-palette-background-surface)',
backgroundColor: 'var(--joy-palette-background-surface)',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}
/>
<Box
sx={{
position: 'absolute',
top: -2,
right: -2,
width: 14,
height: 14,
borderRadius: '50%',
backgroundColor: 'var(--joy-palette-primary-500)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid var(--joy-palette-background-surface)',
boxShadow: '0 1px 2px rgba(0,0,0,0.2)',
}}
>
<SwapHoriz sx={{ fontSize: 8, color: 'white' }} />
</Box>
</Box>
) : (
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
width: 36,
height: 36,
border: '2px solid var(--joy-palette-background-surface)',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
)}
</Box>
</MenuButton>
<Menu
placement='bottom-end'
sx={{
minWidth: 280,
p: 1,
'--List-gap': '4px',
boxShadow: 'var(--joy-shadow-lg)',
border: '1px solid var(--joy-palette-divider)',
borderRadius: 'var(--joy-radius-md)',
}}
>
<Sheet sx={{ p: 2, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
alt={currentUser?.displayName || currentUser?.name}
size='lg'
sx={{
width: 48,
height: 48,
border: '2px solid var(--joy-palette-background-surface)',
}}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
level='title-md'
sx={{
fontWeight: 600,
color: 'var(--joy-palette-text-primary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.25,
}}
>
{currentUser?.displayName || currentUser?.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography
level='body-sm'
sx={{
color: 'var(--joy-palette-text-tertiary)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
}}
>
{currentUser?.email}
</Typography>
{isPlusUser && (
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-warning-600)',
fontWeight: 500,
fontSize: '11px',
}}
>
{getSubscriptionStatus()}
</Typography>
)}
</Box>
{isImpersonating && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
mt: 0.5,
px: 1,
py: 0.25,
backgroundColor: 'var(--joy-palette-primary-softBg)',
borderRadius: 'var(--joy-radius-sm)',
width: 'fit-content',
}}
>
<SwapHoriz
sx={{
fontSize: 12,
color: 'var(--joy-palette-primary-600)',
}}
/>
<Typography
level='body-xs'
sx={{
color: 'var(--joy-palette-primary-600)',
fontWeight: 500,
}}
>
Impersonating
</Typography>
</Box>
)}
</Box>
</Box>
</Sheet>
{isAdmin && (
<>
<MenuItem
onClick={() => setIsModalOpen(true)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-primary-500)' }}
>
<AdminPanelSettings />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isImpersonating ? 'Switch User' : 'Impersonate User'}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Act as another user
</Typography>
</ListItemContent>
</MenuItem>
{isImpersonating && (
<MenuItem
onClick={() => stopImpersonation()}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-success-500)' }}
>
<Person />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Stop Impersonating
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Return to your account
</Typography>
</ListItemContent>
</MenuItem>
)}
<Divider sx={{ my: 1 }} />
</>
)}
<MenuItem
onClick={() => navigate('/settings')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
<Settings />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Settings
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Account & preferences
</Typography>
</ListItemContent>
</MenuItem>
<MenuItem
onClick={() => navigate('/settings/circle')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-primary-500)' }}>
<GroupAdd />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Invite People
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Add members to your circle
</Typography>
</ListItemContent>
</MenuItem>
{isLargeScreen && (
<MenuItem
onClick={() => navigate('/settings/detailed#sidepanel')}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-neutral-500)' }}
>
<Tune />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Side Panel Settings
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Customize layout & cards
</Typography>
</ListItemContent>
</MenuItem>
)}
<MenuItem
onClick={handleThemeToggle}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-neutral-500)' }}>
{isDarkMode ? <LightModeOutlined /> : <DarkModeOutlined />}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
{isDarkMode ? 'Switch to Light' : 'Switch to Dark'}
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
Toggle theme appearance
</Typography>
</ListItemContent>
</MenuItem>
{!isPlusUser && (
<MenuItem
onClick={() => setIsSubscriptionModalOpen(true)}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-warning-softHoverBg)',
},
}}
>
<ListItemDecorator
sx={{ color: 'var(--joy-palette-warning-500)' }}
>
<WorkspacePremium />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{
fontWeight: 500,
}}
>
Upgrade to Plus
</Typography>
<Typography level='body-xs'>Unlock premium features</Typography>
</ListItemContent>
</MenuItem>
)}
{/* <MenuItem
onClick={handleSupportEmail}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-info-500)' }}>
<Email />
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
Support
</Typography>
<Typography
level='body-xs'
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
>
support@donetick.com
</Typography>
</ListItemContent>
</MenuItem> */}
<Divider sx={{ my: 1 }} />
<MenuItem
onClick={handleLogout}
sx={{
borderRadius: 'var(--joy-radius-sm)',
'&:hover': {
backgroundColor: 'var(--joy-palette-danger-softHoverBg)',
},
}}
>
<ListItemDecorator sx={{ color: 'var(--joy-palette-danger-500)' }}>
<Logout />
</ListItemDecorator>
<ListItemContent>
<Typography
level='body-sm'
sx={{ fontWeight: 500, color: 'var(--joy-palette-danger-500)' }}
>
Logout
</Typography>
</ListItemContent>
</MenuItem>
</Menu>
</Dropdown>
<UserModal
isOpen={isModalOpen}
performers={circleMembersData?.res}
onSelect={user => {
startImpersonation(user, userProfile)
setIsModalOpen(false)
}}
onClose={() => setIsModalOpen(false)}
/>
<SubscriptionModal
open={isSubscriptionModalOpen}
onClose={() => setIsSubscriptionModalOpen(false)}
/>
</>
)
}
export default UserProfileAvatar

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { Box } from '@mui/joy'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { useStaggeredAnimation, useReducedMotion } from '../../hooks/useAnimations'
import './PageTransition.css'
const AnimatedList = ({
children,
staggerDelay = 50,
animationType = 'stagger', // 'stagger', 'fade', 'slide'
direction = 'up', // 'up', 'down', 'left', 'right'
renderItem,
keyExtractor,
items,
...boxProps
}) => {
// Handle both children and items patterns
let childrenArray
if (items && renderItem) {
childrenArray = items.map((item, index) =>
React.cloneElement(renderItem(item, index), {
key: keyExtractor ? keyExtractor(item, index) : index
})
)
} else {
childrenArray = React.Children.toArray(children)
}
const visibleItems = useStaggeredAnimation(childrenArray.length, staggerDelay)
const prefersReducedMotion = useReducedMotion()
// If user prefers reduced motion, render without animations
if (prefersReducedMotion) {
return (
<Box {...boxProps}>
{items && renderItem ? childrenArray : children}
</Box>
)
}
const getAnimationClass = () => {
switch (animationType) {
case 'fade':
return 'fade'
case 'slide':
return direction === 'up' ? 'slide-up' : 'page'
case 'stagger':
default:
return 'stagger'
}
}
return (
<Box {...boxProps}>
<TransitionGroup component={null}>
{childrenArray.map((child, index) => {
const isVisible = visibleItems.has(index)
return (
<CSSTransition
key={child.key || index}
in={isVisible}
timeout={{
enter: 300,
exit: 200,
}}
classNames={getAnimationClass()}
unmountOnExit={false}
>
<Box
sx={{
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'none' : 'translateY(20px)',
transition: 'opacity 0.3s ease, transform 0.3s ease',
transitionDelay: `${index * staggerDelay}ms`,
}}
>
{child}
</Box>
</CSSTransition>
)
})}
</TransitionGroup>
</Box>
)
}
export default AnimatedList

View File

@@ -0,0 +1,105 @@
import { Box, CircularProgress, Typography } from '@mui/joy'
import { styled } from '@mui/joy/styles'
// Styled components with keyframe animations
const LoadingContainer = styled(Box)({
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(8px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
animation: 'fadeIn 0.3s ease-out',
'@keyframes fadeIn': {
from: {
opacity: 0,
},
to: {
opacity: 1,
},
},
})
const LoadingContent = styled(Box)({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '24px',
animation: 'slideUp 0.5s ease-out 0.2s both',
'@keyframes slideUp': {
from: {
opacity: 0,
transform: 'translateY(20px)',
},
to: {
opacity: 1,
transform: 'translateY(0)',
},
},
})
const PulsingText = styled(Typography)({
animation: 'pulse 2s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': {
opacity: 1,
},
'50%': {
opacity: 0.5,
},
},
})
const LogoContainer = styled(Box)({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBottom: '24px',
})
const LoadingScreen = ({
message = 'Loading...',
showLogo = true,
size = 'lg',
}) => {
return (
<LoadingContainer>
<LoadingContent>
{showLogo && (
<LogoContainer>
<Typography
level='h1'
sx={{
fontSize: { xs: '2rem', sm: '2.5rem' },
fontWeight: 700,
color: 'primary.500',
mb: 1,
}}
>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
</LogoContainer>
)}
<CircularProgress
size={size}
sx={{
'--CircularProgress-size': size === 'lg' ? '60px' : '40px',
mb: 2,
}}
/>
<PulsingText level='body-md'>{message}</PulsingText>
</LoadingContent>
</LoadingContainer>
)
}
export default LoadingScreen

View File

@@ -0,0 +1,248 @@
/* Page Wrapper */
.page-wrapper {
position: relative;
width: 100%;
min-height: 100vh;
}
/* View Transitions API - Native browser page transitions */
/* These run automatically when the browser supports view transitions */
/* Forward navigation (going deeper) */
::view-transition-old(root):only-child {
animation: slide-out-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
::view-transition-new(root):only-child {
animation: slide-in-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Back navigation (going up) */
html[data-transition='back']::view-transition-old(root):only-child {
animation: slide-out-right 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
html[data-transition='back']::view-transition-new(root):only-child {
animation: slide-in-left 250ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Fade for auth pages */
html[data-transition='fade']::view-transition-old(root):only-child,
html[data-transition='fade']::view-transition-new(root):only-child {
animation: fade 200ms ease-in-out;
}
/* Animation keyframes */
@keyframes slide-out-left {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(-30%);
opacity: 0;
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes slide-out-right {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(100%);
opacity: 0;
}
}
@keyframes slide-in-left {
from {
transform: translateX(-30%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes fade {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* Modal/overlay animations (still useful for non-route transitions) */
.modal-enter {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
.modal-enter-active {
opacity: 1;
transform: scale(1) translateY(0);
transition:
opacity 250ms ease-out,
transform 250ms ease-out;
}
.modal-exit {
opacity: 1;
transform: scale(1) translateY(0);
}
.modal-exit-active {
opacity: 0;
transform: scale(0.95) translateY(10px);
transition:
opacity 200ms ease-in,
transform 200ms ease-in;
}
/* Slide up animation for bottom sheets/panels */
.slide-up-enter {
opacity: 0;
transform: translateY(100%);
}
.slide-up-enter-active {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
}
.slide-up-exit {
opacity: 1;
transform: translateY(0);
}
.slide-up-exit-active {
opacity: 0;
transform: translateY(100%);
transition:
opacity 250ms ease-in,
transform 250ms ease-in;
}
/* Loading spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.loading-spinner {
animation: spin 1s linear infinite;
}
/* Skeleton loading animations */
@keyframes skeleton-loading {
0% {
background-position: -200px 0;
}
100% {
background-position: calc(200px + 100%) 0;
}
}
.skeleton {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200px 100%;
animation: skeleton-loading 1.5s infinite;
}
/* Micro-interactions */
.interactive-element {
transition: all 0.2s ease-out;
transform: translateZ(0); /* Enable GPU acceleration */
}
.interactive-element:hover {
transform: translateY(-2px) translateZ(0);
}
.interactive-element:active {
transform: translateY(0) translateZ(0);
transition: all 0.1s ease-out;
}
/* Stagger animation for lists */
.stagger-enter {
opacity: 0;
transform: translateY(20px);
}
.stagger-enter-active {
opacity: 1;
transform: translateY(0);
transition:
opacity 300ms ease-out,
transform 300ms ease-out;
}
/* Add animation delays for staggered effects */
.stagger-enter-active:nth-child(1) {
transition-delay: 0ms;
}
.stagger-enter-active:nth-child(2) {
transition-delay: 50ms;
}
.stagger-enter-active:nth-child(3) {
transition-delay: 100ms;
}
.stagger-enter-active:nth-child(4) {
transition-delay: 150ms;
}
.stagger-enter-active:nth-child(5) {
transition-delay: 200ms;
}
.stagger-enter-active:nth-child(6) {
transition-delay: 250ms;
}
.stagger-enter-active:nth-child(7) {
transition-delay: 300ms;
}
.stagger-enter-active:nth-child(8) {
transition-delay: 350ms;
}
/* Reduced motion for accessibility */
@media (prefers-reduced-motion: reduce) {
/* Disable View Transitions for users who prefer reduced motion */
::view-transition-old(root),
::view-transition-new(root) {
animation: none !important;
}
.modal-enter,
.modal-enter-active,
.modal-exit,
.modal-exit-active,
.interactive-element,
.stagger-enter,
.stagger-enter-active {
transition: none !important;
animation: none !important;
transform: none !important;
}
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocation } from 'react-router-dom'
import './PageTransition.css'
// Route hierarchy for determining navigation direction
const routeHierarchy = {
'/': 0,
'/my/chores': 0,
'/chores': 1,
'/chores/create': 2,
'/settings': 1,
'/things': 1,
'/activities': 1,
'/points': 1,
'/labels': 1,
'/projects': 1,
'/login': 0,
'/signup': 1,
'/landing': 0,
'/archived': 1,
}
const getRouteLevel = pathname => {
// Check for exact matches first
if (routeHierarchy[pathname] !== undefined) {
return routeHierarchy[pathname]
}
// Check for dynamic routes (e.g., /chores/123/edit)
if (pathname.includes('/chores/') && pathname.includes('/edit')) {
return 3
}
if (pathname.includes('/chores/') && pathname.includes('/history')) {
return 3
}
if (pathname.includes('/chores/') && pathname.includes('/timer')) {
return 3
}
if (
pathname.includes('/chores/') &&
!pathname.includes('/edit') &&
!pathname.includes('/history') &&
!pathname.includes('/timer')
) {
return 2
}
if (pathname.includes('/things/')) {
return 2
}
if (pathname.includes('/settings/')) {
return 2
}
// Default level
return 1
}
const PageTransition = ({ children }) => {
const location = useLocation()
const prevLevel = useRef(0)
const [displayLocation, setDisplayLocation] = useState(location)
const isFirstRender = useRef(true)
useLayoutEffect(() => {
// Skip transition on first render
if (isFirstRender.current) {
isFirstRender.current = false
setDisplayLocation(location)
prevLevel.current = getRouteLevel(location.pathname)
return
}
// Don't transition if location hasn't actually changed
if (location.pathname === displayLocation.pathname) {
return
}
const currentLevel = getRouteLevel(location.pathname)
const previousLevel = prevLevel.current
// Determine navigation direction
const isBack = currentLevel < previousLevel
const isFade =
location.pathname.includes('/login') ||
location.pathname.includes('/signup') ||
location.pathname.includes('/landing') ||
location.pathname.includes('/auth/')
// Apply transition type as data attribute for CSS
document.documentElement.dataset.transition = isFade
? 'fade'
: isBack
? 'back'
: 'forward'
// Trigger View Transition if supported
if (document.startViewTransition) {
document.startViewTransition(() => {
// flushSync forces React to update the DOM synchronously
// This ensures the View Transition captures the actual DOM change
flushSync(() => {
setDisplayLocation(location)
})
// Scroll to top after DOM update
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
})
} else {
// Fallback for browsers without View Transitions API
setDisplayLocation(location)
window.scrollTo({ top: 0, left: 0, behavior: 'instant' })
}
// Update refs
prevLevel.current = currentLevel
}, [location, displayLocation.pathname])
return (
<div
className='page-wrapper'
style={{
paddingBottom: `var(--safe-area-inset-bottom, 0px)`,
}}
>
{displayLocation.pathname === location.pathname ? children : null}
</div>
)
}
export default PageTransition

View File

@@ -0,0 +1,138 @@
import { Box, Skeleton } from '@mui/joy'
const SkeletonLoader = ({
type = 'card',
count = 1,
height = 100,
width = '100%',
variant = 'rectangular',
...props
}) => {
const renderSkeleton = () => {
switch (type) {
case 'card':
return (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
}}
>
<Skeleton variant='text' height={24} width='60%' sx={{ mb: 1 }} />
<Skeleton
variant='text'
height={16}
width='100%'
sx={{ mb: 0.5 }}
/>
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', gap: 1 }}>
<Skeleton variant='circular' width={32} height={32} />
<Skeleton variant='rectangular' height={32} width={80} />
</Box>
</Box>
)
case 'list':
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 1.5 }}>
<Skeleton variant='circular' width={40} height={40} />
<Box sx={{ flex: 1 }}>
<Skeleton
variant='text'
height={20}
width='70%'
sx={{ mb: 0.5 }}
/>
<Skeleton variant='text' height={16} width='50%' />
</Box>
<Skeleton variant='rectangular' width={60} height={24} />
</Box>
)
case 'chore':
return (
<Box
sx={{
p: 2,
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
mb: 1,
}}
>
<Skeleton variant='text' height={24} width='50%' />
<Skeleton variant='circular' width={24} height={24} />
</Box>
<Skeleton variant='text' height={16} width='80%' sx={{ mb: 1 }} />
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Skeleton variant='rectangular' height={20} width={60} />
<Skeleton variant='rectangular' height={20} width={40} />
</Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Skeleton variant='circular' width={32} height={32} />
<Skeleton variant='text' height={16} width='30%' />
</Box>
</Box>
)
case 'profile':
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: 3,
}}
>
<Skeleton
variant='circular'
width={80}
height={80}
sx={{ mb: 2 }}
/>
<Skeleton variant='text' height={24} width={150} sx={{ mb: 1 }} />
<Skeleton variant='text' height={16} width={100} />
</Box>
)
default:
return (
<Skeleton
variant={variant}
height={height}
width={width}
{...props}
/>
)
}
}
return (
<>
{Array.from({ length: count }, (_, index) => (
<Box key={index} sx={{ mb: type === 'list' ? 0 : 2 }}>
{renderSkeleton()}
</Box>
))}
</>
)
}
export default SkeletonLoader

View File

@@ -0,0 +1,107 @@
import { Button } from '@mui/joy'
import { styled } from '@mui/joy/styles'
const AnimatedButton = styled(Button)(({ theme }) => ({
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateZ(0)', // Enable GPU acceleration
position: 'relative',
overflow: 'hidden',
'&:hover:not(:disabled)': {
transform: 'translateY(-2px) translateZ(0)',
boxShadow: theme.shadow.lg,
},
'&:active:not(:disabled)': {
transform: 'translateY(0) translateZ(0)',
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
},
'&:focus-visible': {
outline: '2px solid',
outlineColor: theme.palette.primary[500],
outlineOffset: '2px',
},
// Ripple effect
'&::before': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '0',
height: '0',
borderRadius: '50%',
background: 'currentColor',
opacity: 0.1,
transform: 'translate(-50%, -50%)',
transition: 'width 0.6s, height 0.6s',
},
'&:active::before': {
width: '300px',
height: '300px',
},
// Loading state
'&[data-loading="true"]': {
pointerEvents: 'none',
position: 'relative',
'& > *': {
opacity: 0.6,
},
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '16px',
height: '16px',
border: '2px solid currentColor',
borderTop: '2px solid transparent',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
animation: 'spin 1s linear infinite',
},
},
// Reduced motion support
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none !important',
'&:hover:not(:disabled)': {
transform: 'none',
},
'&:active:not(:disabled)': {
transform: 'none',
},
'&::before': {
display: 'none',
},
},
}))
const SmoothButton = ({ children, loading = false, onClick, ...props }) => {
const handleClick = event => {
if (loading) return
onClick?.(event)
}
return (
<AnimatedButton
{...props}
onClick={handleClick}
data-loading={loading}
disabled={props.disabled || loading}
>
{children}
</AnimatedButton>
)
}
export default SmoothButton

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { Card } from '@mui/joy'
import { styled } from '@mui/joy/styles'
const AnimatedCard = styled(Card)(({ theme }) => ({
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
transform: 'translateZ(0)', // Enable GPU acceleration
cursor: 'pointer',
position: 'relative',
'&:hover': {
transform: 'translateY(-4px) translateZ(0)',
boxShadow: theme.shadow.lg,
},
'&:active': {
transform: 'translateY(-2px) translateZ(0)',
transition: 'all 0.1s cubic-bezier(0.4, 0, 0.2, 1)',
},
// Subtle background animation on hover
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(45deg, transparent, rgba(255,255,255,0.1), transparent)',
opacity: 0,
transition: 'opacity 0.3s ease',
pointerEvents: 'none',
borderRadius: 'inherit',
},
'&:hover::before': {
opacity: 1,
},
// Focus states for accessibility
'&:focus-visible': {
outline: '2px solid',
outlineColor: theme.palette.primary[500],
outlineOffset: '2px',
},
// Reduced motion support
'@media (prefers-reduced-motion: reduce)': {
transition: 'none',
transform: 'none !important',
'&:hover': {
transform: 'none',
boxShadow: theme.shadow.md, // Still provide visual feedback
},
'&:active': {
transform: 'none',
},
'&::before': {
display: 'none',
},
},
}))
const SmoothCard = ({
children,
onClick,
animationDisabled = false,
...props
}) => {
if (animationDisabled) {
return (
<Card {...props} onClick={onClick}>
{children}
</Card>
)
}
return (
<AnimatedCard {...props} onClick={onClick}>
{children}
</AnimatedCard>
)
}
export default SmoothCard

View File

@@ -0,0 +1,54 @@
import { Box } from '@mui/joy'
import React, { useEffect, useState } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import './PageTransition.css'
const StaggeredList = ({
children,
staggerDelay = 50,
initialDelay = 0,
animate = true,
}) => {
const [isVisible, setIsVisible] = useState(!animate)
useEffect(() => {
if (animate) {
const timer = setTimeout(() => {
setIsVisible(true)
}, initialDelay)
return () => clearTimeout(timer)
}
}, [animate, initialDelay])
if (!animate) {
return <Box>{children}</Box>
}
const childrenArray = React.Children.toArray(children)
return (
<Box>
<TransitionGroup component={null}>
{isVisible &&
childrenArray.map((child, index) => (
<CSSTransition
key={child.key || index}
classNames='stagger'
timeout={{
enter: 300 + index * staggerDelay,
exit: 200,
}}
style={{
transitionDelay: `${index * staggerDelay}ms`,
}}
>
<Box sx={{ mb: 1 }}>{child}</Box>
</CSSTransition>
))}
</TransitionGroup>
</Box>
)
}
export default StaggeredList

View File

@@ -0,0 +1,9 @@
// Animation Components
export { default as AnimatedList } from './AnimatedList'
export { default as PageTransition } from './PageTransition'
export { default as SmoothButton } from './SmoothButton'
export { default as SmoothCard } from './SmoothCard'
export { default as StaggeredList } from './StaggeredList'
// Animation Styles
import './PageTransition.css'

View File

@@ -0,0 +1,254 @@
import { Close } from '@mui/icons-material'
import { Divider, IconButton, Modal, Sheet, Typography } from '@mui/joy'
import { forwardRef, useEffect, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BottomSheetModal = forwardRef(
(
{
open,
onClose,
children,
title,
footer,
height = 'auto',
maxHeight = '90vh',
expandedHeight = '95vh',
backdropBlur = true,
showHandle = true,
showCloseButton = true,
...props
},
ref,
) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const [internalOpen, setInternalOpen] = useState(open)
// Handle opening
useEffect(() => {
if (open) {
setInternalOpen(true)
setIsClosing(false)
}
}, [open])
// Handle closing with animation
useEffect(() => {
if (!open && internalOpen) {
setIsClosing(true)
// Wait for animation to complete before hiding modal
const timer = setTimeout(() => {
setInternalOpen(false)
setIsClosing(false)
setIsExpanded(false)
}, 250) // Match transition duration
return () => clearTimeout(timer)
}
}, [open, internalOpen])
// Handle toggle expansion
const handleToggleExpansion = () => {
setIsExpanded(prev => !prev)
}
// Close on escape key
useEffect(() => {
const handleEscape = event => {
if (event.key === 'Escape' && internalOpen) {
onClose?.()
}
}
if (internalOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll when modal is open
// document.body.style.overflow = 'hidden'
} else {
// Restore scroll immediately when modal starts closing
// document.body.style.overflow = 'unset'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [internalOpen, onClose])
// Calculate current height
const currentHeight = isExpanded ? expandedHeight : height
// Filter out DOM props that shouldn't be passed to Modal
const {
fullWidth: _fullWidth,
unmountDelay: _unmountDelay,
...modalProps
} = props
return (
<Modal
open={internalOpen}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
keepMounted
{...modalProps}
>
<Sheet
ref={ref}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minHeight: '20%',
width: '100%',
height: currentHeight,
maxHeight: isExpanded ? expandedHeight : maxHeight,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
p: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition:
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
transform:
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
// Handle safe area on mobile devices
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Header Section with drag handle, title, and close button */}
<div
style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: 'inherit',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
position: 'relative',
}}
>
{/* Close button positioned absolutely in top-right */}
{showCloseButton && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 16,
zIndex: 1,
borderRadius: '50%',
width: 32,
height: 32,
backgroundColor: 'neutral.softBg',
color: 'neutral.softColor',
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
transform: 'scale(1.05)',
},
transition: 'all 0.2s ease',
flexShrink: 0,
}}
>
<Close fontSize='small' />
</IconButton>
)}
{/* Drag Handle */}
{showHandle && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 0 8px 0',
cursor: 'pointer',
userSelect: 'none',
marginBottom: 16,
}}
onClick={handleToggleExpansion}
title={isExpanded ? 'Collapse' : 'Expand'}
>
<div
style={{
width: 30,
height: 4,
borderRadius: 2,
backgroundColor: 'var(--joy-palette-neutral-300)',
transition: 'background-color 0.2s ease',
}}
/>
</div>
)}
{/* Title Row */}
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: showHandle
? '0 20px 16px 20px'
: '16px 20px 16px 20px',
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
minHeight: 24,
}}
>
<Typography
level='title-lg'
sx={{
fontWeight: 600,
flex: 1,
}}
>
{title}
</Typography>
</div>
)}
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '0 20px 20px 20px',
minHeight: 0, // Important for flex child with overflow
}}
>
{children}
</div>
{footer && (
<>
<Divider />
<footer
style={{
flexShrink: 0,
// borderTop: '1px solid var(--joy-palette-divider)',
padding: '16px 20px',
}}
>
{footer}
</footer>
</>
)}
</Sheet>
</Modal>
)
},
)
BottomSheetModal.displayName = 'BottomSheetModal'
export default BottomSheetModal

View File

@@ -0,0 +1,86 @@
import { Modal, ModalDialog, ModalOverflow } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
* FadeModal component with consistent fade-in/out animations
* Can be used as a drop-in replacement for Joy UI's Modal component
*/
const FadeModal = ({
open,
onClose,
children,
size = 'md',
fullWidth = false,
backdropBlur = true,
...props
}) => {
// Filter out props that shouldn't be passed to Modal
const { unmountDelay: _unmountDelay, ...modalProps } = props
return (
<Modal
open={open}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
},
}}
keepMounted
// These transition properties create a smooth fade + slide effect
transition={{
mount: { opacity: 1, transform: 'translateY(0px)' },
unmount: { opacity: 0, transform: 'translateY(20px)' },
duration: 250, // Animation duration in ms
easing: {
enter: 'cubic-bezier(0.34, 1.56, 0.64, 1)', // Slight overshoot for natural feel
exit: 'cubic-bezier(0.4, 0, 0.2, 1)', // Standard ease out
},
}}
{...modalProps}
>
<ModalOverflow>
<ModalDialog
size={size}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
minWidth: fullWidth ? '90%' : 'auto',
animation: open
? 'modalFadeIn 0.35s forwards'
: 'modalFadeOut 0.25s forwards',
'@keyframes modalFadeIn': {
from: { opacity: 0, transform: 'translateY(8px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
'@keyframes modalFadeOut': {
from: { opacity: 1, transform: 'translateY(0)' },
to: { opacity: 0, transform: 'translateY(8px)' },
},
// Add staggered animation for child elements
'& > *': {
opacity: 0,
animation: open
? 'contentFadeIn 0.35s forwards'
: 'contentFadeOut 0.2s forwards',
},
// Stagger child animations
'& > *:nth-of-type(1)': { animationDelay: '0.05s' },
'& > *:nth-of-type(2)': { animationDelay: '0.1s' },
'& > *:nth-of-type(3)': { animationDelay: '0.15s' },
'& > *:nth-of-type(4)': { animationDelay: '0.2s' },
'& > *:nth-of-type(5)': { animationDelay: '0.25s' },
'@keyframes contentFadeIn': {
to: { opacity: 1 },
},
'@keyframes contentFadeOut': {
to: { opacity: 0 },
},
}}
>
{children}
</ModalDialog>
</ModalOverflow>
</Modal>
)
}
export default FadeModal

View File

@@ -0,0 +1,75 @@
import { Chip } from '@mui/joy'
import PropTypes from 'prop-types'
/**
* A component that displays keyboard shortcut hints as small chips
* Only visible on non-mobile devices
* Supports platform-specific shortcuts (Cmd on Mac, Ctrl on Windows) and Shift key
*/
function KeyboardShortcutHint({
shortcut,
show = true,
withCmd = true,
withCtrl, // Legacy prop for backward compatibility
withShift = false,
sx = {},
...props
}) {
if (!show) return null
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0
const modifierKey = isMac ? '⌘' : 'Ctrl'
// Build the shortcut display string
let displayShortcut = ''
// Support both withCmd and withCtrl for backward compatibility
const shouldShowModifier = withCmd || withCtrl
if (shouldShowModifier) {
displayShortcut += modifierKey
}
if (withShift) {
displayShortcut += (displayShortcut ? ' + ' : '') + 'Shift'
}
if (shortcut) {
displayShortcut += (displayShortcut ? ' + ' : '') + shortcut
}
return (
<Chip
size='sm'
variant='outlined'
color='neutral'
sx={{
fontSize: '0.75rem',
maxHeight: '1.5rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
fontWeight: '500',
letterSpacing: '0.025em',
border: '1px solid',
borderColor: 'neutral.300',
// backgroundColor: 'background.surface',
color: 'text.secondary',
borderRadius: '8px',
px: 0.5,
py: 0.125,
boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)',
display: { xs: 'none', md: 'inline-flex' }, // Hide on mobile
...sx,
}}
{...props}
>
{displayShortcut}
</Chip>
)
}
KeyboardShortcutHint.propTypes = {
shortcut: PropTypes.string.isRequired,
show: PropTypes.bool,
withCmd: PropTypes.bool,
withCtrl: PropTypes.bool, // Legacy prop for backward compatibility
withShift: PropTypes.bool,
sx: PropTypes.object,
}
export default KeyboardShortcutHint

View File

@@ -0,0 +1,11 @@
import { SvgIcon } from '@mui/joy'
const DiscordIcon = props => {
return (
<SvgIcon {...props} viewBox='0 0 24 24'>
<path d='M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419-.0190 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1568 2.4189Z' />
</SvgIcon>
)
}
export default DiscordIcon

View File

@@ -0,0 +1,11 @@
import { SvgIcon } from '@mui/joy'
const RedditIcon = props => {
return (
<SvgIcon {...props} viewBox='0 0 24 24'>
<path d='M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z' />
</SvgIcon>
)
}
export default RedditIcon

36
src/constants/zIndex.js Normal file
View File

@@ -0,0 +1,36 @@
// Z-index constants for consistent layering
// Lower values appear behind higher values
export const Z_INDEX = {
// Base layer (0-99)
BASE: 0,
CARD_OVERLAY: 1,
DROPDOWN_ITEM: 2,
TOOLTIP: 3,
// UI Components (100-999)
SAFE_AREA: 100,
CALENDAR: 110,
SMART_INPUT: 110,
AUTOCOMPLETE: 200,
// Navigation (1000-1999)
NAVBAR: 1000,
DRAWER: 999,
// Modals and Overlays (2000-8999)
MODAL_BACKDROP: 2000,
MODAL_CONTENT: 2001,
MODAL_CLOSE_BUTTON: 2002,
TOAST: 3000,
// Critical System UI (9000-9999)
LOADING_SCREEN: 9000,
ALERTS: 9500,
NETWORK_BANNER: 9600,
// Maximum (10000+) - Reserved for absolute emergencies
EMERGENCY: 10000,
}
export default Z_INDEX

View File

@@ -1,13 +1,21 @@
import { AlertsProvider } from '../service/AlertsProvider'
import { NotificationProvider } from '../service/NotificationProvider'
import QueryContext from './QueryContext'
import RouterContext from './RouterContext'
import ThemeContext from './ThemeContext'
const Contexts = () => {
const contexts = [ThemeContext, QueryContext, RouterContext]
const Contexts = ({ children }) => {
const contexts = [
AlertsProvider,
ThemeContext,
QueryContext,
NotificationProvider,
RouterContext,
]
return contexts.reduceRight((acc, Context) => {
return <Context>{acc}</Context>
}, {})
}, children)
}
export default Contexts

View File

@@ -1,15 +1,150 @@
import { createContext, useContext, useState } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from 'react'
const ImpersonateUserContext = createContext()
export const useImpersonateUser = () => useContext(ImpersonateUserContext)
export const ImpersonateUserProvider = ({ children }) => {
const [impersonatedUser, setImpersonatedUser] = useState(null)
const [impersonationState, setImpersonationState] = useState({
isImpersonating: false,
impersonatedUser: null,
originalUser: null,
})
// Start impersonation
const startImpersonation = useCallback((userToImpersonate, currentUser) => {
console.log('Starting impersonation:', { userToImpersonate, currentUser })
const newState = {
isImpersonating: true,
impersonatedUser: userToImpersonate,
originalUser: currentUser,
}
setImpersonationState(newState)
// Store in localStorage for persistence across page refreshes
localStorage.setItem('impersonation', JSON.stringify(newState))
localStorage.setItem('impersonatedUserId', userToImpersonate.userId)
}, [])
// Stop impersonation
const stopImpersonation = useCallback(() => {
console.log('Stopping impersonation')
setImpersonationState({
isImpersonating: false,
impersonatedUser: null,
originalUser: null,
})
// Remove from localStorage
localStorage.removeItem('impersonation')
localStorage.removeItem('impersonatedUserId')
}, [])
// Get effective user (impersonated user if impersonating, otherwise current user)
const getEffectiveUser = useCallback(
currentUser => {
if (
impersonationState.isImpersonating &&
impersonationState.impersonatedUser
) {
return impersonationState.impersonatedUser
}
return currentUser
},
[impersonationState],
)
// Get impersonation headers for API calls
const getImpersonationHeaders = useCallback(() => {
if (
impersonationState.isImpersonating &&
impersonationState.impersonatedUser
) {
return {
'X-Impersonate-User-ID':
impersonationState.impersonatedUser.id.toString(),
}
}
return {}
}, [impersonationState])
// Check if user can impersonate (admin or manager)
// Note: This is a basic check. The component using this should also check circle membership
const canImpersonate = useCallback((user, circleMembers = []) => {
if (!user?.id) return false
// If circleMembers is provided, check role from there
if (circleMembers.length > 0) {
const member = circleMembers.find(m => m.userId === user.id)
return member?.role === 'admin' || member?.role === 'manager'
}
// Fallback to user.role property (if available)
return user?.role === 'admin' || user?.role === 'manager'
}, [])
// Restore impersonation state from localStorage on mount
useEffect(() => {
const storedImpersonation = localStorage.getItem('impersonation')
if (storedImpersonation) {
try {
const parsed = JSON.parse(storedImpersonation)
if (
parsed.isImpersonating &&
parsed.impersonatedUser &&
parsed.originalUser
) {
setImpersonationState(parsed)
}
} catch (error) {
console.error('Failed to restore impersonation state:', error)
localStorage.removeItem('impersonation')
}
}
}, [])
const value = {
// State
...impersonationState,
// Actions
startImpersonation,
stopImpersonation,
getEffectiveUser,
getImpersonationHeaders,
canImpersonate,
// Computed properties
isImpersonating: impersonationState.isImpersonating,
impersonatedUser: impersonationState.impersonatedUser,
originalUser: impersonationState.originalUser,
// Legacy support
setImpersonatedUser: user => {
if (user) {
// If setting a user, assume we're starting impersonation
// Note: This won't have originalUser, so it's for backward compatibility only
setImpersonationState(prev => ({
isImpersonating: true,
impersonatedUser: user,
originalUser: prev.originalUser,
}))
} else {
stopImpersonation()
}
},
}
return (
<ImpersonateUserContext.Provider
value={{ impersonatedUser, setImpersonatedUser }}
>
<ImpersonateUserContext.Provider value={value}>
{children}
</ImpersonateUserContext.Provider>
)

View File

@@ -1,7 +1,16 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const QueryContext = ({ children }) => {
const queryClient = new QueryClient()
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 300000, // 5 minutes
gcTime: 600000, // 10 minutes
refetchOnWindowFocus: false,
retry: 1,
},
},
})
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>

View File

@@ -1,8 +1,15 @@
import App from '@/App'
import ChoreEdit from '@/views/ChoreEdit/ChoreEdit'
import ChoresOverview from '@/views/ChoresOverview'
import Error from '@/views/Error'
import AccountSettings from '@/views/Settings/AccountSettings'
import AdvancedSettings from '@/views/Settings/AdvancedSettings'
import ChildUserSettings from '@/views/Settings/ChildUserSettings'
import CircleSettings from '@/views/Settings/CircleSettings'
import DeveloperSettings from '@/views/Settings/DeveloperSettings'
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 { Capacitor } from '@capacitor/core'
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
import AuthenticationLoading from '../views/Authorization/Authenticating'
@@ -12,21 +19,30 @@ import LoginView from '../views/Authorization/LoginView'
import SignupView from '../views/Authorization/Signup'
import UpdatePasswordView from '../views/Authorization/UpdatePasswordView'
import ChoreView from '../views/ChoreEdit/ChoreView'
import ArchivedTasks from '../views/Chores/ArchivedTasks'
import MyChores from '../views/Chores/MyChores'
import JoinCircleView from '../views/Circles/JoinCircle'
import NotFound from '../views/components/NotFound'
import ChoreHistory from '../views/History/ChoreHistory'
import LabelView from '../views/Labels/LabelView'
import Landing from '../views/Landing/Landing'
import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'
import ProfileSettings from '../views/Settings/ProfileSettings'
import SidepanelSettings from '../views/Settings/SidepanelSettings'
import StorageSettings from '../views/Settings/StorageSettings'
import TermsView from '../views/Terms/TermsView'
import TestView from '../views/TestView/Test'
import ThingsHistory from '../views/Things/ThingsHistory'
import ThingsView from '../views/Things/ThingsView'
import TimerDetails from '../views/Timer/TimerDetails'
import UserActivities from '../views/User/UserActivities'
import UserPoints from '../views/User/UserPoints'
import NotFound from '../views/components/NotFound'
const getMainRoute = () => {
if (
import.meta.env.VITE_IS_LANDING_DEFAULT === 'true' &&
@@ -48,11 +64,73 @@ const Router = createBrowserRouter([
},
{
path: '/settings',
element: <Settings />,
element: <SettingsRoutes />,
children: [
{
index: true,
element: <SettingsOverview />,
},
{
path: 'detailed',
element: <Settings />,
},
{
path: 'profile',
element: <ProfileSettings />,
},
{
path: 'circle',
element: <CircleSettings />,
},
{
path: 'account',
element: <AccountSettings />,
},
{
path: 'subaccounts',
element: <ChildUserSettings />,
},
{
path: 'notifications',
element: <NotificationSetting />,
},
{
path: 'mfa',
element: <MFASettings />,
},
{
path: 'apitokens',
element: <APITokenSettings />,
},
{
path: 'storage',
element: <StorageSettings />,
},
{
path: 'sidepanel',
element: <SidepanelSettings />,
},
{
path: 'theme',
element: <ThemeSettings />,
},
{
path: 'advanced',
element: <AdvancedSettings />,
},
{
path: 'developer',
element: <DeveloperSettings />,
},
],
},
{
path: '/chores',
element: <ChoresOverview />,
element: <MyChores />,
},
{
path: '/archived',
element: <ArchivedTasks />,
},
{
path: '/chores/:choreId/edit',
@@ -70,6 +148,10 @@ const Router = createBrowserRouter([
path: '/chores/:choreId/history',
element: <ChoreHistory />,
},
{
path: '/chores/:choreId/timer',
element: <TimerDetails />,
},
{
path: '/my/chores',
element: <MyChores />,
@@ -94,6 +176,7 @@ const Router = createBrowserRouter([
path: '/signup',
element: <SignupView />,
},
{
path: '/auth/:provider',
element: <AuthenticationLoading />,
@@ -146,6 +229,10 @@ const Router = createBrowserRouter([
path: 'labels/',
element: <LabelView />,
},
{
path: 'projects/',
element: <ProjectView />,
},
{
path: '*',
element: <NotFound />,

View File

@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import { useSSE } from '../hooks/useSSE'
export const SSEContext = createContext({
connectionState: 2, // CLOSED
isConnected: false,
isConnecting: false,
lastEvent: null,
error: null,
connect: () => {},
disconnect: () => {},
getConnectionStatus: () => 'disconnected',
})
export const useSSEContext = () => {
return useContext(SSEContext)
}
export const SSEProvider = ({ children }) => {
const sseState = useSSE()
return <SSEContext.Provider value={sseState}>{children}</SSEContext.Provider>
}
export default SSEProvider

View File

@@ -1,8 +0,0 @@
import { createContext } from 'react'
const UserContext = createContext({
userProfile: null,
setUserProfile: () => {},
})
export { UserContext }

View File

@@ -0,0 +1,39 @@
import { useState } from 'react'
const useAcknowledgmentModal = () => {
const [ackModalConfig, setAckModalConfig] = useState({})
const showAcknowledgment = (
message,
title,
onAcknowledge,
acknowledgeText = 'Got it',
color = 'primary',
) => {
setAckModalConfig({
isOpen: true,
message,
title,
acknowledgeText,
color,
onClose: () => {
if (onAcknowledge) {
onAcknowledge()
}
setAckModalConfig({})
},
})
}
const hideAcknowledgment = () => {
setAckModalConfig({})
}
return {
ackModalConfig,
showAcknowledgment,
hideAcknowledgment,
}
}
export default useAcknowledgmentModal

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
// Hook to detect user's motion preferences
export const useReducedMotion = () => {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false)
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
setPrefersReducedMotion(mediaQuery.matches)
const handleChange = (event) => {
setPrefersReducedMotion(event.matches)
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
return prefersReducedMotion
}
// Hook for staggered animations
export const useStaggeredAnimation = (itemCount, delay = 50) => {
const [visibleItems, setVisibleItems] = useState(new Set())
const prefersReducedMotion = useReducedMotion()
useEffect(() => {
if (prefersReducedMotion) {
// Show all items immediately if reduced motion is preferred
setVisibleItems(new Set(Array.from({ length: itemCount }, (_, i) => i)))
return
}
const timeouts = []
// Stagger the appearance of items
for (let i = 0; i < itemCount; i++) {
const timeout = setTimeout(() => {
setVisibleItems(prev => new Set([...prev, i]))
}, i * delay)
timeouts.push(timeout)
}
return () => {
timeouts.forEach(clearTimeout)
}
}, [itemCount, delay, prefersReducedMotion])
return visibleItems
}
// Hook for intersection observer animations
export const useInViewAnimation = (threshold = 0.1) => {
const [isInView, setIsInView] = useState(false)
const [element, setElement] = useState(null)
useEffect(() => {
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
setIsInView(entry.isIntersecting)
},
{ threshold }
)
observer.observe(element)
return () => {
observer.unobserve(element)
}
}, [element, threshold])
return [setElement, isInView]
}
// Hook for page transition context
export const usePageTransition = () => {
const [isTransitioning, setIsTransitioning] = useState(false)
const startTransition = () => setIsTransitioning(true)
const endTransition = () => setIsTransitioning(false)
return {
isTransitioning,
startTransition,
endTransition,
}
}

121
src/hooks/useAuth.jsx Normal file
View File

@@ -0,0 +1,121 @@
import { createContext, useContext, useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { clearAllTokens, saveTokens } from '../utils/TokenStorage'
import { apiClient } from '../utils/ApiClient'
const AuthContext = createContext(null)
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(() => localStorage.getItem('token'))
const [user, setUser] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const navigate = useNavigate()
const baseURL = apiClient.getApiURL()
const isAuthenticated = !!token
const isTokenExpired = () => {
const expiry = localStorage.getItem('token_expiry')
if (!expiry) return false
return new Date() >= new Date(expiry)
}
const clearAuth = async () => {
setToken(null)
setUser(null)
await clearAllTokens()
}
const login = async credentials => {
setIsLoading(true)
try {
const response = await fetch(`${baseURL}/auth/login`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentials),
})
if (!response.ok) {
const error = await response.json()
return { success: false, error: error.message || 'Login failed' }
}
const data = await response.json()
const userToken = data.token || data.access_token
if (userToken) {
setToken(userToken)
// Use centralized token storage
await saveTokens({
accessToken: userToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
}
setIsLoading(false)
return { success: true, data }
} catch (error) {
setIsLoading(false)
return { success: false, error: 'Network error' }
}
}
const fetchUser = async () => {
if (!token) return null
try {
const response = await apiClient.get('/users/profile')
if (!response.ok) {
return null
}
const userData = await response.json()
setUser(userData)
return userData
} catch (error) {
console.error('Fetch user error:', error)
return null
}
}
useEffect(() => {
// const initAuth = async () => {
// if (token && !isTokenExpired()) {
// await fetchUser()
// } else if (token && isTokenExpired()) {
// // Token is expired, but don't refresh here
// // Let the first API call handle refresh via ApiClient
// // Just try to fetch user - if it fails, ApiClient will handle refresh
// await fetchUser()
// } else {
// clearAuth()
// navigate('/login')
// }
// setIsLoading(false)
// }
// initAuth()
}, [token, navigate])
const value = {
token,
user,
isLoading,
isAuthenticated,
login,
fetchUser,
}
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
}

View File

@@ -0,0 +1,41 @@
import { useState } from 'react'
const useConfirmationModal = () => {
const [confirmModalConfig, setConfirmModalConfig] = useState({})
const showConfirmation = (
message,
title,
onConfirm,
confirmText = 'Confirm',
cancelText = 'Cancel',
color = 'primary',
) => {
setConfirmModalConfig({
isOpen: true,
message,
title,
confirmText,
cancelText,
color,
onClose: isConfirmed => {
if (isConfirmed) {
onConfirm()
}
setConfirmModalConfig({})
},
})
}
const hideConfirmation = () => {
setConfirmModalConfig({})
}
return {
confirmModalConfig,
showConfirmation,
hideConfirmation,
}
}
export default useConfirmationModal

View File

@@ -0,0 +1,18 @@
import BottomSheetModal from '../components/common/BottomSheetModal'
import FadeModal from '../components/common/FadeModal'
import useWindowWidth from './useWindowWidth'
/**
* Hook that returns the appropriate modal component based on screen size
* @param {number} breakpoint - Screen width breakpoint to switch between modals (default: 768px)
* @returns {Object} - { Modal: Component, isMobile: boolean }
*/
export const useResponsiveModal = (breakpoint = 768) => {
const windowWidth = useWindowWidth()
const isMobile = windowWidth <= breakpoint
return {
ResponsiveModal: isMobile ? BottomSheetModal : FadeModal,
isMobile,
}
}

750
src/hooks/useSSE.js Normal file
View File

@@ -0,0 +1,750 @@
import { useQueryClient } from '@tanstack/react-query'
import { EventSourcePolyfill } from 'event-source-polyfill'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useAlerts } from '../service/AlertsProvider'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { useAuth } from './useAuth.jsx'
const SSE_STATES = {
CONNECTING: 0,
OPEN: 1,
CLOSED: 2,
}
const RECONNECT_INTERVALS = [10000, 30000, 360000, 600000, 900000, 6000000] // 10s, 30s, 6m, 10m, 15m , 1h
const MAX_RECONNECT_ATTEMPTS = 10 // Circuit breaker limit
const CIRCUIT_BREAKER_RESET_TIME = 600000 // 10 minutes
export const useSSE = () => {
const { data: userProfile } = useUserProfile()
const { isAuthenticated, token } = useAuth()
const [connectionState, setConnectionState] = useState(SSE_STATES.CLOSED)
const [lastEvent, setLastEvent] = useState(null)
const [error, setError] = useState(null)
const [isCircuitBreakerOpen, setIsCircuitBreakerOpen] = useState(false)
const eventSourceRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const reconnectAttemptsRef = useRef(0)
const isManuallyClosedRef = useRef(false)
const lastHeartbeatRef = useRef(Date.now())
const heartbeatMonitorRef = useRef(null)
const nextReconnectTimeRef = useRef(null)
// Track if reconnect is already scheduled to prevent duplicates
const isReconnectScheduledRef = useRef(false)
const queryClient = useQueryClient()
const { showError, showNotification } = useNotification()
const { showAlert } = useAlerts()
const getSSEUrl = useCallback(() => {
const authToken = token
if (!authToken || !isAuthenticated) {
console.log(
'SSE: No valid authentication token',
authToken,
isAuthenticated,
)
return null
}
// Get the API URL from apiManager
const apiUrl = apiClient.getApiURL() // e.g., "http://localhost:8080/api/v1"
// Build SSE URL - let backend determine circle from authenticated user
const sseUrl = `${apiUrl}/realtime/sse`
return { url: sseUrl, token }
}, [token, isAuthenticated]) // Fixed: Added missing dependencies
const handleSSEMessage = useCallback(
event => {
try {
const eventData = JSON.parse(event.data)
setLastEvent(eventData)
// Update heartbeat timestamp
if (eventData.type === 'heartbeat') {
lastHeartbeatRef.current = Date.now()
}
console.debug('SSE Message received:', eventData)
// Handle different event types and update React Query cache accordingly
switch (eventData.type) {
case 'chore.created':
showNotification({
type: 'info',
title: 'New Task Created',
message: `${eventData.data.user.displayName} created "${eventData.data.chore.name}"`,
duration: 5000,
})
const newChore = eventData.data.chore
// Update individual chore cache
queryClient.setQueryData(['chore', newChore.id], {
res: newChore,
})
// Update chores list cache
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData || !oldData.res) {
return { res: [newChore] }
}
return { res: [newChore, ...oldData.res] }
})
break
case 'chore.updated':
case 'chore.completed':
case 'chore.status':
case 'chore.skipped': {
console.log('userProfile: ', userProfile, eventData.data.user)
if (eventData?.data?.user?.id !== userProfile?.id) {
showNotification({
type: 'info',
title: `Task ${eventData.type.replace('chore.', '')}`,
message: `${eventData.data.user.displayName} ${eventData.type.replace('chore.', '')} "${eventData.data.chore.name}"`,
duration: 5000,
})
}
const updatedChore = eventData.data.chore
// Update individual chore cache
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
if (!oldData) return { res: updatedChore }
return { res: { ...oldData.res, ...updatedChore } }
})
// If chore update then also refetch chore details:
if (
eventData.type === 'chore.updated' ||
eventData.type === 'chore.status'
) {
queryClient.invalidateQueries(['choreDetails', updatedChore.id])
queryClient.refetchQueries({
queryKey: ['choreDetails', updatedChore.id],
})
}
// Update chores list cache - add debugging
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [updatedChore] }
if (!oldData.res || !Array.isArray(oldData.res)) {
return { res: [updatedChore] }
}
// If it's a one-time chore that's completed, we might need to remove it
if (
eventData.type === 'chore.completed' &&
updatedChore.frequencyType === 'once'
) {
return {
res: oldData.res.filter(
chore => chore.id !== updatedChore.id,
),
}
}
// Otherwise update the existing chore or add if it doesn't exist
const newData = oldData.res.map(chore => {
if (chore.id === updatedChore.id) {
return { ...updatedChore }
}
return chore
})
return { res: newData }
})
break
}
case 'chore.deleted':
// update chores list cache
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData || !oldData.res) return oldData
return {
res: oldData.res.filter(
chore => chore.id !== eventData.data.choreId,
),
}
})
// same logic for archived chores view:
queryClient.setQueryData(['chores', true], oldData => {
if (!oldData || !oldData.res) return oldData
return {
res: oldData.res.filter(
chore => chore.id !== eventData.data.choreId,
),
}
})
break
case 'subtask.updated':
case 'subtask.completed':
queryClient.setQueryData(
['choreDetails', String(eventData.data.choreId)], // this should be string to match the query key type which is param in the url in choreView
oldData => {
if (!oldData) return oldData
console.log('Old choreDetails data:', oldData)
// Update the specific subtask within the chore details
const newChoreData = { ...oldData.res }
newChoreData.subTasks = newChoreData.subTasks.map(subtask => {
if (subtask.id === eventData.data.subtaskId) {
return {
...subtask,
completedAt: eventData.data.completedAt,
completedBy: eventData.data.user.id,
}
}
return subtask
})
return { res: newChoreData }
},
)
break
case 'heartbeat':
// Heartbeat events don't need cache invalidation
console.debug('SSE Heartbeat received at', new Date().toISOString())
break
case 'connection.established':
console.log('SSE connection established')
setError(null)
lastHeartbeatRef.current = Date.now()
showAlert({
type: 'success',
color: 'success',
message: 'You are now receiving real-time as they happen.',
})
break
case 'error':
console.error('SSE error event:', eventData.data)
showError({
title: 'Real-time Error',
message:
eventData.data.message ||
'An error occurred with real-time updates',
})
break
default:
console.log('Unknown SSE event type:', eventData.type)
}
} catch (err) {
console.error('Failed to parse SSE message:', err)
showError({
title: 'Message Error',
message: 'Failed to parse server message',
})
return // Stop processing if JSON parsing fails
}
},
[queryClient, showNotification, showError, userProfile, showAlert],
)
const stopHeartbeatMonitor = useCallback(() => {
if (heartbeatMonitorRef.current) {
clearInterval(heartbeatMonitorRef.current)
heartbeatMonitorRef.current = null
}
}, [])
// Centralized reconnect scheduling function to prevent duplicate scheduling
const scheduleReconnect = useCallback((delay, reason) => {
// Prevent duplicate scheduling
if (isReconnectScheduledRef.current) {
console.log('SSE: Reconnect already scheduled, skipping duplicate')
return
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
console.log(
`SSE: Scheduling reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1}, reason: ${reason})`,
)
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
// Note: connect will be called by the caller after this returns
// We need to trigger it here
window.dispatchEvent(new CustomEvent('sse-reconnect'))
}, delay)
}, [])
// Create connect function that can be called from anywhere
const connect = useCallback(() => {
// Clear the scheduled flag when actually connecting
isReconnectScheduledRef.current = false
if (isCircuitBreakerOpen) {
console.log('SSE: Circuit breaker is open, preventing connection attempt')
showError({
title: 'Connection Temporarily Disabled',
message:
'Connection blocked due to repeated failures. Please try again later.',
})
return
}
if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) {
console.error(
'SSE: Maximum reconnection attempts reached, opening circuit breaker',
)
setIsCircuitBreakerOpen(true)
showError({
title: 'Connection Failed',
message:
'Maximum connection attempts reached. SSE disabled for 10 minutes.',
})
// Reset circuit breaker after timeout
setTimeout(() => {
console.log('SSE: Resetting circuit breaker')
setIsCircuitBreakerOpen(false)
reconnectAttemptsRef.current = 0
}, CIRCUIT_BREAKER_RESET_TIME)
return
}
// Prevent race conditions by checking if already connecting or connected
if (eventSourceRef.current?.readyState === SSE_STATES.OPEN) {
console.log('SSE: Already connected')
return // Already connected
}
if (eventSourceRef.current?.readyState === SSE_STATES.CONNECTING) {
console.log('SSE: Connection already in progress')
return // Already connecting
}
const sseConfig = getSSEUrl()
console.log('SSE connect - Config:', sseConfig)
if (!sseConfig) {
console.log('Cannot connect to SSE: missing URL, token, or user profile')
return
}
// Create connection logic inline to avoid circular dependency
try {
console.log('Connecting to SSE:', sseConfig.url)
setConnectionState(SSE_STATES.CONNECTING)
isManuallyClosedRef.current = false
eventSourceRef.current = new EventSourcePolyfill(sseConfig.url, {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
'Cache-Control': 'no-cache',
Accept: 'text/event-stream',
},
withCredentials: true,
heartbeatTimeout: 120000,
silentTimeoutRetry: true,
})
eventSourceRef.current.onopen = () => {
console.log('SSE connection opened')
setConnectionState(SSE_STATES.OPEN)
setError(null)
reconnectAttemptsRef.current = 0
nextReconnectTimeRef.current = null
isReconnectScheduledRef.current = false
lastHeartbeatRef.current = Date.now()
// Start heartbeat monitor
stopHeartbeatMonitor()
heartbeatMonitorRef.current = setInterval(() => {
const timeSinceLastHeartbeat = Date.now() - lastHeartbeatRef.current
const heartbeatTimeout = 150000 // 2.5 minutes
console.debug(
`SSE: Heartbeat check - ${Math.round(timeSinceLastHeartbeat / 1000)}s since last heartbeat`,
)
if (timeSinceLastHeartbeat > heartbeatTimeout) {
console.warn(
`SSE: No heartbeat received for ${Math.round(timeSinceLastHeartbeat / 1000)}s, connection may be stale. Reconnecting...`,
)
if (!isManuallyClosedRef.current) {
// Clear current heartbeat monitor before reconnecting
stopHeartbeatMonitor()
// Close current connection gracefully
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState(SSE_STATES.CLOSED)
// Calculate delay based on current attempt
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
// Schedule reconnect
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
console.log(
`SSE: Scheduling heartbeat-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
)
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
connect()
}, delay)
}
}
}, 60000) // Check every minute
}
eventSourceRef.current.onmessage = handleSSEMessage
eventSourceRef.current.onerror = async error => {
console.error('SSE error:', error)
setConnectionState(SSE_STATES.CLOSED)
stopHeartbeatMonitor()
// Close the EventSource to prevent it from retrying on its own
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
if (isManuallyClosedRef.current) {
console.log('SSE: Manually closed, not reconnecting')
return
}
// Check if reconnect is already scheduled
if (isReconnectScheduledRef.current) {
console.log('SSE: Reconnect already scheduled, skipping')
return
}
// Check if this is a 401 unauthorized error
const is401Error =
error.status === 401 ||
error.error?.message?.includes('401') ||
error.error?.message?.includes('Unauthorized')
// Check if this is a timeout error specifically
const isTimeoutError =
error.error?.message?.includes('No activity within') ||
error.error?.message?.includes('timeout')
if (is401Error) {
console.log('SSE 401 error detected, attempting token refresh...')
setError('Authentication expired - refreshing token...')
try {
const refreshResult = await apiClient.refreshToken()
if (refreshResult.success) {
console.log(
'Token refreshed successfully, retrying SSE connection...',
)
setError('Token refreshed - reconnecting...')
if (apiClient.failedQueue && apiClient.failedQueue.length > 0) {
console.log(
`Processing ${apiClient.failedQueue.length} queued requests after SSE token refresh`,
)
apiClient.processQueue(null, refreshResult.token)
}
// Reset reconnect attempts since we have a fresh token
reconnectAttemptsRef.current = 0
// Schedule immediate reconnect with fresh token
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + 1000
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
nextReconnectTimeRef.current = null
connect()
}, 1000)
return
} else if (
refreshResult.error === 'Already refreshing' ||
refreshResult.error === 'Refresh cooldown active'
) {
console.log(
'SSE: Token refresh in progress by another request, waiting...',
)
setError('Token refresh in progress - reconnecting soon...')
reconnectAttemptsRef.current = 0
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + 1500
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
nextReconnectTimeRef.current = null
connect()
}, 1500)
return
} else if (refreshResult.error === 'Refresh token expired') {
console.error('Refresh token expired, user must login again')
setError('Session expired - please log in again')
return
} else {
console.error('Token refresh failed:', refreshResult.error)
setError('Authentication failed - please log in again')
return
}
} catch (refreshError) {
console.error('Token refresh error:', refreshError)
setError('Authentication error - please log in again')
return
}
} else if (isTimeoutError) {
console.log('SSE timeout detected, attempting reconnection...')
setError('Connection timeout - reconnecting...')
} else {
setError('Connection error occurred')
}
// Schedule reconnect for non-401 errors
const attemptIndex = Math.min(
reconnectAttemptsRef.current,
RECONNECT_INTERVALS.length - 1,
)
const delay = RECONNECT_INTERVALS[attemptIndex]
console.log(
`SSE: Scheduling error-triggered reconnect in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
)
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
isReconnectScheduledRef.current = true
nextReconnectTimeRef.current = Date.now() + delay
reconnectTimeoutRef.current = setTimeout(() => {
isReconnectScheduledRef.current = false
reconnectAttemptsRef.current++
nextReconnectTimeRef.current = null
connect()
}, delay)
}
} catch (err) {
console.error('Failed to create SSE connection:', err)
showError({
title: 'Connection Error',
message: 'Failed to establish real-time connection. Please try again.',
})
setConnectionState(SSE_STATES.CLOSED)
}
}, [
getSSEUrl,
handleSSEMessage,
stopHeartbeatMonitor,
isCircuitBreakerOpen,
showError,
])
const disconnect = useCallback(() => {
isManuallyClosedRef.current = true
isReconnectScheduledRef.current = false
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
reconnectTimeoutRef.current = null
}
nextReconnectTimeRef.current = null
stopHeartbeatMonitor()
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setConnectionState(SSE_STATES.CLOSED)
}, [stopHeartbeatMonitor])
const toggleSSEEnabled = useCallback(
enabled => {
console.log('SSE toggleSSEEnabled called:', {
enabled,
isTokenValid: isAuthenticated,
})
localStorage.setItem('sse_enabled', enabled.toString())
if (enabled && isAuthenticated) {
console.log('SSE toggleSSEEnabled: Calling connect()')
connect()
} else {
console.log('SSE toggleSSEEnabled: Calling disconnect()')
disconnect()
}
},
[connect, disconnect, isAuthenticated],
)
const isSSEEnabled = useCallback(() => {
return localStorage.getItem('sse_enabled') === 'true'
}, [])
// Auto-connect when SSE is enabled and token is valid
useEffect(() => {
console.log('SSE auto-connect effect triggered')
console.log('Token valid:', isAuthenticated)
const isSSEEnabledSetting = localStorage.getItem('sse_enabled') === 'true'
console.log('SSE enabled in settings:', isSSEEnabledSetting)
if (isAuthenticated && isSSEEnabledSetting) {
console.log('SSE: Conditions met, attempting to connect')
connect()
} else {
console.log('SSE: Conditions not met, disconnecting')
disconnect()
}
return () => {
disconnect()
}
}, [isAuthenticated]) // Fixed: Added isAuthenticated dependency
// Cleanup timeouts on unmount
useEffect(() => {
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
stopHeartbeatMonitor()
}
}, [stopHeartbeatMonitor])
// Update EventSource message handler when handleSSEMessage changes
useEffect(() => {
if (
eventSourceRef.current &&
eventSourceRef.current.readyState === SSE_STATES.OPEN
) {
console.log('SSE: Updating message handler with latest userProfile')
eventSourceRef.current.onmessage = handleSSEMessage
}
}, [handleSSEMessage])
// Handle visibility changes for better performance
useEffect(() => {
const handleVisibilityChange = () => {
if (document.hidden) {
console.log(
'SSE: App backgrounded, maintaining connection but reducing activity',
)
} else {
console.log('SSE: App foregrounded, ensuring connection is active')
const isSSEEnabledSetting =
localStorage.getItem('sse_enabled') === 'true'
// Check actual EventSource state, not React state
const isCurrentlyConnected =
eventSourceRef.current?.readyState === SSE_STATES.OPEN
const isCurrentlyConnecting =
eventSourceRef.current?.readyState === SSE_STATES.CONNECTING
if (
isAuthenticated &&
isSSEEnabledSetting &&
!isCurrentlyConnected &&
!isCurrentlyConnecting &&
!isReconnectScheduledRef.current
) {
console.log('SSE: Reconnecting after visibility change')
connect()
}
}
}
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange)
}
}, [connect, isAuthenticated])
return {
connectionState,
isConnected: connectionState === SSE_STATES.OPEN,
isConnecting: connectionState === SSE_STATES.CONNECTING,
lastEvent,
error,
connect,
disconnect,
toggleSSEEnabled,
isSSEEnabled,
getConnectionStatus: () => {
switch (connectionState) {
case SSE_STATES.CONNECTING:
return 'connecting'
case SSE_STATES.OPEN:
return 'connected'
case SSE_STATES.CLOSED:
default:
return 'disconnected'
}
},
getDebugInfo: () => ({
connectionState,
reconnectAttempts: reconnectAttemptsRef.current,
isCircuitBreakerOpen,
isReconnectScheduled: isReconnectScheduledRef.current,
lastHeartbeat: lastHeartbeatRef.current,
timeSinceLastHeartbeat: Date.now() - lastHeartbeatRef.current,
isManuallyCloseRef: isManuallyClosedRef.current,
nextReconnectTime: nextReconnectTimeRef.current,
timeUntilReconnect: nextReconnectTimeRef.current
? nextReconnectTimeRef.current - Date.now()
: null,
reconnectIntervals: RECONNECT_INTERVALS,
currentReconnectDelay:
reconnectAttemptsRef.current < RECONNECT_INTERVALS.length
? RECONNECT_INTERVALS[reconnectAttemptsRef.current]
: RECONNECT_INTERVALS[RECONNECT_INTERVALS.length - 1],
maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS,
circuitBreakerResetTime: CIRCUIT_BREAKER_RESET_TIME,
heartbeatTimeout: 120000,
heartbeatMonitorInterval: 60000,
heartbeatMonitorTimeout: 150000,
}),
}
}

View File

@@ -0,0 +1,6 @@
import { useContext } from 'react'
import { SSEContext } from '../contexts/SSEContext'
export const useSSEContext = () => {
return useContext(SSEContext)
}

115
src/hooks/useTimer.js Normal file
View File

@@ -0,0 +1,115 @@
import { useCallback, useEffect, useRef, useState } from 'react'
/**
* Custom hook for timer functionality with high-resolution timing
* Fixes timing drift issues by using timestamps instead of interval counting
*/
const useTimer = (onTimeUpdate = () => {}) => {
const [timerState, setTimerState] = useState('stopped') // 'stopped' | 'running' | 'paused'
const [time, setTime] = useState(0) // Current time in seconds
// Refs for timing calculations
const startTimeRef = useRef(null)
const pausedTimeRef = useRef(0)
const intervalRef = useRef(null)
const lastNotifiedTimeRef = useRef(0)
// Update display and notify parent
const updateTime = useCallback(() => {
if (timerState === 'running' && startTimeRef.current) {
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
const newTime = pausedTimeRef.current + elapsed
setTime(newTime)
// Only call onTimeUpdate when the second changes to avoid excessive calls
if (newTime !== lastNotifiedTimeRef.current) {
lastNotifiedTimeRef.current = newTime
onTimeUpdate(newTime)
}
}
}, [timerState, onTimeUpdate])
// Timer effect with high-frequency updates for smooth display
useEffect(() => {
if (timerState === 'running') {
intervalRef.current = setInterval(updateTime, 200)
} else {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [timerState, updateTime])
// Timer control functions
const startTimer = useCallback(() => {
const now = Date.now()
startTimeRef.current = now
pausedTimeRef.current = 0
lastNotifiedTimeRef.current = 0
setTime(0)
setTimerState('running')
onTimeUpdate(0)
}, [onTimeUpdate])
const pauseTimer = useCallback(() => {
if (timerState === 'running' && startTimeRef.current) {
// Calculate and store the elapsed time
const elapsed = Math.floor((Date.now() - startTimeRef.current) / 1000)
pausedTimeRef.current = pausedTimeRef.current + elapsed
setTimerState('paused')
}
}, [timerState])
const resumeTimer = useCallback(() => {
if (timerState === 'paused') {
// Reset start time for resumed session
startTimeRef.current = Date.now()
setTimerState('running')
}
}, [timerState])
const stopTimer = useCallback(() => {
setTimerState('stopped')
setTime(0)
pausedTimeRef.current = 0
startTimeRef.current = null
lastNotifiedTimeRef.current = 0
onTimeUpdate(0)
}, [onTimeUpdate])
const resetTimer = useCallback(() => {
stopTimer()
}, [stopTimer])
// Computed properties
const isRunning = timerState === 'running'
const isPaused = timerState === 'paused'
const isStopped = timerState === 'stopped'
return {
// State
time,
timerState,
isRunning,
isPaused,
isStopped,
// Actions
startTimer,
pauseTimer,
resumeTimer,
stopTimer,
resetTimer,
}
}
export default useTimer

View File

@@ -1,3 +1,51 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Global animations for smooth interactions */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Smooth scrolling for better UX */
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
/* Global focus styles for better accessibility */
*:focus-visible {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
border-radius: 4px;
}
/* Improve button and interactive element performance */
button, a, [role="button"] {
transform: translateZ(0);
backface-visibility: hidden;
}
/* Ensure smooth transitions for dynamic content */
* {
box-sizing: border-box;
}
/* Optimize animations for performance */
.animate-optimized {
will-change: transform, opacity;
}
.animate-optimized.animation-complete {
will-change: auto;
}

View File

@@ -1,10 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import Contexts from './contexts/Contexts.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<Contexts />
<Contexts>
<App />
</Contexts>
</React.StrictMode>,
)

View File

@@ -1,22 +1,38 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { networkManager } from '../hooks/NetworkManager'
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
import {
ApproveChore,
ArchiveChore,
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
GetChoresHistory,
GetChoresNew,
MarkChoreComplete,
RejectChore,
SaveChore,
SkipChore,
UnArchiveChore,
UpdateChoreHistory,
} from '../utils/Fetcher'
import { localStore } from '../utils/LocalStore'
export const useChores = includeArchive => {
return useQuery({
queryKey: ['chores'],
queryKey: ['chores', includeArchive],
queryFn: async () => {
const onlineChores = await GetChoresNew(includeArchive)
// Only handle offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChores
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
var finalChores = []
@@ -51,41 +67,106 @@ export const useChores = includeArchive => {
},
})
}
export const useDeleteChores = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async choreIds => {
// If offline mode is enabled and we're offline, handle deletion locally
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updatedOfflineTasks = offlineTasks.filter(
task =>
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
)
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
// Force the chores query to refetch
queryClient.invalidateQueries(['chores'])
return
}
// If online, proceed with server-side deletion
await Promise.all(
choreIds.map(async id => {
const resp = await DeleteChore(id)
if (!resp || !resp.ok) {
throw new Error(`Failed to delete chore with ID: ${id}`)
}
}),
)
},
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useCreateChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: CreateChore,
onMutate: async newTask => {
if (!networkManager.isOnline) {
const tempId = crypto.randomUUID() // Generate temp ID
const offlineTasks =
(await localStore.getFromCache('offlineTasks')) || []
const updateOfflineTasks = [
...offlineTasks,
{ ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
]
await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// force useChores to refetch:
queryClient.invalidateQueries(['chores'])
// Force the chores query to refetch
queryClient.refetchQueries(['chores'])
// Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
return { tempId }
mutationFn: async newTask => {
const resp = await CreateChore(newTask)
if (!resp || !resp.ok) {
throw new Error('Failed to create chore')
}
return { tempId: null }
const createdChore = await resp.json()
if (!createdChore) {
throw new Error('Failed to get created chore data')
}
// Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return { res: createdChore }
},
// onMutate: async newTask => {
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// const tempId = crypto.randomUUID() // Generate temp ID
// const offlineTasks =
// (await localStore.getFromCache('offlineTasks')) || []
// const updateOfflineTasks = [
// ...offlineTasks,
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
// ]
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
// // force useChores to refetch:
// queryClient.invalidateQueries(['chores'])
// // Force the chores query to refetch
// queryClient.refetchQueries(['chores'])
// // Update the chores query cache immediately
// // queryClient.setQueryData(['chores'], oldData => {
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
// // if (!oldData)
// // return {
// // res: [{ ...newTask, id: tempId, tempId }],
// // } // If no data, return offline tasks
// // return {
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// // }
// // })
// return { tempId }
// }
// const tempId = crypto.randomUUID() // Generate temp ID
// // Update the chores query cache immediately
// queryClient.setQueryData(['chores'], oldData => {
// if (!oldData)
// return {
// res: [{ ...newTask, id: tempId, tempId }],
// } // If no data, return offline tasks
// return {
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
// }
// })
// return { tempId: null }
// },
onSuccess: () => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
},
})
}
@@ -95,7 +176,7 @@ export const useUpdateChore = () => {
return useMutation({
mutationFn: async updatedChore => {
if (!networkManager.isOnline) {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
updatedChore['updatedAt'] = new Date().toISOString()
if (!updatedChore['nextDueDate']) {
updatedChore['nextDueDate'] = updatedChore['dueDate']
@@ -142,15 +223,26 @@ export const useUpdateChore = () => {
throw new Error('Failed to get updated chore data')
}
// Successfully updated the chore on the server, return the updated chore
// update the local chores cache with the updated chore:
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return { res: [updatedChore] }
return {
res: oldData.res.map(chore =>
chore.id === updatedChore.id ? updatedChore : chore,
),
}
})
return updatedChoreRes?.res || updatedChoreRes
}
},
onSuccess: (data, variables) => {
// Invalidate the chores query to refresh the data
queryClient.invalidateQueries(['chores'])
// Invalidate history for the specific chore
queryClient.invalidateQueries(['choreHistory', variables.id])
},
onMutate: async updatedChore => {
if (!networkManager.isOnline) {
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
// Handle offline case here if needed
return
}
@@ -167,6 +259,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
const resp = await GetChoresHistory(limit, includeMembers)
return resp?.res || []
},
staleTime: 0,
})
const handleLimitChange = newLimit => {
@@ -178,7 +271,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
export const useChoreDetails = choreId => {
return useQuery({
queryKey: ['chore', choreId],
queryKey: ['choreDetails', choreId],
queryFn: async () => {
var onlineChore = null
@@ -192,6 +285,11 @@ export const useChoreDetails = choreId => {
console.error('Error fetching chore detail:', error)
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
// Match by tempId or id if it was created offline
@@ -224,6 +322,11 @@ export const useChore = choreId => {
console.error('Error fetching chore detail:', error)
}
// Only check offline tasks if experimental offline mode is enabled
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
return onlineChore
}
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
const offline = offlineTasks.find(task => {
return (
@@ -239,3 +342,123 @@ export const useChore = choreId => {
},
})
}
export const useArchiveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ArchiveChore,
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useUnArchiveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: UnArchiveChore,
onSuccess: () => {
queryClient.invalidateQueries(['chores'])
},
})
}
export const useChoreHistory = choreId => {
return useQuery({
queryKey: ['choreHistory', choreId],
queryFn: async () => {
if (!choreId) {
throw new Error('Chore ID is required to fetch history')
}
const response = await GetChoreHistory(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch chore history')
},
enabled: !!choreId,
staleTime: 0, // Always consider data stale
cacheTime: 0, // Don't cache the data
refetchOnMount: true, // Always refetch when component mounts
refetchOnWindowFocus: true, // Refetch when window gains focus
})
}
export const useUpdateChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId, historyData }) =>
UpdateChoreHistory(choreId, historyId, historyData),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useDeleteChoreHistory = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, historyId }) =>
DeleteChoreHistory(choreId, historyId),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useMarkChoreComplete = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, body, completedDate, performer }) =>
MarkChoreComplete(choreId, body, completedDate, performer),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useSkipChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: SkipChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useApproveChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ApproveChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}
export const useRejectChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: RejectChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
queryClient.invalidateQueries(['choreDetails', choreId])
},
})
}

View File

@@ -3,7 +3,7 @@ import { GetResource } from '../utils/Fetcher'
export const useResource = () => {
const { data, isLoading, error } = useQuery({
queryKey: [],
queryKey: ['resource'],
queryFn: async () => {
const response = await GetResource()
return response

View File

@@ -0,0 +1,29 @@
import { useInfiniteQuery } from '@tanstack/react-query'
import { GetThingHistory } from '../utils/Fetcher'
export const useThingHistory = (thingId, limit = 10) => {
return useInfiniteQuery({
queryKey: ['thingHistory', thingId],
queryFn: async ({ pageParam = 0 }) => {
const response = await GetThingHistory(thingId, pageParam)
if (!response.ok) {
throw new Error('Failed to fetch thing history')
}
const data = await response.json()
return data
},
getNextPageParam: (lastPage, allPages) => {
// If the last page has fewer items than the limit, there are no more pages
if (lastPage.res.length < limit) {
return undefined
}
// Calculate the offset for the next page
const totalItems = allPages.reduce(
(acc, page) => acc + page.res.length,
0,
)
return totalItems
},
enabled: !!thingId, // Only run query if thingId exists
})
}

107
src/queries/TimeQueries.jsx Normal file
View File

@@ -0,0 +1,107 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
ClearChoreTimer,
DeleteTimeSession,
GetChoreTimer,
PauseChore,
ResetChoreTimer,
StartChore,
UpdateTimeSession,
} from '../utils/Fetcher'
export const useChoreTimer = choreId => {
return useQuery({
queryKey: ['choreTimer', choreId],
queryFn: async () => {
if (!choreId) {
throw new Error('Chore ID is required to fetch timer')
}
const response = await GetChoreTimer(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch chore timer')
},
enabled: !!choreId,
})
}
export const useStartChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: StartChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const usePauseChore = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: PauseChore,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useUpdateTimeSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, sessionId, sessionData }) =>
UpdateTimeSession(choreId, sessionId, sessionData),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useDeleteTimeSession = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ choreId, sessionId }) =>
DeleteTimeSession(choreId, sessionId),
onSuccess: (data, { choreId }) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useResetChoreTimer = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ResetChoreTimer,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}
export const useClearChoreTimer = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ClearChoreTimer,
onSuccess: (data, choreId) => {
queryClient.invalidateQueries(['choreTimer', choreId])
queryClient.invalidateQueries(['chores'])
queryClient.invalidateQueries(['choreHistory', choreId])
},
})
}

View File

@@ -1,5 +1,22 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher'
import {
GetAllCircleMembers,
GetAllUsers,
GetChildUsers,
GetDeviceTokens,
GetUserProfile,
} from '../utils/Fetcher'
// Helper to check if we have a valid token
const isTokenValid = () => {
const token = localStorage.getItem('token')
if (!token) return false
const expiry = localStorage.getItem('token_expiry')
if (!expiry) return true // No expiry set, assume valid
return new Date() < new Date(expiry)
}
export const useAllUsers = () => {
return useQuery({
@@ -22,3 +39,76 @@ export const useCircleMembers = () => {
return { data, error, isLoading, handleRefetch }
}
export const useUserProfile = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['userProfile'],
queryFn: async () => {
const resp = await GetUserProfile()
const result = await resp.json()
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
return result.res || null
},
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['userProfile']),
}
}
export const useDeviceTokens = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['deviceTokens'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetDeviceTokens(true) // Only get active devices
const result = await resp.json()
return result.res || []
},
staleTime: 0, // Always fetch fresh data
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['deviceTokens']),
}
}
export const useChildUsers = () => {
const queryClient = useQueryClient()
const { data, error, isLoading } = useQuery({
queryKey: ['childUsers'],
queryFn: async () => {
if (!isTokenValid()) {
return null
}
const resp = await GetChildUsers()
const result = await resp.json()
return result.res || []
},
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
})
return {
data,
error,
isLoading,
refetch: () => queryClient.invalidateQueries(['childUsers']),
}
}

View File

@@ -0,0 +1,87 @@
import { Alert, Box } from '@mui/joy'
import PropTypes from 'prop-types'
import { createContext, useCallback, useContext, useState } from 'react'
import Z_INDEX from '../constants/zIndex'
const FADE_DURATION = 400 // ms
const ALERT_DURATION = 5000 // ms
const AlertsContext = createContext()
// Helper function to create a delay
const delay = ms => new Promise(res => setTimeout(res, ms))
export const AlertsProvider = ({ children }) => {
const [show, setShow] = useState(false)
const [visibleAlert, setVisibleAlert] = useState(null)
const showAlert = useCallback(async alertObj => {
setVisibleAlert(alertObj)
setShow(false)
await delay(10)
setShow(true)
await delay(ALERT_DURATION)
setShow(false)
await delay(FADE_DURATION)
setVisibleAlert(null)
}, [])
const hideAlert = useCallback(() => {
setShow(false)
// Wait for the fade out transition to complete before unmounting
setTimeout(() => {
setVisibleAlert(null)
}, FADE_DURATION)
}, [])
return (
<AlertsContext.Provider value={{ showAlert, hideAlert }}>
{children}
{visibleAlert && (
<Box
sx={{
position: 'fixed',
top: 0,
left: 0,
width: '100%',
zIndex: Z_INDEX.ALERTS,
overflow: 'hidden',
}}
>
<Alert
variant='soft'
color={visibleAlert.color || 'primary'}
startDecorator={visibleAlert.icon}
onClick={hideAlert}
sx={{
transition: `transform ${FADE_DURATION}ms ease-in-out, opacity ${FADE_DURATION}ms ease-in-out`,
transform: show ? 'translateY(0)' : 'translateY(-100%)',
opacity: show ? 1 : 0,
pointerEvents: show ? 'auto' : 'none',
width: '100%',
justifyContent: 'center',
alignItems: 'center',
padding: '4px',
paddingTop: `calc(var(--safe-area-inset-top, 0px))`,
fontSize: '10px',
fontWeight: 'md',
}}
>
{visibleAlert.message}
</Alert>
</Box>
)}
</AlertsContext.Provider>
)
}
AlertsProvider.propTypes = {
children: PropTypes.node.isRequired,
}
export const useAlerts = () => useContext(AlertsContext)

View File

@@ -1,18 +0,0 @@
import React, { createContext, useState } from 'react'
const AuthenticationContext = createContext({})
const AuthenticationProvider = ({ children }) => {
const [isLoggedIn, setIsLoggedIn] = useState(false)
const [userProfile, setUserProfile] = useState({})
return (
<AuthenticationContext.Provider
value={{ isLoggedIn, setIsLoggedIn, userProfile, setUserProfile }}
>
{children}
</AuthenticationContext.Provider>
)
}
export { AuthenticationContext, AuthenticationProvider }
// export default AuthenticationProvider;

View File

@@ -1,51 +0,0 @@
import { Error } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react'
const ErrorContext = createContext()
export const useError = () => useContext(ErrorContext)
export const ErrorProvider = ({ children }) => {
const [error, setError] = useState(null)
const showError = error => {
setError(error)
}
return (
<ErrorContext.Provider value={{ showError }}>
{children}
<Snackbar
open={Boolean(error)}
autoHideDuration={6000}
onClose={() => setError(null)}
startDecorator={<Error color='danger' />}
endDecorator={
<Button
variant='outlined'
color='danger'
onClick={() => setError(null)}
>
Dismiss
</Button>
}
>
{typeof error === 'string' ? (
<Typography color='danger' level='body-md'>
{error}
</Typography>
) : (
<Box>
<Typography color='danger' level='title-sm'>
{error?.title}
</Typography>
<Typography color='danger' level='body-sm'>
{error?.message}
</Typography>
</Box>
)}
</Snackbar>
</ErrorContext.Provider>
)
}

View File

@@ -0,0 +1,269 @@
import { CheckCircle, Error, Info, Undo, Warning } from '@mui/icons-material'
import { Box, Button, Snackbar, Typography } from '@mui/joy'
import React, { createContext, useContext, useState } from 'react'
const NotificationContext = createContext()
export const useNotification = () => useContext(NotificationContext)
// For backward compatibility
export const useError = () => {
const { showError } = useNotification()
return { showError }
}
// Notification types configuration with default titles
const NOTIFICATION_TYPES = {
error: {
color: 'danger',
icon: <Error color='danger' />,
autoHideDuration: 6000,
showDismissButton: true,
defaultTitle: 'Error',
},
success: {
color: 'success',
icon: <CheckCircle color='success' />,
autoHideDuration: 5000,
showDismissButton: false,
defaultTitle: 'Success',
},
undo: {
color: 'success',
icon: <Undo color='success' />,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Undone Successfully',
},
warning: {
color: 'warning',
icon: <Warning color='warning' />,
autoHideDuration: 4000,
showDismissButton: false,
defaultTitle: 'Warning',
},
info: {
color: 'primary',
icon: <Info color='primary' />,
autoHideDuration: 4000,
showDismissButton: false,
defaultTitle: 'Information',
},
custom: {
color: 'neutral',
icon: null,
autoHideDuration: null,
showDismissButton: false,
defaultTitle: 'Notification',
},
}
export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([])
const addNotification = notification => {
const id = Date.now() + Math.random()
const newNotification = {
id,
...notification,
timestamp: Date.now(),
}
setNotifications(prev => [...prev, newNotification])
// Auto-remove notification if it has a duration
const config =
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
if (config.autoHideDuration) {
setTimeout(() => {
removeNotification(id)
}, config.autoHideDuration)
}
return id
}
const removeNotification = id => {
setNotifications(prev => prev.filter(n => n.id !== id))
}
const clearAllNotifications = () => {
setNotifications([])
}
// Helper function to normalize notification input
const normalizeNotification = (input, type) => {
if (typeof input === 'string') {
return {
type,
message: input,
}
}
if (typeof input === 'object' && input !== null) {
// If it's already a properly structured notification
if (input.title || input.message) {
return {
type,
...input,
}
}
// If it's a simple object with just message content
return {
type,
message: input.message || input.toString(),
title: input.title,
...input,
}
}
return {
type,
message: input?.toString() || 'Unknown notification',
}
}
// Unified notification method
const showNotification = notification => {
// Handle different input formats
if (typeof notification === 'string') {
return addNotification(normalizeNotification(notification, 'info'))
}
return addNotification(
normalizeNotification(notification, notification.type || 'info'),
)
}
// Specific notification methods with enhanced language
const showError = error => {
return addNotification(normalizeNotification(error, 'error'))
}
const showUndo = message => {
return addNotification(normalizeNotification(message, 'undo'))
}
const showSuccess = message => {
return addNotification(normalizeNotification(message, 'success'))
}
const showWarning = message => {
return addNotification(normalizeNotification(message, 'warning'))
}
const showInfo = message => {
return addNotification(normalizeNotification(message, 'info'))
}
const renderNotification = notification => {
const config =
NOTIFICATION_TYPES[notification.type] || NOTIFICATION_TYPES.info
// Handle custom notifications with components
if (notification.type === 'custom' && notification.component) {
return (
<Snackbar
key={notification.id}
open={true}
onClose={() => removeNotification(notification.id)}
anchorOrigin={
notification.anchorOrigin || {
vertical: 'bottom',
horizontal: 'right',
}
}
{...(notification.snackbarProps || {})}
>
{React.cloneElement(notification.component, {
onClose: () => removeNotification(notification.id),
...notification.componentProps,
})}
</Snackbar>
)
}
// Handle standard notifications
// Determine the icon to use
const notificationIcon = notification.icon || config.icon
// Determine title and message
const title = notification.title || config.defaultTitle
const message = notification.message
return (
<Snackbar
key={notification.id}
open={true}
autoHideDuration={config.autoHideDuration}
onClose={() => removeNotification(notification.id)}
startDecorator={notificationIcon}
endDecorator={
notification.undoAction ? (
<Button
variant='outlined'
color={config.color}
onClick={() => {
notification.undoAction()
removeNotification(notification.id)
}}
>
Undo
</Button>
) : config.showDismissButton ? (
<Button
variant='outlined'
color={config.color}
onClick={() => removeNotification(notification.id)}
>
Dismiss
</Button>
) : null
}
anchorOrigin={
notification.anchorOrigin || {
vertical: 'bottom',
horizontal: 'right',
}
}
{...(notification.snackbarProps || {})}
>
{/* Enhanced structure like ErrorProvider - always show title and message for consistency */}
{title && message ? (
<Box>
<Typography color={config.color} level='title-sm'>
{title}
</Typography>
<Typography color={config.color} level='body-sm'>
{message}
</Typography>
</Box>
) : (
<Typography color={config.color} level='body-md'>
{message || title || 'Notification'}
</Typography>
)}
</Snackbar>
)
}
return (
<NotificationContext.Provider
value={{
showNotification,
showError,
showSuccess,
showUndo,
showWarning,
showInfo,
removeNotification,
clearAllNotifications,
notifications,
}}
>
{children}
{notifications.map(renderNotification)}
</NotificationContext.Provider>
)
}

270
src/utils/ApiClient.js Normal file
View File

@@ -0,0 +1,270 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
isRefreshTokenExpired,
saveTokens,
} from './TokenStorage'
class ApiClient {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.isRefreshing = false
this.failedQueue = []
this.lastRefreshTime = 0
this.refreshCooldown = 3 * 1000 // 3 seconds in milliseconds
}
async init() {
if (this.initPromise) {
return this.initPromise
}
if (this.initialized) {
return Promise.resolve()
}
this.initPromise = this._doInit()
return this.initPromise
}
async _doInit() {
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
async refreshToken() {
// Check if refresh token is expired BEFORE attempting refresh
const refreshExpired = await isRefreshTokenExpired()
if (refreshExpired) {
console.log('Refresh token expired, forcing logout')
await clearAllTokens()
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
return { success: false, error: 'Refresh token expired' }
}
if (this.isRefreshing) {
return { success: false, error: 'Already refreshing' }
}
// Check cooldown
const now = Date.now()
if (now - this.lastRefreshTime < this.refreshCooldown) {
return { success: false, error: 'Refresh cooldown active' }
}
this.isRefreshing = true
try {
const refreshReq = await RefreshToken()
if (refreshReq.ok) {
const data = await refreshReq.json()
const newToken = data.token || data.access_token
// Save all tokens including rotated refresh token
await saveTokens({
accessToken: newToken,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
// Update last refresh time
this.lastRefreshTime = Date.now()
return { success: true, token: newToken }
} else {
return { success: false, error: 'Refresh failed' }
}
} catch (error) {
return { success: false, error: error.message }
} finally {
this.isRefreshing = false
}
}
getToken() {
return localStorage.getItem('token')
}
getHeaders(customHeaders = {}) {
const headers = {
'Content-Type': 'application/json',
...customHeaders,
}
const token = this.getToken()
if (token) {
headers.Authorization = `Bearer ${token}`
}
const impersonateUserId = localStorage.getItem('impersonatedUserId')
if (impersonateUserId) {
headers['X-Impersonate-User-ID'] = impersonateUserId
}
return headers
}
// Process queued requests after refresh attempt
processQueue(error, token = null) {
this.failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token)
}
})
this.failedQueue = []
}
// Helper to avoid repeating cleanup code
async handleLogout() {
await clearAllTokens()
try {
await logout()
} catch (e) {
console.error('Error during logout', e)
}
if (window.location.pathname !== '/login') window.location.href = '/login'
// fire and forget
}
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
}
try {
// 1. Initial Request
let response = await fetch(url, config)
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
// Always queue this request first
const queuedPromise = new Promise((resolve, reject) => {
this.failedQueue.push({
resolve: async token => {
if (!token) {
reject(new Error('Token refresh failed'))
return
}
try {
const newHeaders = this.getHeaders(options?.headers)
const retryConfig = {
...config,
headers: newHeaders,
}
const retryResponse = await fetch(url, retryConfig)
resolve(retryResponse)
} catch (error) {
reject(error)
}
},
reject,
})
})
// If already refreshing, just return the queued promise
if (this.isRefreshing) {
console.log('Token refresh already in progress, queueing request')
return queuedPromise
}
// Attempt to refresh the token
const refreshResult = await this.refreshToken()
if (refreshResult.success) {
// Process queue with success - this will retry all queued requests
this.processQueue(null, refreshResult.token)
} else if (refreshResult.error === 'Refresh cooldown active') {
// We're in cooldown - token was just refreshed, retry with current token
console.log('Refresh cooldown - retrying with current token')
const currentToken = this.getToken()
if (currentToken) {
this.processQueue(null, currentToken)
} else {
this.processQueue(new Error('No token available'), null)
this.handleLogout()
return null
}
} else if (refreshResult.error === 'Already refreshing') {
// This shouldn't happen since we check isRefreshing above, but handle it anyway
console.log('Already refreshing - waiting for refresh to complete')
return queuedPromise
} else {
// Actual refresh failure - logout
this.processQueue(new Error(refreshResult.error), null)
this.handleLogout()
return null
}
// Return the queued promise for this request
return queuedPromise
}
return response
} catch (error) {
console.error('Request failed', error)
throw error
}
}
async get(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'GET' })
}
async post(endpoint, data, options = {}) {
return this.request(endpoint, {
...options,
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
})
}
async put(endpoint, data, options = {}) {
return this.request(endpoint, {
...options,
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
})
}
async delete(endpoint, options = {}) {
return this.request(endpoint, { ...options, method: 'DELETE' })
}
async upload(endpoint, formData, options = {}) {
const headers = options.headers || {}
delete headers['Content-Type']
return this.request(endpoint, {
...options,
method: 'POST',
body: formData,
headers,
})
}
getAssetURL(path) {
return `${this.customServerURL}/assets/${path}`
}
}
export const apiClient = new ApiClient()

View File

@@ -2,7 +2,27 @@ import moment from 'moment'
import { TASK_COLOR } from './Colors.jsx'
const priorityOrder = [1, 2, 3, 4, 0]
// ChoreGrouperOptions enum:
export const GROUPING_OPTIONS = Object.freeze({
SMART: 'default',
DUE_DATE: 'due_date',
PRIORITY: 'priority',
LABELS: 'labels',
})
export const ChoreHistoryStatus = Object.freeze({
STARTED: 0,
COMPLETED: 1,
SKIPPED: 2,
PENDING_APPROVAL: 3,
REJECTED: 4,
})
export const ChoreStatus = Object.freeze({
INACTIVE: 0,
ACTIVE: 1,
PAUSED: 2,
PENDING_APPROVAL: 3,
})
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
@@ -12,12 +32,127 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
chores.sort(ChoreSorter)
var groups = []
switch (groupBy) {
case 'default':
// same as due_date but hide empty groups: and if status is 1 or 2 have seperated catigory as Started:
var groupRaw = {
PendingApproval: [],
Started: [],
Today: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
chores.forEach(chore => {
if (chore.status === 1 || chore.status === 2) {
groupRaw['Started'].push(chore)
} else if (chore.status === 3) {
groupRaw['PendingApproval'].push(chore)
} else if (chore.nextDueDate === null) {
groupRaw['Anytime'].push(chore)
} else if (new Date(chore.nextDueDate) < new Date()) {
groupRaw['Overdue'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date().toDateString()
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate).toDateString() ===
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
) {
groupRaw['Tomorrow'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) >
new Date(Date.now() + 24 * 60 * 60 * 1000)
) {
groupRaw['Next 7 Days'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
) {
groupRaw['Later This Month'].push(chore)
} else {
groupRaw['Future'].push(chore)
}
})
groups = []
if (groupRaw['Started'].length > 0) {
groups.push({
name: 'Started',
content: groupRaw['Started'],
color: TASK_COLOR.STARTED,
})
}
if (groupRaw['PendingApproval'].length > 0) {
groups.push({
name: 'Pending Approval',
content: groupRaw['PendingApproval'],
color: TASK_COLOR.LATE,
})
}
if (groupRaw['Overdue'].length > 0) {
groups.push({
name: 'Overdue',
content: groupRaw['Overdue'],
color: TASK_COLOR.OVERDUE,
})
}
if (groupRaw['Today'].length > 0) {
groups.push({
name: 'Today',
content: groupRaw['Today'],
color: TASK_COLOR.TODAY,
})
}
if (groupRaw['Tomorrow'].length > 0) {
groups.push({
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
})
}
if (groupRaw['Next 7 Days'].length > 0) {
groups.push({
name: 'Next 7 Days',
content: groupRaw['Next 7 Days'],
color: TASK_COLOR.NEXT_7_DAYS,
})
}
if (groupRaw['Later This Month'].length > 0) {
groups.push({
name: 'Later This Month',
content: groupRaw['Later This Month'],
color: TASK_COLOR.LATER_THIS_MONTH,
})
}
if (groupRaw['Future'].length > 0) {
groups.push({
name: 'Future',
content: groupRaw['Future'],
color: TASK_COLOR.FUTURE,
})
}
if (groupRaw['Anytime'].length > 0) {
groups.push({
name: 'Anytime',
content: groupRaw['Anytime'],
color: TASK_COLOR.ANYTIME,
})
}
break
case 'due_date':
var groupRaw = {
Today: [],
'In a week': [],
'This month': [],
Later: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
@@ -32,17 +167,24 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
) {
groupRaw['Today'].push(chore)
} else if (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
new Date(chore.nextDueDate).toDateString() ===
new Date(Date.now() + 24 * 60 * 60 * 1000).toDateString()
) {
groupRaw['In a week'].push(chore)
groupRaw['Tomorrow'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth()
new Date(chore.nextDueDate) <
new Date(Date.now() + 8 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) >
new Date(Date.now() + 24 * 60 * 60 * 1000)
) {
groupRaw['This month'].push(chore)
groupRaw['Next 7 Days'].push(chore)
} else if (
new Date(chore.nextDueDate).getMonth() === new Date().getMonth() &&
new Date(chore.nextDueDate).getFullYear() === new Date().getFullYear()
) {
groupRaw['Later This Month'].push(chore)
} else {
groupRaw['Later'].push(chore)
groupRaw['Future'].push(chore)
}
})
groups = [
@@ -53,16 +195,25 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
},
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
{
name: 'In a week',
content: groupRaw['In a week'],
color: TASK_COLOR.IN_A_WEEK,
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
},
{
name: 'This month',
content: groupRaw['This month'],
color: TASK_COLOR.THIS_MONTH,
name: 'Next 7 Days',
content: groupRaw['Next 7 Days'],
color: TASK_COLOR.NEXT_7_DAYS,
},
{
name: 'Later This Month',
content: groupRaw['Later This Month'],
color: TASK_COLOR.LATER_THIS_MONTH,
},
{
name: 'Future',
content: groupRaw['Future'],
color: TASK_COLOR.FUTURE,
},
{ name: 'Later', content: groupRaw['Later'], color: TASK_COLOR.LATER },
{
name: 'Anytime',
content: groupRaw['Anytime'],
@@ -176,12 +327,43 @@ export const notInCompletionWindow = chore => {
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
)
}
export const ChoreFilters = userProfile => ({
export const ChoreFilters = userId => ({
anyone: () => true,
assigned_to_me: chore => {
return chore.assignedTo && chore.assignedTo === userProfile?.id
return chore.assignedTo && chore.assignedTo === userId
},
assigned_to_others: chore => {
return chore.assignedTo && chore.assignedTo !== userProfile?.id
return chore.assignedTo && chore.assignedTo !== userId
},
assigned_to_me_tasks: chore => {
return (
chore.assignees &&
chore.assignees.some(assignee => assignee.userId === userId)
)
},
created_by_me: chore => {
return chore.createdBy && chore.createdBy === userId
},
})
// Project filter function - separate from ChoreFilters since it's independent
export const filterByProject = (chores, selectedProject) => {
if (
!selectedProject ||
selectedProject === 'Default Project' ||
selectedProject === 'default'
) {
// Default project should show tasks without a project (projectId is null/undefined/empty)
// Based on ChoreEdit.jsx, default projects save projectId as null
return chores.filter(
chore =>
!chore.projectId || chore.projectId === null || chore.projectId === '',
)
}
// For custom projects, match by project ID
return chores.filter(chore => {
// Match by project ID (this should be the primary way chores are linked to projects)
return chore.projectId === selectedProject
})
}

View File

@@ -27,7 +27,6 @@ const LABEL_COLORS = [
]
export const COLORS = {
white: '#FFFFFF',
salmon: '#ff7961',
teal: '#26a69a',
skyBlue: '#80d8ff',
@@ -52,6 +51,12 @@ export const COLORS = {
blush: '#f8bbd0',
ash: '#90a4ae',
sand: '#d7ccc8',
white: '#FFFFFF',
}
export const NOTIFICATION_TYPE = {
PREDUE: '#4ec1a2',
DUE_DATE: '#f6ad55',
POSTDUE: '#F03A47',
}
export const TASK_COLOR = {
@@ -60,25 +65,47 @@ export const TASK_COLOR = {
MISSED: '#F03A47',
UPCOMING: '#AF5B5B',
SKIPPED: '#E2C2FF',
IN_PROGRESS: '#00bcd4',
// PENDING_REVIEW: '#b39ddb',
OVERDUE: '#F03A47',
SCHEDULED: '#10B982',
PENDING_REVIEW: '#8B6CE1',
// For the calendar
OVERDUE: '#F03A47',
TODAY: '#ffc107',
TOMORROW: '#4ec1a2',
NEXT_7_DAYS: '#00bcd4',
LATER_THIS_MONTH: '#b39ddb',
FUTURE: '#d7ccc8',
ANYTIME: '#90a4ae',
// Legacy colors for backward compatibility
IN_A_WEEK: '#4ec1a2',
THIS_MONTH: '#00bcd4',
LATER: '#d7ccc8',
ANYTIME: '#90a4ae',
// FOR ASSIGNEE:
ASSIGNED_TO_ME: '#4ec1a2',
ASSIGNED_TO_OTHER: '#b39ddb',
UNASSIGNED: '#ffc107',
// FOR PRIORITY:
PRIORITY_1: '#F03A47',
PRIORITY_2: '#ffc107',
PRIORITY_3: '#00bcd4',
PRIORITY_4: '#7e57c2',
NO_PRIORITY: '#90a4ae',
// PRIORITY_1: '#F03A47',
// PRIORITY_2: '#ffc107',
// PRIORITY_3: '#00bcd4',
// PRIORITY_4: '#7e57c2',
// NO_PRIORITY: '#90a4ae',
// FOR PRIORITY:
// PRIORITY_1: '#F03A4780',
// PRIORITY_2: '#ffc10780',
// PRIORITY_3: '#00bcd480',
// PRIORITY_4: '#7e57c280',
PRIORITY_1: '#d32f2f',
PRIORITY_2: '#ed6c02',
PRIORITY_3: '#0288d1',
// PRIORITY_4: '#388e3c',
PRIORITY_4: '#90a4ae',
NO_PRIORITY: '#90a4ae80',
}
export default LABEL_COLORS
@@ -90,3 +117,18 @@ export const getTextColorFromBackgroundColor = bgColor => {
const b = parseInt(hex.substring(4, 6), 16)
return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff'
}
export const getPriorityColor = priority => {
switch (priority) {
case 1:
return TASK_COLOR.PRIORITY_1
case 2:
return TASK_COLOR.PRIORITY_2
case 3:
return TASK_COLOR.PRIORITY_3
case 4:
return TASK_COLOR.PRIORITY_4
default:
return TASK_COLOR.NO_PRIORITY
}
}

134
src/utils/FeatureToggle.js Normal file
View File

@@ -0,0 +1,134 @@
export const FEATURES = {
OFFLINE_MODE: 'experimental_feature_offline_mode',
}
/**
* Get the current state of a feature flag from localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
* @returns {boolean} - Whether the feature is enabled
*/
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
try {
const value = localStorage.getItem(featureKey)
if (value === 'true') return true
if (value === 'false') return false
if (value === null || value === undefined) return defaultValue
return Boolean(value)
} catch (error) {
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
return defaultValue
}
}
/**
* Set the state of a feature flag in localStorage
* @param {string} featureKey - The feature key from FEATURES constant
* @param {boolean} enabled - Whether to enable the feature
*/
export const setFeatureEnabled = (featureKey, enabled) => {
try {
localStorage.setItem(featureKey, enabled.toString())
} catch (error) {
console.error(
`FeatureToggle: Error setting feature "${featureKey}":`,
error,
)
}
}
export const toggleFeature = featureKey => {
const currentState = isFeatureEnabled(featureKey)
const newState = !currentState
setFeatureEnabled(featureKey, newState)
return newState
}
export const getAllFeatureStates = () => {
const states = {}
Object.entries(FEATURES).forEach(([name, key]) => {
states[name] = isFeatureEnabled(key)
})
return states
}
export const clearAllFeatures = () => {
try {
Object.values(FEATURES).forEach(featureKey => {
localStorage.removeItem(featureKey)
})
} catch (error) {
console.error('FeatureToggle: Error clearing features:', error)
}
}
/**
* Check if the current instance is the official donetick.com service
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstance = async () => {
try {
// Import here to avoid circular dependencies
const { Preferences } = await import('@capacitor/preferences')
const { API_URL } = await import('../Config')
// Get custom server URL from preferences
const { value: customServerUrl } = await Preferences.get({
key: 'customServerUrl',
})
// Use custom URL if set, otherwise fall back to API_URL
const serverUrl = customServerUrl || API_URL
// Check if the server URL contains donetick.com
return serverUrl.toLowerCase().includes('donetick.com')
} catch (error) {
console.warn('FeatureToggle: Error checking server instance:', error)
// Default to false for safety (self-hosted assumption)
return false
}
}
/**
* Synchronous version that checks based on current API manager state
* Note: This requires apiManager to be initialized first
* @returns {boolean} - Whether this is the official donetick.com instance
*/
export const isOfficialDonetickInstanceSync = () => {
try {
// Dynamic import to avoid circular dependencies
return import('./ApiClient')
.then(({ apiClient }) => {
const currentApiUrl = apiClient.baseURL
// Check if the API URL contains donetick.com
return currentApiUrl.toLowerCase().includes('donetick.com')
})
.catch(error => {
console.warn(
'FeatureToggle: Error checking server instance (sync):',
error,
)
// Default to false for safety (self-hosted assumption)
return false
})
} catch (error) {
console.warn('FeatureToggle: Error checking server instance (sync):', error)
// Default to false for safety (self-hosted assumption)
return false
}
}
// Export default object for easier imports
export default {
FEATURES,
isFeatureEnabled,
setFeatureEnabled,
toggleFeature,
getAllFeatureStates,
clearAllFeatures,
isOfficialDonetickInstance,
isOfficialDonetickInstanceSync,
}

View File

@@ -1,4 +1,18 @@
import { Fetch, HEADERS, apiManager } from './TokenManager'
import { apiClient } from './ApiClient'
// Migration helpers to maintain compatibility with existing code
const Fetch = async (endpoint, options = {}) => {
const response = await apiClient.request(endpoint, options)
return response
}
const HEADERS = () => {
return apiClient.getHeaders()
}
const apiManager = {
getApiURL: () => apiClient.getApiURL(),
}
const createChore = userID => {
return Fetch(`/chores/`, {
@@ -41,6 +55,14 @@ const login = (username, password) => {
})
}
const logout = () => {
const baseURL = apiManager.getApiURL()
return fetch(`${baseURL}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
const GetAllUsers = () => {
return Fetch(`/users/`, {
method: 'GET',
@@ -123,12 +145,26 @@ const MarkChoreComplete = (id, body, completedDate, performer) => {
})
}
const CompleteSubTask = (id, choreId, performedAt) => {
const StartChore = id => {
return Fetch(`/chores/${id}/start`, {
method: 'PUT',
headers: HEADERS(),
})
}
const PauseChore = id => {
return Fetch(`/chores/${id}/pause`, {
method: 'PUT',
headers: HEADERS(),
})
}
const CompleteSubTask = (id, choreId, completedAt) => {
var markChoreURL = `/chores/${choreId}/subtask`
return Fetch(markChoreURL, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ performedAt, id, choreId }),
body: JSON.stringify({ completedAt, id, choreId }),
})
}
@@ -142,11 +178,49 @@ const SkipChore = id => {
})
}
const ApproveChore = id => {
return Fetch(`/chores/${id}/approve`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const RejectChore = id => {
return Fetch(`/chores/${id}/reject`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const UndoChoreAction = id => {
return Fetch(`/chores/${id}/undo`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({}),
})
}
const NudgeChore = (id, { message, notifyAllAssignees }) => {
return Fetch(`/chores/${id}/nudge`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
all_assignees: notifyAllAssignees,
message: message || '',
}),
})
}
const UpdateChoreAssignee = (id, assignee) => {
return Fetch(`/chores/${id}/assignee`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ assignee: Number(assignee) }),
body: JSON.stringify({
assignee: Number(assignee),
updatedAt: new Date().toISOString(),
}),
})
}
@@ -201,14 +275,6 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => {
})
}
const UpdateChoreStatus = (choreId, status) => {
return Fetch(`/chores/${choreId}/status`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({ status }),
})
}
const GetAllCircleMembers = async () => {
const resp = await Fetch(`/circles/members`, {
method: 'GET',
@@ -499,6 +565,7 @@ const UpdateDueDate = (id, dueDate) => {
},
body: JSON.stringify({
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
updatedAt: new Date().toISOString(),
}),
})
}
@@ -510,12 +577,39 @@ const RedeemPoints = (userId, points, circleID) => {
body: JSON.stringify({ points, userId }),
})
}
const RefreshToken = () => {
const RefreshToken = async () => {
const basedURL = apiManager.getApiURL()
return fetch(`${basedURL}/auth/refresh`, {
method: 'GET',
headers: HEADERS(),
})
// Check if running on native platform
const isNative =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
if (isNative) {
// For native platforms, send refresh token in request body
const { Preferences } = await import('@capacitor/preferences')
const { value: refreshToken } = await Preferences.get({
key: 'refresh_token',
})
if (!refreshToken) {
throw new Error('No refresh token available')
}
return fetch(`${basedURL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refresh_token: refreshToken }),
})
} else {
// For web, continue using cookies
return fetch(`${basedURL}/auth/refresh`, {
method: 'POST',
credentials: 'include',
headers: HEADERS(),
})
}
}
const GetChoresHistory = async (limit, includeMembers) => {
var url = `/chores/history`
@@ -549,37 +643,249 @@ const GetStorageUsage = () => {
})
}
// Timer/TimeSession API functions
const GetChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer`, {
method: 'GET',
headers: HEADERS(),
})
}
const UpdateTimeSession = (choreId, sessionId, sessionData) => {
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(sessionData),
})
}
const DeleteTimeSession = (choreId, sessionId) => {
return Fetch(`/chores/${choreId}/timer/${sessionId}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const ResetChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer/reset`, {
method: 'PUT',
headers: HEADERS(),
})
}
const ClearChoreTimer = choreId => {
return Fetch(`/chores/${choreId}/timer`, {
method: 'DELETE',
headers: HEADERS(),
})
}
const CheckUserDeletion = password => {
return Fetch(`/users/delete/check`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
password,
}),
})
}
const DeleteUser = (password, confirmation, transferOptions = []) => {
return Fetch(`/users/delete`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
password,
confirmation,
transferOptions,
}),
})
}
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
include_assets: includeAssets,
backup_name: backupName,
}),
})
}
const RestoreBackup = (encryptionKey, backupData) => {
return Fetch(`/backup/restore`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
encryption_key: encryptionKey,
backup_data: backupData,
}),
})
}
const RegisterDeviceToken = (
token,
deviceId,
platform,
appVersion,
deviceModel,
) => {
return Fetch(`/devices/tokens`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
token,
deviceId,
platform,
appVersion,
deviceModel,
}),
})
}
const UnregisterDeviceToken = (deviceId, token) => {
return Fetch(`/devices/tokens`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({
deviceId,
token,
}),
})
}
const GetDeviceTokens = (active = true) => {
return Fetch(`/devices/tokens?active=${active}`, {
method: 'GET',
headers: HEADERS(),
})
}
// Child User Management Functions
const CreateChildUser = (childName, displayName, password) => {
return Fetch(`/users/subaccounts`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify({
childName,
displayName,
password,
}),
})
}
const GetChildUsers = () => {
return Fetch(`/users/subaccounts`, {
method: 'GET',
headers: HEADERS(),
})
}
const UpdateChildPassword = (childUserId, password) => {
return Fetch(`/users/subaccounts/password`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify({
childUserId,
password,
}),
})
}
const DeleteChildUser = childUserId => {
return Fetch(`/users/subaccounts/${childUserId}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
// Project-related API functions
const GetProjects = () => {
return Fetch(`/projects`, {
method: 'GET',
headers: HEADERS(),
})
}
const GetProjectById = id => {
return Fetch(`/projects/${id}`, {
method: 'GET',
headers: HEADERS(),
})
}
const CreateProject = project => {
return Fetch(`/projects`, {
method: 'POST',
headers: HEADERS(),
body: JSON.stringify(project),
})
}
const UpdateProject = (id, project) => {
return Fetch(`/projects/${id}`, {
method: 'PUT',
headers: HEADERS(),
body: JSON.stringify(project),
})
}
const DeleteProject = id => {
return Fetch(`/projects/${id}`, {
method: 'DELETE',
headers: HEADERS(),
})
}
export {
AcceptCircleMemberRequest,
ApproveChore,
ArchiveChore,
CancelSubscription,
ChangePassword,
CheckUserDeletion,
ClearChoreTimer,
CompleteSubTask,
ConfirmMFA,
CreateBackup,
CreateChildUser,
CreateChore,
createChore,
CreateLabel,
CreateLongLiveToken,
CreateProject,
CreateThing,
DeleteChildUser,
DeleteChore,
DeleteChoreHistory,
DeleteCircleMember,
DeleteLabel,
DeleteLongLiveToken,
DeleteProject,
DeleteThing,
DeleteTimeSession,
DeleteUser,
DisableMFA,
GetAllCircleMembers,
GetAllUsers,
GetArchivedChores,
GetChildUsers,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
GetChores,
GetChoresHistory,
GetChoresNew,
GetChoreTimer,
GetCircleMemberRequests,
GetDeviceTokens,
GetLabels,
GetLongLiveTokens,
GetMFAStatus,
GetProjectById,
GetProjects,
GetResource,
GetStorageUsage,
GetSubscriptionSession,
@@ -589,31 +895,42 @@ export {
GetUserProfile,
JoinCircle,
LeaveCircle,
login,
logout,
MarkChoreComplete,
NudgeChore,
PauseChore,
PutNotificationTarget,
PutWebhookURL,
RedeemPoints,
RefreshToken,
RegenerateBackupCodes,
RegisterDeviceToken,
RejectChore,
ResetChoreTimer,
ResetPassword,
RestoreBackup,
SaveChore,
SaveThing,
SetupMFA,
signUp,
SkipChore,
StartChore,
UnArchiveChore,
UndoChoreAction,
UnregisterDeviceToken,
UpdateChildPassword,
UpdateChoreAssignee,
UpdateChoreHistory,
UpdateChorePriority,
UpdateChoreStatus,
UpdateDueDate,
UpdateLabel,
UpdateMemberRole,
UpdateNotificationTarget,
UpdatePassword,
UpdateProject,
UpdateThingState,
UpdateTimeSession,
UpdateUserDetails,
VerifyMFA,
createChore,
login,
signUp,
}

View File

@@ -1,5 +1,5 @@
import moment from 'moment'
import { getAssetURL } from './TokenManager'
import { apiClient } from './ApiClient'
const isPlusAccount = userProfile => {
return userProfile?.expiration && moment(userProfile?.expiration).isAfter()
@@ -11,7 +11,7 @@ const resolvePhotoURL = url => {
return url
}
if (url.startsWith('assets')) {
return getAssetURL(url)
return apiClient.getAssetURL(url)
}
return url
}

View File

@@ -0,0 +1,63 @@
/**
* Utility functions for platform detection
*/
/**
* Detects if the current platform is macOS using modern APIs with fallback
* @returns {boolean} True if running on macOS, false otherwise
*/
export const isMacOS = () => {
// Modern approach using User-Agent Client Hints API
if (navigator.userAgentData) {
return navigator.userAgentData.platform === 'macOS'
}
// Fallback for older browsers
return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)
}
/**
* Gets the appropriate keyboard shortcut text for the current platform
* @param {string} key - The key combination (e.g., 'F', 'K', 'S')
* @param {boolean} withCtrl - Whether to include Ctrl/Cmd modifier
* @param {boolean} withShift - Whether to include Shift modifier
* @returns {string} Platform-appropriate keyboard shortcut text
*/
export const getKeyboardShortcut = (
key,
withCtrl = true,
withShift = false,
) => {
let shortcut = ''
if (withCtrl) {
const modifier = isMacOS() ? '⌘' : 'Ctrl+'
shortcut += modifier
}
if (withShift) {
if (isMacOS()) {
shortcut += '⇧'
} else {
shortcut += 'Shift+'
}
}
shortcut += key
return shortcut
}
/**
* Gets common keyboard shortcuts for the current platform
*/
export const getCommonShortcuts = () => ({
search: getKeyboardShortcut('F'),
newTask: getKeyboardShortcut('K'),
selectAll: getKeyboardShortcut('A'),
multiSelect: getKeyboardShortcut('S'),
save: getKeyboardShortcut('S'),
copy: getKeyboardShortcut('C'),
paste: getKeyboardShortcut('V'),
undo: getKeyboardShortcut('Z'),
redo: getKeyboardShortcut('Z', true, true), // Ctrl/Cmd + Shift + Z
})

View File

@@ -0,0 +1,60 @@
import {
AccountBalance,
Book,
Build,
BusinessCenter,
Code,
Computer,
DirectionsCar,
FitnessCenter,
Flight,
FolderOpen,
Games,
Home,
LocalHospital,
MusicNote,
Palette,
Pets,
PhotoCamera,
Restaurant,
School,
Science,
ShoppingCart,
SportsSoccer,
Work,
Yard,
} from '@mui/icons-material'
const PROJECT_ICONS = [
{ name: 'Folder', icon: FolderOpen, value: 'FolderOpen' },
{ name: 'Work', icon: Work, value: 'Work' },
{ name: 'Home', icon: Home, value: 'Home' },
{ name: 'School', icon: School, value: 'School' },
{ name: 'Business', icon: BusinessCenter, value: 'BusinessCenter' },
{ name: 'Code', icon: Code, value: 'Code' },
{ name: 'Build', icon: Build, value: 'Build' },
{ name: 'Design', icon: Palette, value: 'Palette' },
{ name: 'Sports', icon: SportsSoccer, value: 'SportsSoccer' },
{ name: 'Fitness', icon: FitnessCenter, value: 'FitnessCenter' },
{ name: 'Shopping', icon: ShoppingCart, value: 'ShoppingCart' },
{ name: 'Food', icon: Restaurant, value: 'Restaurant' },
{ name: 'Travel', icon: Flight, value: 'Flight' },
{ name: 'Study', icon: Book, value: 'Book' },
{ name: 'Music', icon: MusicNote, value: 'MusicNote' },
{ name: 'Photo', icon: PhotoCamera, value: 'PhotoCamera' },
{ name: 'Games', icon: Games, value: 'Games' },
{ name: 'Science', icon: Science, value: 'Science' },
{ name: 'Finance', icon: AccountBalance, value: 'AccountBalance' },
{ name: 'Health', icon: LocalHospital, value: 'LocalHospital' },
{ name: 'Auto', icon: DirectionsCar, value: 'DirectionsCar' },
{ name: 'Pets', icon: Pets, value: 'Pets' },
{ name: 'Garden', icon: Yard, value: 'Garden' },
{ name: 'Tech', icon: Computer, value: 'Computer' },
]
export default PROJECT_ICONS
export const getIconComponent = iconValue => {
const iconData = PROJECT_ICONS.find(icon => icon.value === iconValue)
return iconData ? iconData.icon : FolderOpen
}

View File

@@ -0,0 +1,89 @@
import { Capacitor } from '@capacitor/core'
/**
* Utility functions for handling safe area insets consistently across the app
*/
/**
* Get the appropriate bottom value that accounts for safe area insets
* @param {number|string} baseBottom - The base bottom value (default: 0)
* @param {number|string} extraPadding - Additional padding to add (default: 0)
* @returns {string} - CSS calc() expression for bottom positioning
*/
export const getSafeBottom = (baseBottom = 0, extraPadding = 0) => {
const base = typeof baseBottom === 'number' ? `${baseBottom}px` : baseBottom
const extra =
typeof extraPadding === 'number' ? `${extraPadding}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${base} + ${extra})`
}
return `calc(var(--safe-area-inset-bottom, 0px) + ${base})`
}
// For iOS and web, safe area is already handled by the system
if (extraPadding) {
return `calc(${base} + ${extra})`
}
return base
}
/**
* Get safe area padding for bottom elements
* @param {number|string} extraPadding - Additional padding to add
* @returns {string} - CSS calc() expression for padding
*/
export const getSafeBottomPadding = (extraPadding = 0) => {
const extra =
typeof extraPadding === 'number' ? `${extraPadding * 8}px` : extraPadding
if (Capacitor.getPlatform() === 'android') {
if (extraPadding) {
return `calc(var(--safe-area-inset-bottom, 0px) + ${extra})`
}
return `var(--safe-area-inset-bottom, 0px)`
}
return extra || '0px'
}
/**
* Get safe area styles object for common bottom-positioned elements
* @param {object} options - Configuration options
* @param {number|string} options.bottom - Bottom position value
* @param {number|string} options.padding - Additional padding
* @param {'fixed'|'absolute'|'sticky'} options.position - Position type
* @returns {object} - Style object
*/
export const getSafeBottomStyles = ({
bottom = 0,
padding = 0,
position = 'fixed',
} = {}) => {
return {
position,
bottom: getSafeBottom(bottom, padding),
}
}
/**
* Hook-like function to get safe area values for use in components
* @returns {object} - Object with safe area utility functions
*/
export const useSafeArea = () => {
return {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
isAndroid: Capacitor.getPlatform() === 'android',
isNative: Capacitor.isNativePlatform(),
}
}
export default {
getSafeBottom,
getSafeBottomPadding,
getSafeBottomStyles,
useSafeArea,
}

View File

@@ -0,0 +1,65 @@
export const DEFAULT_SIDEPANEL_CONFIG = [
{
id: 'welcome', // legacy name, now represents User Switcher
name: 'User Switcher',
description: 'Allows admins/managers to view tasks as different users',
iconName: 'SupervisorAccount',
enabled: true,
order: 0,
},
{
id: 'assignees',
name: 'Tasks by Assignee',
description: 'Groups tasks by who they are assigned to',
iconName: 'Person',
enabled: true,
order: 1,
},
{
id: 'calendar',
name: 'Calendar View',
description: 'Shows tasks in a calendar format',
iconName: 'CalendarMonth',
enabled: true,
order: 2,
},
{
id: 'activities',
name: 'Recent Activities',
description: 'Shows recent task completions and activities',
iconName: 'History',
enabled: true,
order: 3,
},
{
id: 'weeklyGoals',
name: 'Weekly Goals',
description: 'Shows weekly progress and family completion stats',
iconName: 'EmojiEvents',
enabled: true,
order: 4,
},
]
export const getSidepanelConfig = () => {
const saved = localStorage.getItem('sidepanelConfig')
if (saved) {
try {
return JSON.parse(saved)
} catch (error) {
console.error('Error parsing sidepanel config:', error)
return DEFAULT_SIDEPANEL_CONFIG
}
}
return DEFAULT_SIDEPANEL_CONFIG
}
export const saveSidepanelConfig = config => {
localStorage.setItem('sidepanelConfig', JSON.stringify(config))
window.dispatchEvent(new Event('sidepanelConfigChanged'))
}
export const resetSidepanelConfig = () => {
saveSidepanelConfig(DEFAULT_SIDEPANEL_CONFIG)
return DEFAULT_SIDEPANEL_CONFIG
}

View File

@@ -0,0 +1,228 @@
import { Capacitor } from '@capacitor/core'
import { StatusBar, Style } from '@capacitor/status-bar'
import { SafeArea } from 'capacitor-plugin-safe-area'
/**
* StatusBarManager - A utility class to handle status bar configuration
* following Capacitor best practices and theme-aware styling
*/
class StatusBarManager {
constructor() {
this.isNativePlatform = Capacitor.isNativePlatform()
this.platform = Capacitor.getPlatform()
this.listeners = []
this.currentTheme = 'light'
this.safeAreaApplied = false
}
/**
* Initialize the status bar with proper configuration
* @param {string} initialTheme - The initial theme ('light' | 'dark' | 'system')
*/
async initialize(initialTheme = 'light') {
if (!this.isNativePlatform) {
console.log('StatusBarManager: Not running on native platform')
return
}
try {
// Configure basic status bar settings - use overlay: true for precise control
await StatusBar.setOverlaysWebView({ overlay: false })
await StatusBar.show()
// Set initial theme
await this.setTheme(initialTheme)
// Apply safe area insets
await this.applySafeAreaInsets()
console.log('StatusBarManager: Initialized successfully')
} catch (error) {
console.error('StatusBarManager: Failed to initialize:', error)
}
}
/**
* Set status bar style based on theme
* @param {string} theme - The theme ('light' | 'dark' | 'system')
*/
async setTheme(theme) {
if (!this.isNativePlatform) return
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
}
await StatusBar.setStyle({ style })
console.log(`StatusBarManager: Theme set to ${theme}, style: ${style}`)
} catch (error) {
console.error('StatusBarManager: Failed to set theme:', error)
}
}
/**
* Apply safe area insets using CSS custom properties
* Components should use env() variables or CSS custom properties for proper safe area handling
*/
async applySafeAreaInsets() {
if (!this.isNativePlatform || this.safeAreaApplied) return
try {
// Get safe area insets
const { insets } = await SafeArea.getSafeAreaInsets()
// Apply CSS custom properties for safe area
this.applySafeAreaCSS(insets)
this.safeAreaApplied = true
console.log('StatusBarManager: Safe area insets applied:', insets)
} catch (error) {
console.error(
'StatusBarManager: Failed to apply safe area insets:',
error,
)
}
}
/**
* Apply safe area insets using CSS custom properties
* @param {Object} insets - The safe area insets
*/
applySafeAreaCSS(insets) {
const root = document.documentElement
// Set CSS custom properties that can be used throughout the app
root.style.setProperty('--safe-area-inset-top', `${insets.top}px`)
root.style.setProperty('--safe-area-inset-right', `${insets.right}px`)
root.style.setProperty('--safe-area-inset-bottom', `${insets.bottom}px`)
root.style.setProperty('--safe-area-inset-left', `${insets.left}px`)
// Note: We no longer apply padding directly to the body to avoid double
// application with component-level safe area handling. Components should
// use the CSS custom properties or the utility classes from safe-area.css
// Let's apply it directly to body for now:
if (Capacitor.getPlatform() === 'android') {
// removing the top padding on android as it is handled by the navbar
// and adding it causes double padding
// document.body.style.paddingTop = `${insets.top}px`
document.body.style.paddingRight = `${insets.right}px`
document.body.style.paddingBottom = `${insets.bottom}px`
document.body.style.paddingLeft = `${insets.left}px`
}
}
/**
* Add a listener for theme changes
* @param {Function} callback - Function to call when theme changes
* @returns {Function} - Cleanup function to remove the listener
*/
addThemeChangeListener(callback) {
this.listeners.push(callback)
// Return cleanup function
return () => {
const index = this.listeners.indexOf(callback)
if (index > -1) {
this.listeners.splice(index, 1)
}
}
}
/**
* Notify all listeners of theme change
* @param {string} newTheme - The new theme
*/
notifyThemeChange(newTheme) {
this.listeners.forEach(callback => {
try {
callback(newTheme)
} catch (error) {
console.error(
'StatusBarManager: Error in theme change listener:',
error,
)
}
})
}
/**
* Update status bar based on resolved theme (after system detection)
* @param {string} resolvedTheme - The actual theme being used ('light' | 'dark')
*/
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)
}
}
/**
* Hide the status bar
*/
async hide() {
if (!this.isNativePlatform) return
try {
await StatusBar.hide()
} catch (error) {
console.error('StatusBarManager: Failed to hide status bar:', error)
}
}
/**
* Show the status bar
*/
async show() {
if (!this.isNativePlatform) return
try {
await StatusBar.show()
} catch (error) {
console.error('StatusBarManager: Failed to show status bar:', error)
}
}
/**
* Get current status bar info
*/
async getInfo() {
if (!this.isNativePlatform) return null
try {
return await StatusBar.getInfo()
} catch (error) {
console.error('StatusBarManager: Failed to get status bar info:', error)
return null
}
}
/**
* Clean up all listeners and reset state
*/
cleanup() {
this.listeners = []
this.safeAreaApplied = false
console.log('StatusBarManager: Cleaned up')
}
}
// Create and export a singleton instance
const statusBarManager = new StatusBarManager()
export default statusBarManager

View File

@@ -1,202 +0,0 @@
import { Network } from '@capacitor/network'
import { Preferences } from '@capacitor/preferences'
import Cookies from 'js-cookie'
import murmurhash from 'murmurhash'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { RefreshToken } from './Fetcher'
import { localStore } from './LocalStore'
class ApiManager {
constructor() {
this.customServerURL = `${API_URL}/api/v1`
this.initialized = false
this.navigateToLogin = () => {}
}
async init() {
if (this.initialized) {
return
}
const { value: serverURL } = await Preferences.get({
key: 'customServerUrl',
})
this.customServerURL = `${serverURL || API_URL}/api/v1`
await localStore.initDatabase()
this.initialized = true
}
getApiURL() {
return this.customServerURL
}
updateApiURL(url) {
this.customServerURL = url
this.init()
}
setNavigateToLogin(callback) {
this.navigateToLogin = callback
}
}
export const apiManager = new ApiManager()
export const getAssetURL = path => {
const baseURL = apiManager.getApiURL()
return `${baseURL}/assets/${path}`
}
export async function UploadFile(url, options) {
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
}
if (!options) {
options = {}
}
const headers = HEADERS()
options.headers = { Authorization: headers['Authorization'] }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
return fetch(fullURL, options)
}
export async function Fetch(url, options) {
if (!isTokenValid()) {
Cookies.set('ca_redirect', window.location.pathname)
window.location.href = '/login'
}
if (!options) {
options = {}
}
options.headers = { ...options.headers, ...HEADERS() }
const baseURL = apiManager.getApiURL()
const fullURL = `${baseURL}${url}`
const networkStatus = await Network.getStatus()
if (!networkStatus.connected) {
return handleOfflineRequest(fullURL, options)
}
// Online: Perform the fetch
try {
const response = await fetch(fullURL, options)
if (response.ok) {
const data = await response.clone().json()
const optionsHash = murmurhash.v3(JSON.stringify(options))
await localStore.saveToCache(fullURL + optionsHash, data)
networkManager.setOnline()
} else if (response.status === 401) {
// Handle 401 Unauthorized
const errorData = await response.json()
console.error('Unauthorized:', errorData)
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
apiManager.navigateToLogin()
} else if (
response.status === 503 ||
response.type === 'opaque' ||
response.status === 0
) {
networkManager.setOffline()
return handleOfflineRequest(fullURL, options)
}
// return promise that resolves to response object:
return Promise.resolve(response)
} catch (error) {
networkManager.setOffline()
console.error('Fetch error:', error)
// throw error
return handleOfflineRequest(fullURL, options)
}
}
export const HEADERS = () => {
return {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + localStorage.getItem('ca_token'),
}
}
export const isTokenValid = () => {
const expiration = localStorage.getItem('ca_expiration')
const token = localStorage.getItem('ca_token')
if (token) {
const now = new Date()
const expire = new Date(expiration)
if (now < expire) {
if (now.getTime() + 24 * 60 * 60 * 1000 > expire.getTime()) {
refreshAccessToken()
}
return true
} else {
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
}
return false
}
}
export const refreshAccessToken = () => {
RefreshToken().then(res => {
if (res.status === 200) {
res.json().then(data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
})
} else {
return res.json().then(error => {
console.log(error)
})
}
})
}
async function handleOfflineRequest(url, options) {
// if get request then attempt to fetch from cache otherewise queue it :
if (options.method === 'GET') {
return attemptFetchFromCache(url, options)
} else {
// Queue the request for later processing
const requestId = murmurhash.v3(JSON.stringify({ url, options }))
await localStore.queueRequest(requestId, { url, options })
console.log('Request queued for later processing:', requestId)
return Promise.reject({
error: 'Offline and request queued',
requestId,
queued: true,
})
}
}
async function attemptFetchFromCache(url, options) {
const optionsHash = murmurhash.v3(JSON.stringify(options))
const cachedData = await localStore.getFromCache(url + optionsHash)
networkManager.setOffline()
if (cachedData) {
return Promise.resolve({
ok: true,
status: 200,
json: async () => cachedData,
})
} else {
// TODO: change this to throw error instead of returning promise
return Promise.reject(
new Error(
'No cached data found for URL: ' +
url +
' with options hash: ' +
optionsHash,
),
)
}
}

190
src/utils/TokenStorage.js Normal file
View File

@@ -0,0 +1,190 @@
import { Capacitor } from '@capacitor/core'
import { Preferences } from '@capacitor/preferences'
// Token storage keys
const TOKEN_KEYS = {
ACCESS_TOKEN: 'token',
ACCESS_TOKEN_EXPIRY: 'token_expiry',
REFRESH_TOKEN: 'refresh_token',
REFRESH_TOKEN_EXPIRY: 'refresh_token_expiry',
}
// Cache platform detection to avoid repeated checks
let _isNativePlatform = null
// Platform detection
const isNativePlatform = () => {
if (_isNativePlatform === null) {
try {
_isNativePlatform =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
} catch (error) {
console.warn('Platform detection failed, defaulting to web:', error)
_isNativePlatform = false
}
}
return _isNativePlatform
}
// Track if we're currently clearing tokens to prevent race conditions
let clearingTokens = false
/**
* Save tokens based on platform
* Web: Only access tokens to localStorage
* Native: Access tokens to localStorage + refresh tokens to Capacitor Preferences
*/
export const saveTokens = async ({
accessToken,
accessTokenExpiry,
refreshToken,
refreshTokenExpiry,
}) => {
try {
// Always save access tokens to localStorage
if (accessToken) {
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN, accessToken)
}
if (accessTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY, accessTokenExpiry)
}
if (refreshTokenExpiry) {
localStorage.setItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY, refreshTokenExpiry)
}
if (Capacitor.isNativePlatform()) {
// On native platforms, also save refresh tokens to Capacitor Preferences
try {
if (refreshToken) {
await Preferences.set({
key: TOKEN_KEYS.REFRESH_TOKEN,
value: refreshToken,
})
}
if (refreshTokenExpiry) {
await Preferences.set({
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
value: refreshTokenExpiry,
})
}
} catch (error) {
console.error(
'Failed to save refresh tokens to Capacitor Preferences:',
error,
)
// Don't throw - access token is still saved to localStorage
}
}
} catch (error) {
console.error('Error saving tokens:', error)
throw error
}
}
/**
* Get refresh token from storage
* Web: Returns null (uses HTTP-only cookies)
* Native: Returns from Capacitor Preferences
*/
export const getRefreshToken = async () => {
if (!isNativePlatform()) {
return null // Web uses HTTP-only cookies
}
try {
const { value } = await Preferences.get({ key: TOKEN_KEYS.REFRESH_TOKEN })
return value
} catch (error) {
console.error('Error reading refresh token from Preferences:', error)
return null
}
}
/**
* Get refresh token expiry from storage
* Web: Returns null (uses HTTP-only cookies)
* Native: Returns from Capacitor Preferences
*/
export const getRefreshTokenExpiry = async () => {
if (!isNativePlatform()) {
return null // Web uses HTTP-only cookies
}
try {
const { value } = await Preferences.get({
key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY,
})
return value
} catch (error) {
console.error('Error reading refresh token expiry from Preferences:', error)
return null
}
}
/**
* Check if refresh token is expired
* Web: Returns false (backend handles cookie expiration)
* Native: Checks expiry from Capacitor Preferences
*/
export const isRefreshTokenExpired = async () => {
if (!isNativePlatform()) {
return false // Web uses cookies, backend handles expiration
}
const expiry = await getRefreshTokenExpiry()
if (!expiry) {
return true // No expiry means no token
}
try {
const expiryDate = new Date(expiry)
if (isNaN(expiryDate.getTime())) {
console.error('Invalid refresh token expiry date:', expiry)
return true // Treat invalid date as expired
}
return new Date() >= expiryDate
} catch (error) {
console.error('Error parsing refresh token expiry:', error)
return true // Treat parse error as expired
}
}
/**
* Clear all tokens from all storage locations
* Idempotent - safe to call multiple times
*/
export const clearAllTokens = async () => {
if (clearingTokens) {
return // Already clearing, don't run concurrently
}
clearingTokens = true
try {
// Clear localStorage
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
// Clean up legacy keys
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
localStorage.removeItem('access_token')
// Clear Capacitor Preferences on native
if (isNativePlatform()) {
try {
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN })
await Preferences.remove({ key: TOKEN_KEYS.REFRESH_TOKEN_EXPIRY })
} catch (error) {
console.error('Error clearing tokens from Preferences:', error)
}
}
} catch (error) {
console.error('Error clearing tokens:', error)
} finally {
clearingTokens = false
}
}
/**
* Export platform detection helper
*/
export const isNative = isNativePlatform

66
src/utils/UserHelpers.js Normal file
View File

@@ -0,0 +1,66 @@
// User type detection and permission utilities
export const USER_TYPES = {
PARENT: 0,
CHILD: 1,
}
export const isParentUser = (user) => {
if (!user) return false
return user.userType === USER_TYPES.PARENT && !user.parentUserId
}
export const isChildUser = (user) => {
if (!user) return false
return user.userType === USER_TYPES.CHILD && user.parentUserId !== null
}
export const canManageChildUsers = (user) => {
return isParentUser(user)
}
export const canCreateChores = (user) => {
// Both parent and child users can create chores
return user && (isParentUser(user) || isChildUser(user))
}
export const canManageCircle = (user) => {
// Only parent users can manage circle settings
return isParentUser(user)
}
export const canAccessAdminSettings = (user) => {
// Only parent users can access admin settings like API tokens, MFA, etc.
return isParentUser(user)
}
export const getUserDisplayInfo = (user) => {
if (!user) return { displayName: '', username: '', userType: 'unknown' }
return {
displayName: user.displayName || user.username,
username: user.username,
userType: isParentUser(user) ? 'parent' : isChildUser(user) ? 'child' : 'unknown',
parentUserId: user.parentUserId,
circleID: user.circleID,
}
}
export const getChildUsernameFromCombined = (combinedUsername) => {
// Extract child name from format: parent_child
const parts = combinedUsername.split('_')
if (parts.length >= 2) {
return parts.slice(1).join('_') // In case child name contains underscores
}
return combinedUsername
}
export const getParentUsernameFromCombined = (combinedUsername) => {
// Extract parent name from format: parent_child
const parts = combinedUsername.split('_')
return parts[0] || combinedUsername
}
export const buildChildUsername = (parentUsername, childName) => {
return `${parentUsername}_${childName}`
}

View File

@@ -1,16 +1,16 @@
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { useContext, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import Logo from '../../Logo'
import { apiManager } from '../../utils/TokenManager'
import { apiClient } from '../../utils/ApiClient'
import Cookies from 'js-cookie'
import { useRef } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { UserContext } from '../../contexts/UserContext'
import { useUserProfile } from '../../queries/UserQueries'
import { GetUserProfile } from '../../utils/Fetcher'
const AuthenticationLoading = () => {
const { userProfile, setUserProfile } = useContext(UserContext)
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const Navigate = useNavigate()
const hasCalledHandleOAuth2 = useRef(false)
const [message, setMessage] = useState('Authenticating')
@@ -29,15 +29,16 @@ const AuthenticationLoading = () => {
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
setUserProfile(data.res)
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
refetchUserProfile().then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
})
})
})
}
@@ -57,7 +58,7 @@ const AuthenticationLoading = () => {
}
if (code) {
const baseURL = apiManager.getApiURL()
const baseURL = apiClient.baseURL
fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST',
headers: {
@@ -70,8 +71,8 @@ const AuthenticationLoading = () => {
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {

View File

@@ -1,5 +1,4 @@
// create boilerplate for ResetPasswordView:
import LogoSVG from '@/assets/logo.svg'
import {
Box,
Button,
@@ -8,21 +7,20 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from './../../Config'
import { ResetPassword } from '../../utils/Fetcher'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ResetPassword } from '../../utils/Fetcher'
const ForgotPasswordView = () => {
const navigate = useNavigate()
// const [showLoginSnackbar, setShowLoginSnackbar] = useState(false)
// const [snackbarMessage, setSnackbarMessage] = useState('')
const [resetStatusOk, setResetStatusOk] = useState(null)
const [email, setEmail] = useState('')
const [emailError, setEmailError] = useState(null)
const { showError, showNotification } = useNotification()
const validateEmail = email => {
return !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(email)
@@ -48,12 +46,24 @@ const ForgotPasswordView = () => {
if (response.ok) {
setResetStatusOk(true)
// wait 3 seconds and then redirect to login:
showNotification({
type: 'success',
title: 'Reset Email Sent',
message: 'Check your email for password reset instructions',
})
} else {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
} catch (error) {
setResetStatusOk(false)
showError({
title: 'Reset Failed',
message: 'Failed to send reset email, please try again later',
})
}
}
@@ -67,19 +77,12 @@ const ForgotPasswordView = () => {
}
return (
<Container
component='main'
maxWidth='xs'
// make content center in the middle of the page:
>
<Container component='main' maxWidth='xs'>
<Box
sx={{
marginTop: 4,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
@@ -94,120 +97,111 @@ const ForgotPasswordView = () => {
padding: 2,
borderRadius: '8px',
boxShadow: 'md',
minHeight: '70vh',
justifyContent: 'space-between',
justifyItems: 'center',
}}
>
<Box>
<img src={LogoSVG} alt='logo' width='128px' height='128px' />
{/* <Logo /> */}
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
</Typography>
</Box>
{/* HERE */}
<Box sx={{ textAlign: 'center' }}></Box>
<Logo />
<Typography level='h2'>
Done
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
{resetStatusOk === null && (
<form onSubmit={handleSubmit}>
<div className='grid gap-6'>
<Typography level='body2' gutterBottom>
Enter your email, and we'll send you a link to get into your
account.
</Typography>
<FormControl error={emailError !== null}>
<Input
placeholder='Email'
type='email'
variant='soft'
fullWidth
size='lg'
value={email}
onChange={handleEmailChange}
error={emailError !== null}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<FormHelperText>{emailError}</FormHelperText>
</FormControl>
<Box>
<Button
variant='solid'
size='lg'
fullWidth
sx={{
mb: 1,
}}
onClick={handleSubmit}
>
Reset Password
</Button>
<Button
fullWidth
size='lg'
variant='soft'
sx={{
width: '100%',
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/login')
}}
color='neutral'
>
Back to Login
</Button>
</Box>
</div>
</form>
)}
{resetStatusOk != null && (
<>
<Box mt={-30}>
<Typography level='body-md'>
if there is an account associated with the email you entered,
you will receive an email with instructions on how to reset
your
</Typography>
</Box>
<Typography level='body2' sx={{ mb: 3 }}>
Enter your email, and we'll send you a link to get into your
account.
</Typography>
<Typography level='body2' alignSelf={'start'} mb={1}>
Email Address
</Typography>
<FormControl
error={emailError !== null}
sx={{ width: '100%', mb: 2 }}
>
<Input
margin='normal'
required
fullWidth
id='email'
placeholder='Enter your email address'
type='email'
name='email'
autoComplete='email'
autoFocus
value={email}
onChange={handleEmailChange}
error={emailError !== null}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleSubmit()
}
}}
/>
<FormHelperText>{emailError}</FormHelperText>
</FormControl>
<Button
variant='soft'
type='submit'
fullWidth
size='lg'
sx={{ position: 'relative', bottom: '0' }}
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={handleSubmit}
>
Reset Password
</Button>
<Button
type='submit'
fullWidth
size='lg'
variant='plain'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
navigate('/login')
}}
color='neutral'
>
Back to Login
</Button>
</>
)}
{resetStatusOk != null && (
<>
<Typography
level='body-md'
sx={{ textAlign: 'center', mt: 2, mb: 3 }}
>
If there is an account associated with the email you entered,
you will receive an email with instructions on how to reset your
password.
</Typography>
<Button
variant='solid'
size='lg'
fullWidth
onClick={() => {
navigate('/login')
}}
>
Go to Login
</Button>
</>
)}
<Snackbar
open={resetStatusOk ? resetStatusOk : resetStatusOk === false}
autoHideDuration={5000}
onClose={() => {
if (resetStatusOk) {
navigate('/login')
}
}}
>
{resetStatusOk
? 'Reset email sent, check your email'
: 'Reset email failed, try again later'}
</Snackbar>
</Sheet>
</Box>
</Container>

View File

@@ -1,23 +1,15 @@
import { Preferences } from '@capacitor/preferences'
import {
Box,
Button,
Container,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { API_URL } from '../../Config'
import Logo from '../../Logo'
import { apiManager } from '../../utils/TokenManager'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
const LoginSettings = () => {
const [error, setError] = React.useState(null)
const Navigate = useNavigate()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -112,14 +104,18 @@ const LoginSettings = () => {
return
}
if (!isValidServerURL()) {
setError('Invalid server URL')
showError({
title: 'Invalid Server URL',
message:
'Please enter a valid server URL with protocol (http:// or https://)',
})
return
}
Preferences.set({
key: 'customServerUrl',
value: serverURL,
}).then(() => {
apiManager.updateApiURL(serverURL + '/api/v1')
apiClient.customServerURL = serverURL + '/api/v1'
Navigate('/login')
})
}}
@@ -150,14 +146,6 @@ const LoginSettings = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -1,7 +1,10 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Device } from '@capacitor/device'
// import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'
import { SocialLogin } from '@capgo/capacitor-social-login'
import { Settings } from '@mui/icons-material'
import AppleIcon from '@mui/icons-material/Apple'
import GoogleIcon from '@mui/icons-material/Google'
import {
Avatar,
@@ -12,29 +15,55 @@ import {
IconButton,
Input,
Sheet,
Snackbar,
Tab,
TabList,
TabPanel,
Tabs,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import React, { useEffect } from 'react'
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 { UserContext } from '../../contexts/UserContext'
import { useAuth } from '../../hooks/useAuth.jsx'
import Logo from '../../Logo'
import { useResource } from '../../queries/ResourceQueries'
import { GetUserProfile, login } from '../../utils/Fetcher'
import { apiManager } from '../../utils/TokenManager'
import { useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
import { saveTokens } from '../../utils/TokenStorage'
import { buildChildUsername, getUserDisplayInfo } from '../../utils/UserHelpers'
import MFAVerificationModal from './MFAVerificationModal'
const LoginView = () => {
const { userProfile, setUserProfile } = React.useContext(UserContext)
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const [error, setError] = React.useState(null)
const [mfaModalOpen, setMfaModalOpen] = React.useState(false)
const [mfaSessionToken, setMfaSessionToken] = React.useState('')
// Use React Query client directly to invalidate the user profile query
const queryClient = useQueryClient()
// const [userProfile, setUserProfile] = useState(null)
const { data: userProfile } = useUserProfile()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const [isAppleSignInSupported, setIsAppleSignInSupported] = useState(false)
// Child login state
const [loginType, setLoginType] = useState('primary')
const [parentUsername, setParentUsername] = useState('')
const [childName, setChildName] = useState('')
// Clear fields when switching login modes
const handleLoginModeChange = (event, newValue) => {
setLoginType(newValue)
setUsername('')
setParentUsername('')
setChildName('')
setPassword('')
}
const { data: resource } = useResource()
const { showError } = useNotification()
const { isAuthenticated, login: authLogin, user } = useAuth()
const Navigate = useNavigate()
useEffect(() => {
const initializeSocialLogin = async () => {
@@ -45,81 +74,145 @@ const LoginView = () => {
mode: 'online', // replaces grantOfflineAccess
},
})
// Check if Apple Sign In is supported (iOS 13+)
if (Capacitor.isNativePlatform()) {
try {
const deviceInfo = await Device.getInfo()
if (deviceInfo.platform === 'ios') {
const majorVersion = parseInt(deviceInfo.osVersion.split('.')[0])
setIsAppleSignInSupported(majorVersion >= 13)
}
} catch (error) {
console.log(
'Could not determine device info for Apple Sign In support',
)
}
}
}
initializeSocialLogin()
}, [])
useEffect(() => {
if (isAuthenticated && user) {
setUserProfile(user)
Navigate('/chores')
}
}, [isAuthenticated, user, Navigate])
const handleSubmit = async e => {
e.preventDefault()
login(username, password)
.then(response => {
if (response.status === 200) {
return response.json().then(data => {
// Check if MFA is required
if (data.mfaRequired) {
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
return
}
// Normal login without MFA
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
})
} else if (response.status === 401) {
setError('Wrong username or password')
} else {
setError('An error occurred, please try again')
console.log('Login failed')
}
})
.catch(err => {
setError('Unable to communicate with server, please try again')
console.log('Login failed', err)
})
}
const loggedWithProvider = function (provider, data) {
const baseURL = apiManager.getApiURL()
const getAccessToken = data => {
if (data['access_token']) {
// data["access_token"] is for Google
return data['access_token']
} else if (data['accessToken']) {
// data["accessToken"] is for Google Capacitor
return data['accessToken']['token']
// Validation for child login
if (loginType === 'sub') {
if (!parentUsername.trim()) {
showError({
title: 'Validation Error',
message: 'Primary username is required for sub account login',
})
return
}
if (!childName.trim()) {
showError({
title: 'Validation Error',
message: 'Sub account name is required for sub account login',
})
return
}
} else {
if (!username.trim()) {
showError({
title: 'Validation Error',
message: 'Username is required',
})
return
}
}
return fetch(`${baseURL}/auth/${provider}/callback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
if (!password) {
showError({
title: 'Validation Error',
message: 'Password is required',
})
return
}
// Determine the actual username to send
const actualUsername =
loginType === 'sub'
? buildChildUsername(parentUsername, childName)
: username
const result = await authLogin({ username: actualUsername, password })
if (result.success) {
if (result.data?.mfaRequired) {
setMfaSessionToken(result.data.sessionToken)
setMfaModalOpen(true)
return
}
// Refetch user profile after successful login
queryClient.refetchQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl && redirectUrl !== '/') {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
} else {
showError({
title: 'Login Failed',
message: result.error || 'An error occurred, please try again',
})
}
}
const loggedWithProvider = async function (provider, data) {
const getAccessToken = data => {
if (data['access_token']) {
return data['access_token']
} else if (data['accessToken']) {
return data['accessToken']['token']
} else if (data['response'] && data['response']['id_token']) {
return data['response']['id_token']
} else if (data['id_token']) {
return data['id_token']
}
}
try {
const response = await apiClient.post(`/auth/${provider}/callback`, {
provider: provider,
token: getAccessToken(data),
data: data,
}),
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
// Check if MFA is required for OAuth login
if (data.mfaRequired) {
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
return
}
})
// Normal OAuth login without MFA
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
if (response.ok) {
const responseData = await response.json()
// Check if MFA is required for OAuth login
if (responseData.mfaRequired) {
setMfaSessionToken(responseData.sessionToken)
setMfaModalOpen(true)
return
}
// Use new auth system to handle token storage
if (responseData.token || responseData.access_token) {
const token = responseData.token || responseData.access_token
const expiry = responseData.expire || responseData.access_token_expiry
// Save all tokens including refresh tokens
await saveTokens({
accessToken: token,
accessTokenExpiry: expiry,
refreshToken: responseData.refresh_token,
refreshTokenExpiry: responseData.refresh_token_expiry,
})
// Refetch user profile after successful OAuth login
queryClient.invalidateQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
@@ -128,46 +221,65 @@ const LoginView = () => {
} else {
getUserProfileAndNavigateToHome()
}
}
} else {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Failed`,
message: `Couldn't log in with ${providerName}, please try again`,
})
}
return response.json().then(() => {
setError("Couldn't log in with Google, please try again")
} catch (error) {
const providerName = provider === 'apple' ? 'Apple' : 'Google'
showError({
title: `${providerName} Login Error`,
message: 'Network error occurred, please try again',
})
})
}
}
const getUserProfileAndNavigateToHome = () => {
GetUserProfile().then(data => {
data.json().then(data => {
setUserProfile(data.res)
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
}
})
// Refetch user profile after login using React Query
queryClient.invalidateQueries(['userProfile']).then(() => {
// check if redirect url is set in cookie:
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/chores')
}
})
}
const handleMFASuccess = data => {
localStorage.setItem('ca_token', data.token)
localStorage.setItem('ca_expiration', data.expire)
const handleMFASuccess = async data => {
// Save all tokens including refresh tokens
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
// Refetch user profile after MFA success
queryClient.invalidateQueries(['userProfile'])
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
Navigate('/my/chores')
Navigate('/chores')
}
}
const handleMFAError = errorMessage => {
setError(errorMessage)
showError({
title: 'Two-Factor Authentication Failed',
message: errorMessage,
})
}
const handleMFAClose = () => {
@@ -185,19 +297,52 @@ const LoginView = () => {
return randomState
}
const handleAuthentikLogin = () => {
const handleAuthentikLogin = async () => {
const authentikAuthorizeUrl = resource?.identity_provider?.auth_url
const state = generateRandomState()
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: `${window.location.origin}/auth/oauth2`,
scope: 'openid profile email', // Your scopes
state: generateRandomState(),
})
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
if (Capacitor.isNativePlatform()) {
// For mobile devices, use a custom URL scheme for the redirect
const redirectUri = 'donetick://auth/oauth2'
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: redirectUri,
scope: 'openid profile email',
state: state,
})
const authUrl = `${authentikAuthorizeUrl}?${params.toString()}`
console.log('Opening OAuth in browser:', authUrl)
try {
// Open OAuth flow in system browser
await Browser.open({ url: authUrl })
// Note: The OAuth callback will be handled by deep link handling
// You'll need to implement deep link handling to catch the redirect
// and extract the authorization code
} catch (error) {
console.error('Failed to open OAuth browser:', error)
showError({
title: 'OAuth Error',
message: 'Failed to open authentication browser',
})
}
} else {
// For web platforms, use the current approach
const params = new URLSearchParams({
response_type: 'code',
client_id: resource?.identity_provider?.client_id,
redirect_uri: `${window.location.origin}/auth/oauth2`,
scope: 'openid profile email',
state: state,
})
console.log('redirect', `${authentikAuthorizeUrl}?${params.toString()}`)
window.location.href = `${authentikAuthorizeUrl}?${params.toString()}`
}
}
return (
@@ -258,6 +403,16 @@ const LoginView = () => {
<Typography level='body-md' alignSelf={'center'}>
Welcome back,{' '}
{userProfile?.displayName || userProfile?.username}
{getUserDisplayInfo(userProfile).userType === 'child' && (
<Typography
component='span'
level='body-xs'
color='neutral'
sx={{ ml: 1 }}
>
(Sub Account)
</Typography>
)}
</Typography>
<Button
@@ -274,7 +429,6 @@ const LoginView = () => {
type='submit'
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
@@ -283,11 +437,7 @@ const LoginView = () => {
borderRadius: '8px',
}}
onClick={() => {
setUserProfile(null)
localStorage.removeItem('ca_token')
localStorage.removeItem('ca_expiration')
// go to login page:
window.location.href = '/login'
apiClient.handleLogout()
}}
>
Logout
@@ -296,27 +446,109 @@ const LoginView = () => {
)}
{!userProfile && (
<>
<Typography level='body2'>
<Typography level='body2' sx={{ mb: 3 }}>
Sign in to your account to continue
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='Email Address'
name='email'
autoComplete='email'
autoFocus
value={username}
onChange={e => {
setUsername(e.target.value)
}}
/>
<Typography level='body2' alignSelf={'start'}>
{/* Login Type Tabs */}
<Tabs
value={loginType}
onChange={handleLoginModeChange}
sx={{ width: '100%', mb: 3 }}
>
<TabList
sx={{
width: '100%',
p: 0.5,
borderBottom: 'none',
boxShadow: 'none',
'&::after': {
display: 'none',
},
}}
>
<Tab
value='primary'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Primary Account
</Tab>
<Tab
value='sub'
variant='plain'
sx={{
flex: 1,
borderRadius: '6px',
fontSize: '0.875rem',
fontWeight: 500,
}}
>
Sub Account
</Tab>
</TabList>
<TabPanel value='primary' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='email'
label='Email Address'
name='email'
autoComplete='email'
autoFocus
value={username}
onChange={e => {
setUsername(e.target.value)
}}
/>
</TabPanel>
<TabPanel value='sub' sx={{ p: 0, mt: 2 }}>
<Typography level='body2' alignSelf={'start'} mb={1}>
Primary Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='parentUsername'
name='parentUsername'
placeholder='Enter primary account username'
autoFocus
value={parentUsername}
onChange={e => {
setParentUsername(e.target.value)
}}
/>
<Typography level='body2' alignSelf={'start'} mt={1} mb={1}>
Sub Account Username
</Typography>
<Input
margin='normal'
required
fullWidth
id='childName'
name='childName'
placeholder='Enter sub account name'
value={childName}
onChange={e => {
setChildName(e.target.value)
}}
/>
</TabPanel>
</Tabs>
<Typography level='body2' alignSelf={'start'} mb={1}>
Password:
</Typography>
<Input
@@ -327,6 +559,7 @@ const LoginView = () => {
label='Password'
type='password'
id='password'
autoComplete='password'
value={password}
onChange={e => {
setPassword(e.target.value)
@@ -347,13 +580,12 @@ const LoginView = () => {
}}
onClick={handleSubmit}
>
Sign In
{loginType === 'sub' ? 'Sign In as Sub Account' : 'Sign In'}
</Button>
<Button
type='submit'
fullWidth
size='lg'
q
variant='plain'
sx={{
width: '100%',
@@ -382,8 +614,12 @@ const LoginView = () => {
onResolve={({ provider, data }) => {
loggedWithProvider(provider, data)
}}
onReject={err => {
setError("Couldn't log in with Google, please try again")
onReject={() => {
showError({
title: 'Google Login Failed',
message:
"Couldn't log in with Google, please try again",
})
}}
>
<Button
@@ -405,8 +641,50 @@ const LoginView = () => {
</div>
</Button>
</LoginSocialGoogle>
{/* <Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mt: 1,
mb: 1,
backgroundColor: 'black',
color: 'white',
'&:hover': {
backgroundColor: '#333',
},
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button> */}
</Box>
)}
{Capacitor.isNativePlatform() && (
<Box sx={{ width: '100%' }}>
<Button
@@ -415,16 +693,6 @@ const LoginView = () => {
size='lg'
sx={{ mt: 3, mb: 2 }}
onClick={() => {
// GoogleAuth.initialize({
// clientId: import.meta.env.VITE_APP_GOOGLE_CLIENT_ID,
// scopes: ['profile', 'email', 'openid'],
// grantOfflineAccess: true,
// })
// GoogleAuth.signIn().then(user => {
// console.log('Google user', user)
// loggedWithProvider('google', user.authentication)
// })
SocialLogin.login({
provider: 'google',
options: { scopes: ['profile', 'email', 'openid'] },
@@ -439,6 +707,45 @@ const LoginView = () => {
Continue with Google
</div>
</Button>
{/* Apple Sign In Button for Native Platforms */}
{isAppleSignInSupported && (
<Button
fullWidth
variant='soft'
color='neutral'
size='lg'
sx={{
mb: 1,
}}
onClick={() => {
SocialLogin.login({
provider: 'apple',
options: {
scopes: ['email', 'name'],
state: 'random_string',
},
})
.then(user => {
console.log('Apple user', user)
loggedWithProvider('apple', user)
})
.catch(error => {
console.error('Apple login error:', error)
showError({
title: 'Apple Login Failed',
message:
"Couldn't log in with Apple, please try again",
})
})
}}
>
<div className='flex gap-2'>
<AppleIcon />
Continue with Apple
</div>
</Button>
)}
</Box>
)}
</>
@@ -467,16 +774,31 @@ const LoginView = () => {
>
Create new account
</Button>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={3000}
message={error}
>
{error}
</Snackbar>
<MFAVerificationModal
open={mfaModalOpen}

View File

@@ -5,13 +5,13 @@ import {
Button,
Input,
Link,
Modal,
ModalClose,
ModalDialog,
Stack,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { VerifyMFA } from '../../utils/Fetcher'
const MFAVerificationModal = ({
@@ -25,7 +25,7 @@ const MFAVerificationModal = ({
const [isBackupCode, setIsBackupCode] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const { ResponsiveModal } = useResponsiveModal()
const handleVerify = async () => {
if (!verificationCode.trim()) {
setError('Please enter a verification code')
@@ -70,90 +70,88 @@ const MFAVerificationModal = ({
}
return (
<Modal open={open} onClose={handleClose}>
<ModalDialog size='sm' sx={{ maxWidth: 400 }}>
<ModalClose />
<ResponsiveModal open={open} onClose={handleClose} size='sm'>
<ModalClose />
<Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='h4' sx={{ mb: 1 }}>
Two-Factor Authentication
</Typography>
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app
<Box className='mb-4 text-center'>
<Security sx={{ fontSize: 48, color: 'primary.main', mb: 2 }} />
<Typography level='h4' sx={{ mb: 1 }}>
Two-Factor Authentication
</Typography>
<Typography level='body-md' sx={{ color: 'text.secondary' }}>
Enter the verification code from your authenticator app
</Typography>
</Box>
<Stack spacing={3}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
<Stack spacing={3}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{isBackupCode ? 'Backup Code' : 'Verification Code'}
</Typography>
<Input
placeholder={
isBackupCode ? 'Enter backup code' : 'Enter 6-digit code'
}
value={verificationCode}
onChange={e => setVerificationCode(e.target.value)}
onKeyPress={handleKeyPress}
sx={{
textAlign: 'center',
fontSize: '1.1em',
letterSpacing: isBackupCode ? 'normal' : '0.1em',
}}
slotProps={{
input: {
maxLength: isBackupCode ? 50 : 6,
pattern: isBackupCode ? undefined : '[0-9]*',
},
}}
startDecorator={<Smartphone />}
autoFocus
/>
</Box>
{error && (
<Alert color='danger' size='sm'>
{error}
</Alert>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
{error && (
<Alert color='danger' size='sm'>
{error}
</Alert>
</Stack>
</ModalDialog>
</Modal>
)}
<Button
color='primary'
loading={loading}
onClick={handleVerify}
disabled={!verificationCode.trim()}
size='lg'
>
Verify & Sign In
</Button>
<Box className='text-center'>
<Link
component='button'
type='button'
onClick={() => {
setIsBackupCode(!isBackupCode)
setVerificationCode('')
setError('')
}}
sx={{ fontSize: 'sm' }}
>
{isBackupCode
? 'Use authenticator app instead'
: "Can't access your authenticator? Use a backup code"}
</Link>
</Box>
<Alert color='neutral' size='sm'>
<Typography level='body-xs'>
Having trouble? Make sure your authenticator app is synced and try
again. Each backup code can only be used once.
</Typography>
</Alert>
</Stack>
</ResponsiveModal>
)
}

View File

@@ -7,38 +7,38 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import React from 'react'
import { useNavigate } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { login, signUp } from '../../utils/Fetcher'
const SignupView = () => {
const [username, setUsername] = React.useState('')
const [password, setPassword] = React.useState('')
const Navigate = useNavigate()
const queryClient = useQueryClient()
const [displayName, setDisplayName] = React.useState('')
const [email, setEmail] = React.useState('')
const [usernameError, setUsernameError] = React.useState('')
const [passwordError, setPasswordError] = React.useState('')
const [emailError, setEmailError] = React.useState('')
const [displayNameError, setDisplayNameError] = React.useState('')
const [error, setError] = React.useState(null)
const [snackbarOpen, setSnackbarOpen] = React.useState(false)
const [snackbarMessage, setSnackbarMessage] = React.useState('')
const { showError } = useNotification()
const handleLogin = (username, password) => {
login(username, password).then(response => {
if (response.status === 200) {
response.json().then(res => {
localStorage.setItem('ca_token', res.token)
localStorage.setItem('ca_expiration', res.expire)
setTimeout(() => {
// TODO: not sure if there is a race condition here
// but on first sign up it renavigates to login.
Navigate('/my/chores')
}, 500)
localStorage.setItem('token', res.token)
localStorage.setItem('token_expiry', res.expire)
// Invalidate user profile queries to ensure fresh data
queryClient.invalidateQueries(['userProfile'])
Navigate('/chores')
})
} else {
console.log('Login failed', response)
@@ -85,10 +85,10 @@ const SignupView = () => {
isValid = false
}
// username should only contain letters , numbers , dot and dash:
if (!/^[a-zA-Z0-9.-]+$/.test(username)) {
// username should only contain lowercase letters, dot and dash:
if (!/^[a-z.-]+$/.test(username)) {
setUsernameError(
'Username can only contain letters, numbers, dot and dash',
'Username can only contain lowercase letters, dot and dash',
)
isValid = false
}
@@ -104,11 +104,17 @@ const SignupView = () => {
if (response.status === 201) {
handleLogin(username, password)
} else if (response.status === 403) {
setError('Signup disabled, please contact admin')
showError({
title: 'Signup Failed',
message: 'Signup disabled, please contact admin',
})
} else {
console.log('Signup failed')
response.json().then(res => {
setError(res.error)
showError({
title: 'Signup Failed',
message: res.error || 'An error occurred during signup',
})
})
}
})
@@ -240,12 +246,18 @@ const SignupView = () => {
<FormControl error={displayNameError}>
<FormHelperText>{displayNameError}</FormHelperText>
</FormControl>
<Typography
level='body2'
sx={{ mt: 2, mb: 1, textAlign: 'center', color: 'text.secondary' }}
>
By signing up, you agree to our Terms of Service and Privacy Policy
</Typography>
<Button
// type='submit'
size='lg'
fullWidth
variant='solid'
sx={{ mt: 3, mb: 1 }}
sx={{ mt: 1, mb: 1 }}
onClick={handleSubmit}
>
Sign Up
@@ -262,16 +274,31 @@ const SignupView = () => {
>
Login
</Button>
<Box
sx={{ display: 'flex', justifyContent: 'center', gap: 2, mt: 2 }}
>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/privacy-policy', '_blank')
}}
>
Privacy Policy
</Button>
<Button
variant='plain'
size='sm'
onClick={() => {
window.open('https://donetick.com/terms', '_blank')
}}
>
Terms of Use
</Button>
</Box>
</Sheet>
</Box>
<Snackbar
open={error !== null}
onClose={() => setError(null)}
autoHideDuration={5000}
message={error}
>
{error}
</Snackbar>
</Container>
)
}

View File

@@ -7,13 +7,13 @@ import {
FormHelperText,
Input,
Sheet,
Snackbar,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import Logo from '../../Logo'
import { useNotification } from '../../service/NotificationProvider'
import { ChangePassword } from '../../utils/Fetcher'
const UpdatePasswordView = () => {
@@ -24,8 +24,7 @@ const UpdatePasswordView = () => {
const [passworConfirmationError, setPasswordConfirmationError] =
useState(null)
const [searchParams] = useSearchParams()
const [updateStatusOk, setUpdateStatusOk] = useState(null)
const { showError, showNotification } = useNotification()
const verifiticationCode = searchParams.get('c')
@@ -55,16 +54,27 @@ const UpdatePasswordView = () => {
const response = await ChangePassword(verifiticationCode, password)
if (response.ok) {
setUpdateStatusOk(true)
showNotification({
type: 'success',
title: 'Password Updated',
message:
'Your password has been updated successfully. Redirecting to login...',
})
// wait 3 seconds and then redirect to login:
setTimeout(() => {
navigate('/login')
}, 3000)
} else {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
} catch (error) {
setUpdateStatusOk(false)
showError({
title: 'Password Update Failed',
message: 'Failed to update password, please try again later',
})
}
}
return (
@@ -169,15 +179,6 @@ const UpdatePasswordView = () => {
</Button>
</Sheet>
</Box>
<Snackbar
open={updateStatusOk === false}
autoHideDuration={6000}
onClose={() => {
setUpdateStatusOk(null)
}}
>
Password update failed, try again later
</Snackbar>
</Container>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import {
Archive,
CalendarMonth,
CancelScheduleSend,
Check,
@@ -6,11 +7,16 @@ import {
CloseFullscreen,
Edit,
History,
HourglassEmpty,
LowPriority,
OpenInFull,
PeopleAlt,
Person,
PlayArrow,
SwitchAccessShortcut,
ThumbDown,
ThumbUp,
Unarchive,
} from '@mui/icons-material'
import {
Box,
@@ -33,26 +39,40 @@ import {
Typography,
} from '@mui/joy'
import { Divider } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import moment from 'moment'
import { useEffect, useState } from 'react'
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
import { useCircleMembers } from '../../queries/UserQueries.jsx'
import { notInCompletionWindow } from '../../utils/Chores.jsx'
import {
useChoreTimer,
useDeleteTimeSession,
usePauseChore,
useResetChoreTimer,
useStartChore,
} from '../../queries/TimeQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import {
ApproveChore,
GetChoreDetailById,
MarkChoreComplete,
RejectChore,
SkipChore,
UnArchiveChore,
UpdateChorePriority,
} from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
const ChoreView = () => {
const [chore, setChore] = useState({})
@@ -61,6 +81,7 @@ const ChoreView = () => {
const [infoCards, setInfoCards] = useState([])
const { choreId } = useParams()
const [note, setNote] = useState(null)
const queryClient = useQueryClient()
const [searchParams] = useSearchParams()
@@ -68,21 +89,25 @@ const ChoreView = () => {
const [timeoutId, setTimeoutId] = useState(null)
const [secondsLeftToCancel, setSecondsLeftToCancel] = useState(null)
const [completedDate, setCompletedDate] = useState(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [confirmModelConfig, setConfirmModelConfig] = useState({
isOpen: false,
})
const [chorePriority, setChorePriority] = useState(null)
const [isDescriptionOpen, setIsDescriptionOpen] = useState(false)
const {
data: circleMembersData,
isLoading: isCircleMembersLoading,
handleRefetch: handleCircleMembersRefetch,
} = useCircleMembers()
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
const {
data: choreData,
isLoading: isChoreLoading,
refetch: refetchChore,
} = useChoreDetails(choreId)
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const startChore = useStartChore()
const pauseChore = usePauseChore()
const deleteTimeSession = useDeleteTimeSession()
const resetChoreTimer = useResetChoreTimer()
const { data: choreTimer } = useChoreTimer(choreId)
useEffect(() => {
if (!choreData || !choreData.res || !circleMembersData) {
@@ -107,8 +132,10 @@ const ChoreView = () => {
const handleUpdatePriority = priority => {
UpdateChorePriority(choreId, priority.value).then(response => {
if (response.ok) {
response.json().then(data => {
response.json().then(() => {
setChorePriority(priority)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
@@ -120,11 +147,13 @@ const ChoreView = () => {
icon: <PeopleAlt />,
title: 'Assignment',
text: `Assigned: ${
performers.find(p => p.id === chore.assignedTo)?.displayName || 'N/A'
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
'N/A'
}`,
subtext: ` Last: ${
chore.lastCompletedDate
? performers.find(p => p.id === chore.lastCompletedBy)?.displayName
? performers.find(p => p.userId === chore.lastCompletedBy)
?.displayName
: '--'
}`,
},
@@ -152,7 +181,8 @@ const ChoreView = () => {
icon: <Person />,
title: 'Details',
subtext: `Created By: ${
performers.find(p => p.id === chore.createdBy)?.displayName || 'N/A'
performers.find(p => p.userId === chore.createdBy)?.displayName ||
'N/A'
}`,
},
]
@@ -195,6 +225,8 @@ const ChoreView = () => {
clearInterval(countdownInterval) // Ensure to clear this interval as well
setTimeoutId(null)
setSecondsLeftToCancel(null)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
.then(() => {
// refetch the chore details
@@ -216,10 +248,145 @@ const ChoreView = () => {
response.json().then(data => {
const newChore = data.res
setChore(newChore)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleChoreStart = () => {
startChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
const handleChorePause = () => {
pauseChore.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
const handleResetTimer = () => {
setTimerActionConfig({
isOpen: true,
title: 'Reset Timer',
message:
'Are you sure you want to reset the timer? This will clear all time records since you started the task.',
confirmText: 'Reset Timer',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
resetChoreTimer.mutate(choreId, {
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
})
}
setTimerActionConfig({})
},
})
}
const handleClearAllTime = () => {
setTimerActionConfig({
isOpen: true,
title: 'Clear All Time Records',
message:
'This will permanently delete all timers for this task and set it back to "not started".',
confirmText: 'Clear All Time',
cancelText: 'Cancel',
onClose: async confirmed => {
if (confirmed) {
if (choreTimer?.res?.id) {
deleteTimeSession.mutate(
{ choreId, sessionId: choreTimer.res.id },
{
onSuccess: data => {
const newChore = {
...chore,
...data.res,
}
setChore(newChore)
},
},
)
}
}
setTimerActionConfig({})
},
})
}
const handleApproveChore = () => {
ApproveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleRejectChore = () => {
RejectChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore(data.res)
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
const handleUnarchiveChore = () => {
UnArchiveChore(choreId).then(response => {
if (response.ok) {
response.json().then(data => {
setChore({ ...chore, isActive: true })
// Invalidate chores cache to refetch data
queryClient.invalidateQueries(['chores'])
})
}
})
}
// Check if the current user can approve/reject (admin, manager, or task owner)
const canApproveReject = () => {
if (!circleMembersData?.res || !chore) return false
const currentUser = circleMembersData.res.find(
member => member.userId === (impersonatedUser?.userId || userProfile?.id),
)
// User can approve/reject if they are:
// 1. Admin or manager of the circle
// 2. Owner/creator of the task
return (
currentUser?.role === 'admin' ||
currentUser?.role === 'manager' ||
chore.createdBy === (impersonatedUser?.userId || userProfile?.id)
)
}
if (isChoreLoading || isCircleMembersLoading) {
// while loading the chore or circle members, return a loading state
return <LoadingComponent />
@@ -256,6 +423,16 @@ const ChoreView = () => {
>
{chore.name}
</Typography>
{chore.isActive === false && (
<Chip
startDecorator={<Archive />}
size='md'
color='warning'
sx={{ mb: 1 }}
>
Archived
</Chip>
)}
<Chip startDecorator={<CalendarMonth />} size='md' sx={{ mb: 1 }}>
{chore.nextDueDate
? `Due at ${moment(chore.nextDueDate).format('MM/DD/YYYY hh:mm A')}`
@@ -296,8 +473,23 @@ const ChoreView = () => {
mb: 1,
}}
>
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) && (
<Grid xs={12}>
<TimePassedCard
chore={chore}
handleAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
/>
</Grid>
)}
{infoCards.map((card, index) => (
<Grid item xs={6} sm={6} key={index}>
<Grid xs={6} sm={6} key={index}>
<Card
variant='soft'
sx={{
@@ -306,6 +498,7 @@ const ChoreView = () => {
px: 2,
py: 1,
minHeight: 90,
height: '100%',
// change from space-between to start:
justifyContent: 'start',
}}
@@ -363,6 +556,7 @@ const ChoreView = () => {
>
<Dropdown>
<MenuButton
disabled={chore.isActive === false}
color={
chorePriority?.name === 'P1'
? 'danger'
@@ -375,8 +569,8 @@ const ChoreView = () => {
alignItems: 'center',
justifyContent: 'center',
p: 1,
width: '100%',
}}
fullWidth
variant='plain'
>
{chorePriority ? chorePriority.icon : <LowPriority />}
@@ -423,6 +617,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
onClick={() => {
navigate(`/chores/${choreId}/history`)
}}
@@ -441,6 +636,7 @@ const ChoreView = () => {
color='neutral'
variant='plain'
fullWidth
disabled={chore.isActive === false}
sx={{
// top right of the card:
flexDirection: 'column',
@@ -525,6 +721,7 @@ const ChoreView = () => {
>
<SubTasks
editMode={false}
performers={performers}
tasks={chore.subTasks}
setTasks={tasks => {
setChore({
@@ -544,17 +741,19 @@ const ChoreView = () => {
p: 2,
borderRadius: 'md',
boxShadow: 'sm',
paddingBottom: getSafeBottomPadding(2, '8px'),
}}
variant='soft'
>
<Typography level='body-md' sx={{ mb: 1 }}>
Complete the task
Task Actions
</Typography>
<FormControl size='sm'>
<Checkbox
checked={note !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setNote('')
@@ -571,35 +770,30 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Add Additional Notes
Add a note
</Typography>
}
/>
</FormControl>
{note !== null && (
<Input
fullWidth
multiline
label='Additional Notes'
placeholder='note or information about the task'
value={note || ''}
onChange={e => {
if (e.target.value.trim() === '') {
setNote(null)
return
}
setNote(e.target.value)
}}
sx={{
mb: 1,
}}
/>
<Box sx={{ mb: 1 }}>
<Typography level='body-sm' sx={{ mb: 1 }}>
Additional Notes:
</Typography>
<RichTextEditor
value={note || ''}
onChange={setNote}
entityType={'chore_completion_note'}
placeholder='Add a note about the completion...'
/>
</Box>
)}
<FormControl size='sm'>
<Checkbox
checked={completedDate !== null}
size='lg'
disabled={chore.isActive === false}
onChange={e => {
if (e.target.checked) {
setCompletedDate(
@@ -624,7 +818,7 @@ const ChoreView = () => {
alignItems: 'center',
}}
>
Specify completion date
Set custom completion time
</Typography>
}
/>
@@ -640,65 +834,188 @@ const ChoreView = () => {
/>
)}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
{chore.isActive === false ? (
// Archived chore - only show unarchive button
<Box
sx={{
flex: 4,
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={handleUnarchiveChore}
color='primary'
startDecorator={<Unarchive />}
>
Unarchive
</Button>
</Box>
) : (
// Active chore - show all normal actions
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
}}
>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
gap: 1,
alignContent: 'center',
justifyContent: 'center',
mb: 1,
}}
>
{chore.status === 3 ? (
// Pending approval: Show approve/reject for admins/managers/owners, grayed out button for others
canApproveReject() ? (
<>
<Button
fullWidth
size='lg'
onClick={handleApproveChore}
color='success'
startDecorator={<ThumbUp />}
sx={{
flex: 1,
}}
>
Approve
</Button>
<Button
fullWidth
size='lg'
onClick={handleRejectChore}
color='danger'
startDecorator={<ThumbDown />}
sx={{
flex: 1,
}}
>
<Box>Reject</Box>
</Button>
</>
) : (
<Button
fullWidth
size='lg'
disabled={true}
color='neutral'
startDecorator={<HourglassEmpty />}
>
<Box>Pending Approval</Box>
</Button>
)
) : (
// Normal completion flow
<>
<Button
fullWidth
size='lg'
onClick={handleTaskCompletion}
disabled={
isPendingCompletion ||
notInCompletionWindow(chore) ||
(chore.lastCompletedDate !== null &&
chore.frequencyType === 'once')
}
color={isPendingCompletion ? 'danger' : 'success'}
startDecorator={<Check />}
sx={{
flex: 4,
}}
>
<Box>Mark as done</Box>
</Button>
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
<Button
fullWidth
size='lg'
onClick={() => {
setConfirmModelConfig({
isOpen: true,
title: 'Skip Task',
message: 'Are you sure you want to skip this task?',
message: 'Are you sure you want to skip this task?',
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
confirmText: 'Skip',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed) {
handleSkippingTask()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Skip</Box>
</Button>
</>
)}
</Box>
{/* Timer Button - Show split button when timer is active, regular button otherwise */}
{[ChoreStatus.ACTIVE, ChoreStatus.PAUSED].includes(chore.status) ? (
<TimerSplitButton
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
chore={chore}
onAction={action => {
if (action === 'pause') {
handleChorePause()
} else if (action === 'resume') {
handleChoreStart()
}
setConfirmModelConfig({})
},
})
}}
disabled={
chore.lastCompletedDate !== null && chore.frequencyType === 'once'
}
startDecorator={<SwitchAccessShortcut />}
sx={{
flex: 1,
}}
>
<Box>Skip</Box>
</Button>
</Box>
}}
onShowDetails={() => navigate(`/chores/${choreId}/timer`)}
onResetTimer={handleResetTimer}
onClearAllTime={handleClearAllTime}
fullWidth
/>
) : chore.status === ChoreStatus.PENDING_APPROVAL ? (
<></>
) : (
<Button
size='lg'
onClick={() => {
handleChoreStart()
}}
variant='soft'
color='success'
disabled={
chore.lastCompletedDate !== null &&
chore.frequencyType === 'once'
}
startDecorator={<PlayArrow />}
sx={{
flex: 1,
}}
>
Start
</Button>
)}
</Box>
)}
<Snackbar
open={isPendingCompletion}
@@ -726,6 +1043,7 @@ const ChoreView = () => {
</Typography>
</Snackbar>
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
</Card>
</Container>
)

View File

@@ -17,8 +17,9 @@ import {
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useContext, useEffect, useState } from 'react'
import { UserContext } from '../../contexts/UserContext'
import { useEffect } from 'react'
import { useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
import ThingTriggerSection from './ThingTriggerSection'
@@ -61,6 +62,51 @@ const DAYS = [
'saturday',
'sunday',
]
const WEEK_PATTERNS = {
every_week: 'Every week',
week_of_month: 'Specific occurrences in the month',
}
const DAY_OCCURRENCE_OPTIONS = [
{ value: 1, label: '1st occurrence' },
{ value: 2, label: '2nd occurrence' },
{ value: 3, label: '3rd occurrence' },
{ value: 4, label: '4th occurrence' },
{ value: -1, label: 'Last occurrence' },
]
// Helper function to generate schedule preview text
const generateSchedulePreview = metadata => {
if (!metadata?.days?.length) return ''
const dayNames = metadata.days
.map(day => day.charAt(0).toUpperCase() + day.slice(1, 3))
.join(', ')
const timeStr = metadata.time
? moment(metadata.time).format('h:mm A')
: '6:00 PM'
if (metadata.weekPattern === 'every_week' || !metadata.weekPattern) {
return `Every ${dayNames} at ${timeStr}`
}
if (
metadata.weekPattern === 'week_of_month' &&
metadata.occurrences?.length
) {
const occurrenceStr = metadata.occurrences
.map(w => {
if (w === -1) return 'last'
return `${w}${w === 1 ? 'st' : w === 2 ? 'nd' : w === 3 ? 'rd' : 'th'}`
})
.join(', ')
return `Every ${occurrenceStr} ${dayNames} of the month at ${timeStr}`
}
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
frequencyType,
frequency,
@@ -68,7 +114,6 @@ const RepeatOnSections = ({
frequencyMetadata,
onFrequencyMetadataUpdate,
}) => {
const [intervalUnit, setIntervalUnit] = useState('days')
// if time on frequencyMetadata is not set, try to set it to the nextDueDate if available,
// otherwise set it to 18:00 of the current day
useEffect(() => {
@@ -77,13 +122,30 @@ const RepeatOnSections = ({
moment(new Date()).format('YYYY-MM-DD') + 'T' + '18:00',
).format()
}
}, [frequencyMetadata])
// Initialize weekPattern if not set
if (!frequencyMetadata?.weekPattern) {
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: 'every_week',
occurrences: [],
})
}
}, [frequencyMetadata, onFrequencyMetadataUpdate])
const timePickerComponent = (
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
<Typography level='h5'>At: </Typography>
<Grid
item
sm={12}
sx={{
display: 'flex',
direction: 'column',
flexDirection: 'column',
}}
>
<Typography level='h5'>Time of day: </Typography>
<Input
type='time'
sx={{ width: '150px' }}
defaultValue={moment(frequencyMetadata?.time).format('HH:mm')}
onChange={e => {
onFrequencyMetadataUpdate({
@@ -117,13 +179,16 @@ const RepeatOnSections = ({
onFrequencyUpdate(e.target.value)
}}
/>
<Select placeholder='Unit' value={intervalUnit}>
<Select
placeholder='Unit'
value={frequencyMetadata?.unit || 'days'}
sx={{ ml: 1 }}
>
{['hours', 'days', 'weeks', 'months', 'years'].map(item => (
<Option
key={item}
value={item}
onClick={() => {
setIntervalUnit(item)
onFrequencyMetadataUpdate({
...frequencyMetadata,
unit: item,
@@ -189,6 +254,8 @@ const RepeatOnSections = ({
onFrequencyMetadataUpdate({
...frequencyMetadata,
days: [],
weekPattern: 'every_week',
occurrences: [],
})
} else {
onFrequencyMetadataUpdate({
@@ -206,6 +273,140 @@ const RepeatOnSections = ({
</Button>
</Card>
</Grid>
<Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
<Box>
<RadioGroup
value={frequencyMetadata?.weekPattern || 'every_week'}
onChange={event => {
const newPattern = event.target.value
onFrequencyMetadataUpdate({
...frequencyMetadata,
weekPattern: newPattern,
occurrences:
newPattern === 'every_week'
? []
: frequencyMetadata?.occurrences || [],
})
}}
sx={{ gap: 1, '& > div': { p: 1 } }}
>
{Object.entries(WEEK_PATTERNS).map(([value, label]) => (
<FormControl key={value}>
<Radio value={value} label={label} variant='soft' />
{value === 'every_week' && (
<FormHelperText>
Task repeats every week on selected days
</FormHelperText>
)}
{value === 'week_of_month' && (
<FormHelperText>
Task repeats on specific day occurrences each month
(e.g., 1st Monday, 3rd Friday)
</FormHelperText>
)}
</FormControl>
))}
</RadioGroup>
{frequencyMetadata?.weekPattern === 'week_of_month' && (
<Box mt={2}>
<Typography level='body-sm' mb={1}>
Select which occurrences of the selected days:
</Typography>
<Typography level='body-xs' color='neutral' mb={2}>
Example: "1st Monday" means the first Monday of each month
</Typography>
<Card>
<List
orientation='horizontal'
wrap
sx={{
'--List-gap': '8px',
'--ListItem-radius': '20px',
}}
>
{DAY_OCCURRENCE_OPTIONS.map(option => (
<ListItem key={option.value}>
<Checkbox
checked={
frequencyMetadata?.occurrences?.includes(
option.value,
) || false
}
onChange={() => {
const currentOccurrences =
frequencyMetadata?.occurrences || []
const newOccurrences =
currentOccurrences.includes(option.value)
? currentOccurrences.filter(
w => w !== option.value,
)
: [...currentOccurrences, option.value]
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: newOccurrences.sort((a, b) => {
if (a === -1) return 1 // Last occurrence goes to end
if (b === -1) return -1
return a - b
}),
})
}}
overlay
disableIcon
variant='soft'
label={option.label}
/>
</ListItem>
))}
</List>
<Button
size='sm'
variant='soft'
color='neutral'
onClick={() => {
if (
frequencyMetadata?.occurrences?.length ===
DAY_OCCURRENCE_OPTIONS.length
) {
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: [],
})
} else {
onFrequencyMetadataUpdate({
...frequencyMetadata,
occurrences: DAY_OCCURRENCE_OPTIONS.map(
option => option.value,
),
})
}
}}
overlay
disableIcon
>
{frequencyMetadata?.occurrences?.length ===
DAY_OCCURRENCE_OPTIONS.length
? 'Unselect All'
: 'Select All'}
</Button>
</Card>
</Box>
)}
{/* Quarter week pattern removed - doesn't make sense with Nth day approach */}
{/* Live Preview */}
{frequencyMetadata?.days?.length > 0 && (
<Card mt={2} p={2}>
<Typography level='body-sm' color='primary'>
{generateSchedulePreview(frequencyMetadata)}
</Typography>
</Card>
)}
</Box>
</Grid>
{timePickerComponent}
</>
)
@@ -337,10 +538,11 @@ const RepeatSection = ({
isAttemptToSave,
selectedThing,
}) => {
const { userProfile } = useContext(UserContext)
const { data: userProfile } = useUserProfile()
return (
<Box mt={2}>
<Typography level='h4'>Repeat :</Typography>
<Typography level='h4'>Repeat:</Typography>
<FormControl sx={{ mt: 1 }}>
<Checkbox
onChange={e => {
@@ -457,6 +659,8 @@ const RepeatSection = ({
onFrequencyMetadataUpdate({
...frequencyMetadata,
days: [],
weekPattern: 'every_week',
weekNumbers: [],
})
} else if (item === 'day_of_the_month') {
onFrequencyMetadataUpdate({

View File

@@ -7,9 +7,6 @@ import {
Chip,
FormControl,
Input,
ListItem,
ListItemContent,
ListItemDecorator,
Option,
Select,
TextField,
@@ -113,7 +110,7 @@ const ThingTriggerSection = ({
onChange={(e, newValue) => setSelectedThing(newValue)}
getOptionLabel={option => option.name}
renderOption={(props, option) => (
<ListItem {...props}>
<Box {...props}>
<Box
sx={{
display: 'flex',
@@ -123,19 +120,19 @@ const ThingTriggerSection = ({
p: 1,
}}
>
<ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
<Box sx={{ alignSelf: 'flex-start' }}>
<Typography level='body-lg' textColor='primary'>
{option.name}
</Typography>
</ListItemDecorator>
<ListItemContent>
</Box>
<Box>
<Typography level='body2' textColor='text.secondary'>
<Chip>type: {option.type}</Chip>{' '}
<Chip>state: {option.state}</Chip>
</Typography>
</ListItemContent>
</Box>
</Box>
</ListItem>
</Box>
)}
renderInput={params => (
<TextField {...params} label='Select a thing' />

View File

@@ -0,0 +1,233 @@
import {
Flag,
OpenInFull,
Pause,
PlayArrow,
Schedule,
} from '@mui/icons-material'
import { Box, Card, Chip, Typography } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
const TimePassedCard = ({ chore, handleAction, onShowDetails }) => {
const navigate = useNavigate()
const [time, setTime] = useState(0)
const [shouldAnimate, setShouldAnimate] = useState(false)
const [prevStatus, setPrevStatus] = useState(null) // Initialize as null
const intervalRef = useRef(null)
// Track status changes to trigger animation
useEffect(() => {
// Only trigger animation if we have a previous status and it changed from 0 to 1
if (prevStatus !== null && prevStatus === 0 && chore.status === 1) {
setShouldAnimate(true)
// Reset animation after it completes
const timer = setTimeout(() => setShouldAnimate(false), 300)
return () => clearTimeout(timer)
}
setPrevStatus(chore.status)
}, [chore.status, prevStatus])
// Single effect to handle both time calculation and timer
useEffect(() => {
// Calculate current time based on chore data
const calculateCurrentTime = () => {
if (chore.timerUpdatedAt && chore.status === 1) {
// Active session: base duration + time since start
const timeSinceStart = Math.floor(
(Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000,
)
return timeSinceStart + (chore.duration || 0)
}
// Not active: just return accumulated duration
return chore.duration || 0
}
// Clear any existing timer first
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
// Set initial time
const currentTime = calculateCurrentTime()
setTime(currentTime)
// Handle timer based on status
if (chore.status === 1) {
// Active: start interval timer
intervalRef.current = setInterval(() => {
const newTime = calculateCurrentTime()
setTime(newTime)
}, 1000)
}
// Cleanup function
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [chore.status, chore.duration, chore.timerUpdatedAt])
const formatTime = seconds => {
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
gap: 0,
px: 2,
py: 1,
height: '75px',
alignItems: 'center',
...(shouldAnimate && {
animation: 'slideInUp 0.3s ease-out',
}),
'@keyframes slideInUp': {
'0%': {
opacity: 0,
transform: 'translateY(20px) scale(0.95)',
},
'100%': {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
},
transition: 'all 0.3s ease',
cursor: 'pointer',
}}
onClick={e => {
// if this click on this element itself and not its children:
if (e.target !== e.currentTarget) return
navigate('./timer')
}}
>
<OpenInFull
sx={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 2,
cursor: 'pointer',
color: 'text.secondary',
fontSize: '15px',
'&:hover': { color: 'primary.main' },
}}
onClick={e => {
e.stopPropagation()
navigate('./timer')
}}
></OpenInFull>
<Typography
level='h4'
sx={{
fontWeight: 600,
color: chore.status === 1 ? 'success.main' : 'text.primary',
// mb: 0.5,
mb: 0.5,
transition: 'all 0.3s ease',
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
cursor: 'pointer',
'&:hover': {
textDecoration: 'underline',
},
}}
onClick={() => onShowDetails?.()}
>
{formatTime(time)}
</Typography>
{/* Status and info section */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0 }}>
{/* Show start time and user if active */}
{chore.status === 1 ? (
<Chip
variant='soft'
color='warning'
size='md'
startDecorator={<Pause sx={{ fontSize: 14 }} />}
onClick={() => {
handleAction('pause')
}}
>
Pause
</Chip>
) : (
<Chip
variant='solid'
color='success'
size='md'
startDecorator={<PlayArrow sx={{ fontSize: 14 }} />}
onClick={() => {
handleAction('resume')
}}
>
Resume
</Chip>
)}
{/* Chips for start time and current session */}
{chore.status === 1 && chore.timerUpdatedAt && (
<>
{/* Original start time */}
{chore.startTime && (
<Chip
variant='plain'
color='primary'
size='md'
startDecorator={<Flag sx={{ fontSize: 14 }} />}
>
{new Date(chore.startTime).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
{/* Current session start time */}
{chore.timerUpdatedAt !== chore.startTime && (
<Chip
variant='plain'
color='neutral'
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
</>
)}
{/* Chips for paused state */}
{chore.status === 2 && (
<Chip
variant='plain'
color='neutral'
size='md'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
</Box>
</Card>
)
}
export default TimePassedCard

View File

@@ -0,0 +1,161 @@
import {
ArrowDropDown,
DeleteSweep,
Info,
Pause,
PlayArrow,
} from '@mui/icons-material'
import { Box, ButtonGroup, IconButton, Menu, MenuItem } from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
const TimerSplitButton = ({
chore,
onAction,
onShowDetails,
onResetTimer,
onClearAllTime,
disabled = false,
fullWidth = false,
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const isMenuOpen = Boolean(anchorEl)
const menuRef = useRef(null)
const handleMainAction = () => {
if (chore.status === 1) {
onAction('pause')
} else if (chore.status === 2) {
onAction('resume')
}
}
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
const handleShowDetails = () => {
onShowDetails()
handleMenuClose()
}
const handleResetTimer = () => {
onResetTimer()
handleMenuClose()
}
const handleClearAllTime = () => {
onClearAllTime()
handleMenuClose()
}
// Handle outside clicks to close menu
useEffect(() => {
const handleMenuOutsideClick = event => {
if (
anchorEl &&
!anchorEl.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [anchorEl])
// Only show the split button when there's an active timer (status 1 or 2)
if (chore.status === 0) {
return null
}
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
width: fullWidth ? '100%' : 'auto',
}}
>
<ButtonGroup
variant='soft'
color={chore.status === 1 ? 'warning' : 'success'}
sx={{
'--ButtonGroup-separatorSize': '1px',
'--ButtonGroup-connected': '1',
width: fullWidth ? '100%' : 'auto',
}}
disabled={disabled}
>
{/* Main action button */}
<IconButton
onClick={handleMainAction}
disabled={disabled}
size='md'
sx={{
px: 3,
py: 1,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
minWidth: fullWidth ? 'auto' : 120,
flex: fullWidth ? 1 : 'none',
}}
>
{chore.status === 1 ? <Pause /> : <PlayArrow />}
{chore.status === 1 ? 'Pause' : 'Resume'}
</IconButton>
{/* Dropdown arrow button */}
<IconButton
onClick={handleMenuOpen}
disabled={disabled}
size='lg'
sx={{
px: 1,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderLeft: '1px solid',
borderLeftColor: 'divider',
minWidth: 'auto',
}}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
{/* Dropdown menu */}
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={isMenuOpen}
onClose={handleMenuClose}
placement='bottom-end'
sx={{
mt: 1,
}}
>
<MenuItem onClick={handleShowDetails}>
<Info sx={{ mr: 1 }} />
Timer Details
</MenuItem>
{/* <MenuItem onClick={handleResetTimer}>
<RestartAlt sx={{ mr: 1 }} />
Restart timer
</MenuItem> */}
<MenuItem onClick={handleClearAllTime} color='danger'>
<DeleteSweep sx={{ mr: 1 }} />
Clear & Reset
</MenuItem>
</Menu>
</Box>
)
}
export default TimerSplitButton

View File

@@ -1,10 +1,13 @@
import {
CheckCircle,
EventNote,
HourglassEmpty,
Notes,
Person,
Redo,
Refresh,
ThumbDown,
Timelapse,
Toll,
WatchLater,
} from '@mui/icons-material'
@@ -32,9 +35,9 @@ const ActivityItem = ({ activity, members }) => {
member => member.userId === activity.completedBy,
)
const getTimeDisplay = performedAt => {
const getTimeDisplay = dateToDisplay => {
const now = moment()
const completed = moment(performedAt)
const completed = moment(dateToDisplay)
const diffInHours = now.diff(completed, 'hours')
const diffInDays = now.diff(completed, 'days')
@@ -50,37 +53,55 @@ const ActivityItem = ({ activity, members }) => {
}
const getStatusInfo = activity => {
if (!activity.status === 1) {
if (activity.status === 0) {
return {
color: 'neutral',
text: 'Completed',
icon: <CheckCircle />,
color: 'primary',
text: 'Started',
icon: <Timelapse />,
}
} else if (activity.status === 1) {
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
}
} else if (activity.status === 2) {
// skipped
return {
color: 'warning',
text: 'Skipped',
icon: <Redo />,
}
} else if (activity.status === 3) {
return {
color: 'neutral',
text: 'Pending Approval',
icon: <HourglassEmpty />,
}
} else if (activity.status === 4) {
return {
color: 'danger',
text: 'Rejected',
icon: <ThumbDown />,
}
}
const wasOnTime = moment(activity.performedAt).isSameOrBefore(
moment(activity.dueDate),
)
if (wasOnTime) {
return {
color: 'success',
text: 'Done',
icon: <CheckCircle />,
}
} else {
return {
color: 'primary',
text: 'Late',
icon: <WatchLater />,
}
// Fallback for completed status
return {
color: 'success',
text: 'Completed',
icon: <CheckCircle />,
}
}
@@ -105,7 +126,11 @@ const ActivityItem = ({ activity, members }) => {
{activity.choreName}
</Typography>
<Typography level='body-xs' color='text.secondary'>
{getTimeDisplay(activity.performedAt)}
{getTimeDisplay(
activity.performedAt ||
activity.updatedAt ||
activity.createdAt,
)}
</Typography>
</Box>
@@ -127,18 +152,6 @@ const ActivityItem = ({ activity, members }) => {
completedByMember?.name ||
'Unknown'}
</Typography>
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
>
{/* Points chip */}
{activity.points && activity.points > 0 && (
<Chip
@@ -152,6 +165,17 @@ const ActivityItem = ({ activity, members }) => {
)}
</Box>
{/* Status, Points, and Notes */}
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
mt: 0.5,
ml: 2.5,
}}
></Box>
{/* Notes */}
{activity.notes && (
<Box sx={{ mt: 0.5, ml: 2.5 }}>
@@ -180,7 +204,9 @@ const groupActivitiesByDate = activities => {
const groups = {}
activities.forEach(activity => {
const date = moment(activity.performedAt).format('YYYY-MM-DD')
const date = moment(
activity.performedAt || activity.updatedAt || activity.createdAt,
).format('YYYY-MM-DD')
if (!groups[date]) {
groups[date] = []
}
@@ -216,7 +242,7 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
// Refresh function to refetch all data
const handleRefresh = async () => {
await Promise.all([refetchChores(), refetchHistory(), refetchMembers()])
await Promise.all([refetchChores(), refetchHistory, refetchMembers])
}
// Show loading state
@@ -228,12 +254,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
width: '310px',
minHeight: 300,
width: '315px',
maxHeight: 400,
mb: 1,
}}
>
@@ -270,7 +295,8 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
const sortedHistory = enrichedHistory
.sort(
(a, b) =>
moment(b.performedAt).valueOf() - moment(a.performedAt).valueOf(),
moment(b.performedAt || b.updatedAt).valueOf() -
moment(a.performedAt || a.updatedAt).valueOf(),
)
.slice(0, 10) // Show only latest 10 activities
@@ -284,12 +310,11 @@ const ActivitiesCard = ({ title = 'Recent Activities' }) => {
p: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: 'sm',
borderRadius: 20,
// width: '290px',
width: '310px',
minHeight: 300,
maxHeight: 400,
mb: 1,
}}
>

View File

@@ -0,0 +1,868 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Box,
Button,
Container,
Divider,
IconButton,
Input,
List,
Stack,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { ChoreSorter } from '../../utils/Chores'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard'
import MultiSelectHelp from './MultiSelectHelp'
const ArchivedTasks = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const { showSuccess, showError } = useNotification()
const { impersonatedUser } = useImpersonateUser()
const unArchiveChore = useUnArchiveChore()
const [archivedChores, setArchivedChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([])
const navigate = useNavigate()
const [viewMode, setViewMode] = useState(
localStorage.getItem('archivedChoreCardViewMode') || 'default',
)
const [isLoading, setIsLoading] = useState(true)
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const searchInputRef = useRef(null)
// Multi-select state
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
setPerformers(membersData.res)
try {
const response = await GetArchivedChores()
const data = await response.json()
const sortedChores = data.res.sort(ChoreSorter)
setArchivedChores(sortedChores)
setFilteredChores(sortedChores)
} catch (error) {
showError({
title: 'Failed to load archived tasks',
message: 'Please try again later.',
})
} finally {
setIsLoading(false)
}
}
}
loadArchivedChores()
}, [membersLoading, userProfile, membersData])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = event => {
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
if (isHoldingCmdOrCtrl) {
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()
toggleMultiSelectMode()
return
}
// Ctrl/Cmd + A to select all
if (
isHoldingCmdOrCtrl &&
!event.shiftKey &&
event.key === 'a' &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setTimeout(() => {
selectAllVisibleChores()
}, 0)
} else {
selectAllVisibleChores()
}
}
// Multi-select keyboard shortcuts
if (isMultiSelectMode) {
// Escape to clear selection or exit multi-select mode
if (event.key === 'Escape') {
event.preventDefault()
if (selectedChores.size > 0) {
clearSelection()
} else {
setIsMultiSelectMode(false)
}
return
}
// "r" key for bulk restore (unarchive)
if (
isHoldingCmdOrCtrl &&
event.key === 'r' &&
selectedChores.size > 0
) {
event.preventDefault()
handleBulkRestore()
return
}
// "e" key for bulk delete
if (
isHoldingCmdOrCtrl &&
event.key === 'e' &&
selectedChores.size > 0
) {
event.preventDefault()
handleBulkDelete()
return
}
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [isMultiSelectMode, selectedChores.size])
const toggleViewMode = () => {
const modes = ['default', 'compact']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
setViewMode(newMode)
localStorage.setItem('archivedChoreCardViewMode', newMode)
}
const searchOptions = {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}
const fuse = new Fuse(
archivedChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(c => c.name).join(' '),
})),
searchOptions,
)
const handleSearchChange = e => {
const search = e.target.value
if (search === '') {
setFilteredChores(archivedChores)
setSearchTerm('')
return
}
const term = search.toLowerCase()
setSearchTerm(term)
setFilteredChores(fuse.search(term).map(result => result.item))
}
const handleSearchClose = () => {
setSearchTerm('')
setFilteredChores(archivedChores)
searchInputRef.current?.blur()
}
const handleChoreUpdated = (updatedChore, event) => {
if (event === 'unarchive') {
// Remove from archived list when unarchived
const newArchivedChores = archivedChores.filter(
chore => chore.id !== updatedChore.id,
)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== updatedChore.id,
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
showSuccess({
title: 'Task Restored',
message: 'The task has been restored and is now active.',
})
}
}
const handleChoreDeleted = deletedChore => {
const newArchivedChores = archivedChores.filter(
chore => chore.id !== deletedChore.id,
)
const newFilteredChores = filteredChores.filter(
chore => chore.id !== deletedChore.id,
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
showSuccess({
title: 'Task Deleted',
message: 'The archived task has been permanently deleted.',
})
}
// Multi-select helper functions
const toggleMultiSelectMode = () => {
const newMode = !isMultiSelectMode
setIsMultiSelectMode(newMode)
if (!newMode) {
setSelectedChores(new Set())
}
}
const toggleChoreSelection = choreId => {
const newSelection = new Set(selectedChores)
if (newSelection.has(choreId)) {
newSelection.delete(choreId)
} else {
newSelection.add(choreId)
}
setSelectedChores(newSelection)
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
}
}
const clearSelection = () => {
if (selectedChores.size === 0) {
setIsMultiSelectMode(false)
return
}
setSelectedChores(new Set())
}
const getSelectedChoresData = () => {
return Array.from(selectedChores)
.map(id => archivedChores.find(chore => chore.id === id))
.filter(Boolean)
}
// Bulk operations
const handleBulkRestore = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Restore Tasks',
confirmText: 'Restore',
cancelText: 'Cancel',
message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const restoredTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await new Promise((resolve, reject) => {
unArchiveChore.mutate(chore.id, {
onSuccess: data => {
restoredTasks.push(chore)
resolve(data)
},
onError: error => {
failedTasks.push(chore)
reject(error)
},
})
})
} catch (error) {
// Error already handled in onError callback
}
}
if (restoredTasks.length > 0) {
showSuccess({
title: '📤 Tasks Restored',
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
})
// Remove restored tasks from archived list
const restoredIds = new Set(restoredTasks.map(c => c.id))
const newArchivedChores = archivedChores.filter(
c => !restoredIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !restoredIds.has(c.id),
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Restore Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
const handleBulkDelete = async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Delete Archived Tasks',
confirmText: 'Delete',
cancelText: 'Cancel',
message: `Permanently delete ${selectedData.length} archived task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const deletedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await DeleteChore(chore.id)
deletedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (deletedTasks.length > 0) {
showSuccess({
title: '🗑️ Tasks Deleted',
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
})
const deletedIds = new Set(deletedTasks.map(c => c.id))
const newArchivedChores = archivedChores.filter(
c => !deletedIds.has(c.id),
)
const newFilteredChores = filteredChores.filter(
c => !deletedIds.has(c.id),
)
setArchivedChores(newArchivedChores)
setFilteredChores(newFilteredChores)
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
clearSelection()
} catch (error) {
showError({
title: 'Bulk Delete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}
// Helper function to render the appropriate card component
const renderChoreCard = (chore, key) => {
const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return (
<CardComponent
key={key || chore.id}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
viewOnly={false}
showActions={false}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
)
}
if (isUserProfileLoading || performers.length === 0 || isLoading) {
return <LoadingComponent />
}
return (
<Container maxWidth='md'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Archived Tasks
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
View and manage tasks that have been archived or completed.
</Typography>
</Stack>
</Box>
{/* Header */}
{/* <Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 2,
pt: 2,
}}
>
<Typography
level='h3'
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<Archive />
Archived Tasks
</Typography>
<Button
variant='outlined'
color='neutral'
startDecorator={<Close />}
onClick={() => navigate('/chores')}
sx={{ ml: 'auto' }}
>
Close
</Button>
</Box> */}
{/* Search and Controls */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
mb: 2,
}}
>
<Input
slotProps={{ input: { ref: searchInputRef } }}
placeholder='Search archived tasks'
value={searchTerm}
fullWidth
sx={{
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={handleSearchChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
}
endDecorator={
searchTerm && (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<KeyboardShortcutHint
shortcut='X'
show={showKeyboardShortcuts}
/>
<IconButton
variant='plain'
size='sm'
onClick={handleSearchClose}
sx={{ borderRadius: '50%' }}
>
<Close />
</IconButton>
</Box>
)
}
/>
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
viewMode === 'default'
? 'Switch to Compact View'
: 'Switch to Card View'
}
>
{viewMode === 'default' ? <ViewAgenda /> : <ViewModule />}
</IconButton>
{/* Multi-select Toggle Button */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleMultiSelectMode}
title={
isMultiSelectMode
? 'Exit Multi-select Mode (Ctrl+S)'
: 'Enable Multi-select Mode (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
</Box>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1000,
mb: 2,
}}
>
<Box
sx={{
backgroundColor: 'background.surface',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
p: 2,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'm',
gap: 2,
display: 'flex',
flexDirection: {
sm: 'column',
md: 'row',
},
alignItems: {
xs: 'stretch',
sm: 'center',
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
}}
>
{/* Selection Info and Controls */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-start',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'>
{selectedChores.size} task
{selectedChores.size !== 1 ? 's' : ''} selected
</Typography>
</Box>
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' },
}}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title='Select all visible tasks (Ctrl+A)'
>
All
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='A'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='outlined'
onClick={clearSelection}
startDecorator={
selectedChores.size === 0 ? (
<Close />
) : (
<CheckBoxOutlineBlank />
)
}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title={`${selectedChores.size === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
>
{selectedChores.size === 0 ? 'Close' : 'Clear'}
{showKeyboardShortcuts && (
<KeyboardShortcutHint
withCtrl={false}
shortcut='Esc'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
{/* Action Buttons */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-end',
},
}}
>
<Button
size='sm'
variant='solid'
color='success'
onClick={handleBulkRestore}
startDecorator={<Unarchive />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Restore selected tasks (R)'
>
Restore
{showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint
shortcut='R'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={handleBulkDelete}
startDecorator={<Delete />}
disabled={selectedChores.size === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Delete selected tasks (E)'
>
Delete
{showKeyboardShortcuts && selectedChores.size > 0 && (
<KeyboardShortcutHint
shortcut='E'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
</Box>
)}
{/* Content */}
{filteredChores.length === 0 ? (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
: 'Archived tasks will appear here when you archive them from the main task list'}
</Typography>
{searchTerm && (
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
{filteredChores.map(chore =>
renderChoreCard(chore, `archived-${chore.id}`),
)}
</List>
</Box>
)}
{/* Multi-select Help */}
<MultiSelectHelp isVisible={isMultiSelectMode} />
{/* Confirmation Modal */}
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
</Container>
)
}
export default ArchivedTasks

Some files were not shown because too many files have changed in this diff Show More