Merge branch 'develop' into feature/ja-localization

This commit is contained in:
Mohamad Tarbin
2026-07-07 23:01:46 -04:00
committed by GitHub
117 changed files with 13081 additions and 4117 deletions

9
.editorconfig Normal file
View File

@@ -0,0 +1,9 @@
# EditorConfig is awesome: https://EditorConfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true

View File

@@ -2,9 +2,7 @@ name: Build validation
on:
push:
branches: [ "main", "develop" ]
pull_request:
branches: [ "main", "develop" ]
jobs:
build:

3
.gitignore vendored
View File

@@ -23,4 +23,5 @@ dist-ssr
*.sln
*.sw?
resources/android/**/*
resources/android/**/*
resources/ios/**/*

View File

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

View File

@@ -13,12 +13,16 @@ dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-device')
implementation project(':capacitor-local-llm')
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-document-scanner')
implementation project(':capgo-capacitor-nfc')
implementation project(':capgo-capacitor-social-login')
implementation project(':jcesarmobile-capacitor-ocr')
implementation project(':revenuecat-purchases-capacitor')
implementation project(':revenuecat-purchases-capacitor-ui')
implementation project(':capacitor-plugin-safe-area')

View File

@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.donetick.app",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 20,
"versionName": "1.0.20",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}

View File

@@ -9,7 +9,7 @@
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
@@ -20,13 +20,20 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Deep link intent filter for OAuth callback -->
<!-- Deep link intent filter for OAuth and other donetick:// URLs -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="donetick" />
</intent-filter>
<!-- NFC NDEF dispatch: open app directly when a donetick:// tag is scanned -->
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="donetick" />
</intent-filter>
</activity>
<provider
@@ -36,6 +43,16 @@
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
<!-- Push Notifications Icon -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/ic_launcher" />
<!-- Push Notifications Color -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@android:color/white" />
</application>
<!-- Permissions -->
@@ -43,6 +60,8 @@
<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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.NFC" />
</manifest>

View File

@@ -2,4 +2,36 @@ package com.donetick.app;
import com.getcapacitor.BridgeActivity;
public class MainActivity extends BridgeActivity {}
import ee.forgr.capacitor.social.login.GoogleProvider;
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
import com.getcapacitor.PluginHandle;
import com.getcapacitor.Plugin;
import android.content.Intent;
import android.util.Log;
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
@Override
public void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode >= GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MIN && requestCode < GoogleProvider.REQUEST_AUTHORIZE_GOOGLE_MAX) {
PluginHandle pluginHandle = getBridge().getPlugin("SocialLogin");
if (pluginHandle == null) {
Log.i("Google Activity Result", "SocialLogin login handle is null");
return;
}
Plugin plugin = pluginHandle.getInstance();
if (!(plugin instanceof SocialLoginPlugin)) {
Log.i("Google Activity Result", "SocialLogin plugin instance is not SocialLoginPlugin");
return;
}
((SocialLoginPlugin) plugin).handleGoogleLoginIntent(requestCode, data);
}
}
@Override
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {}
}

View File

@@ -7,8 +7,8 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.2.1'
classpath 'com.google.gms:google-services:4.4.0'
classpath 'com.android.tools.build:gradle:8.10.1'
classpath 'com.google.gms:google-services:4.4.2'
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files

View File

@@ -14,6 +14,9 @@ project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/
include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/@capacitor/device/android')
include ':capacitor-local-llm'
project(':capacitor-local-llm').projectDir = new File('../node_modules/@capacitor/local-llm/android')
include ':capacitor-local-notifications'
project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android')
@@ -29,9 +32,18 @@ project(':capacitor-push-notifications').projectDir = new File('../node_modules/
include ':capacitor-status-bar'
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
include ':capgo-capacitor-document-scanner'
project(':capgo-capacitor-document-scanner').projectDir = new File('../node_modules/@capgo/capacitor-document-scanner/android')
include ':capgo-capacitor-nfc'
project(':capgo-capacitor-nfc').projectDir = new File('../node_modules/@capgo/capacitor-nfc/android')
include ':capgo-capacitor-social-login'
project(':capgo-capacitor-social-login').projectDir = new File('../node_modules/@capgo/capacitor-social-login/android')
include ':jcesarmobile-capacitor-ocr'
project(':jcesarmobile-capacitor-ocr').projectDir = new File('../node_modules/@jcesarmobile/capacitor-ocr/android')
include ':revenuecat-purchases-capacitor'
project(':revenuecat-purchases-capacitor').projectDir = new File('../node_modules/@revenuecat/purchases-capacitor/android')

View File

@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME

View File

@@ -1,16 +1,17 @@
ext {
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'
minSdkVersion = 28
compileSdkVersion = 36
targetSdkVersion = 36
androidxActivityVersion = '1.11.0'
androidxAppCompatVersion = '1.7.1'
androidxCoordinatorLayoutVersion = '1.3.0'
androidxCoreVersion = '1.17.0'
androidxFragmentVersion = '1.8.9'
coreSplashScreenVersion = '1.0.1'
androidxWebkitVersion = '1.9.0'
androidxWebkitVersion = '1.14.0'
junitVersion = '4.13.2'
androidxJunitVersion = '1.1.5'
androidxEspressoCoreVersion = '3.5.1'
androidxJunitVersion = '1.3.0'
androidxEspressoCoreVersion = '3.7.0'
cordovaAndroidVersion = '10.1.1'
}
firebaseMessagingVersion = '24.1.0'
}

View File

@@ -1,4 +1,4 @@
import type { CapacitorConfig } from '@capacitor/cli';
import type { CapacitorConfig } from '@capacitor/cli'
const config: CapacitorConfig = {
appId: 'com.donetick.app',
@@ -12,17 +12,17 @@ const config: CapacitorConfig = {
presentationOptions: ['badge', 'sound', 'alert'],
},
LocalNotifications: {
smallIcon: "ic_stat_icon_config_sample",
iconColor: "#488AFF",
sound: "beep.wav",
smallIcon: 'ic_stat_icon_config_sample',
iconColor: '#488AFF',
sound: 'beep.wav',
},
GoogleAuth: {
scopes: ['profile', 'email', 'openid'],
clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,
androidClientId: process.env.VITE_APP_ANDRIOD_CLIENT_ID,
iosClientId: process.env.VITE_APP_IOS_CLIENT_ID,
// GoogleAuth: {
// scopes: ['profile', 'email', 'openid'],
// clientId: process.env.VITE_APP_GOOGLE_CLIENT_ID,
// androidClientId: process.env.VITE_APP_ANDRIOD_CLIENT_ID,
// iosClientId: process.env.VITE_APP_IOS_CLIENT_ID,
// },
},
}
};
export default config;
export default config

View File

@@ -3,8 +3,13 @@
<head>
<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" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<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

@@ -14,6 +14,7 @@
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */; };
A084ECDBA7D38E1E42DFC39D /* Pods_App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */; };
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = D1115B482C653D60004C6043 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
@@ -28,6 +29,7 @@
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
AF277DCFFFF123FFC6DF26C7 /* Pods_App.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
D1115B482C653D60004C6043 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
@@ -62,6 +64,7 @@
504EC3051FED79650016851F /* Products */,
7F8756D8B27F46E3366F6CEA /* Pods */,
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
71866EB277374608AD02C137 /* PrivacyInfo.xcprivacy */,
);
sourceTree = "<group>";
};
@@ -127,8 +130,8 @@
504EC2FC1FED79650016851F /* Project object */ = {
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 0920;
LastUpgradeCheck = 0920;
LastSwiftUpdateCheck = 920;
LastUpgradeCheck = 920;
TargetAttributes = {
504EC3031FED79650016851F = {
CreatedOnToolsVersion = 9.2;
@@ -169,6 +172,7 @@
504EC30D1FED79650016851F /* Main.storyboard in Resources */,
2FAD9763203C412B000D30F8 /* config.xml in Resources */,
D1115B492C653D60004C6043 /* GoogleService-Info.plist in Resources */,
72FA9293C4A649D1BA0E5917 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -289,7 +293,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -340,7 +344,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
@@ -355,13 +359,13 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.2.3;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -375,12 +379,12 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = App/App.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 24;
CURRENT_PROJECT_VERSION = 25;
DEVELOPMENT_TEAM = 6UJJ78R3BS;
INFOPLIST_FILE = App/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.2.2;
MARKETING_VERSION = 1.2.3;
PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

View File

@@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.nfc.readersession.formats</key>
<array>
<string>TAG</string>
</array>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.developer.applesignin</key>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,14 +1,14 @@
{
"images" : [
"images": [
{
"filename" : "AppIcon-512@2x.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
"idiom": "universal",
"size": "1024x1024",
"filename": "AppIcon-512@2x.png",
"platform": "ios"
}
],
"info" : {
"author" : "xcode",
"version" : 1
"info": {
"author": "xcode",
"version": 1
}
}
}

View File

@@ -1,23 +1,56 @@
{
"images" : [
"images": [
{
"idiom" : "universal",
"filename" : "splash-2732x2732-2.png",
"scale" : "1x"
"idiom": "universal",
"filename": "Default@1x~universal~anyany.png",
"scale": "1x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732-1.png",
"scale" : "2x"
"idiom": "universal",
"filename": "Default@2x~universal~anyany.png",
"scale": "2x"
},
{
"idiom" : "universal",
"filename" : "splash-2732x2732.png",
"scale" : "3x"
"idiom": "universal",
"filename": "Default@3x~universal~anyany.png",
"scale": "3x"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "1x",
"filename": "Default@1x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "2x",
"filename": "Default@2x~universal~anyany-dark.png"
},
{
"appearances": [
{
"appearance": "luminosity",
"value": "dark"
}
],
"idiom": "universal",
"scale": "3x",
"filename": "Default@3x~universal~anyany-dark.png"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
"info": {
"version": 1,
"author": "xcode"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

View File

@@ -2,10 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NFCReaderUsageDescription</key>
<string>Donetick uses NFC to read and write chore tags for quick task completion.</string>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>DoneTick</string>
<string>Donetick</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -18,10 +20,27 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.682262497914-5dacpk46qcc1494lood6ch8ul9c83kop</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<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>
<key>UIBackgroundModes</key>
<array/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIRequiredDeviceCapabilities</key>
@@ -30,6 +49,7 @@
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
@@ -54,9 +74,5 @@
</array>
</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>
</plist>

View File

@@ -20,6 +20,7 @@ def capacitor_pods
pod 'CapacitorPreferences', :path => '../../node_modules/@capacitor/preferences'
pod 'CapacitorPushNotifications', :path => '../../node_modules/@capacitor/push-notifications'
pod 'CapacitorStatusBar', :path => '../../node_modules/@capacitor/status-bar'
pod 'CapgoCapacitorNfc', :path => '../../node_modules/@capgo/capacitor-nfc'
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'

View File

@@ -1,68 +1,76 @@
PODS:
- Alamofire (5.10.2)
- 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 (2.1.0):
- AppAuth/Core (= 2.1.0)
- AppAuth/ExternalUserAgent (= 2.1.0)
- AppAuth/Core (2.1.0)
- AppAuth/ExternalUserAgent (2.1.0):
- AppAuth/Core
- AppCheckCore (11.2.0):
- AppCheckCore (11.3.0):
- GoogleUtilities/Environment (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- PromisesObjC (~> 2.4)
- Capacitor (7.4.3):
- PromisesSwift (~> 2.4)
- RecaptchaInterop (~> 101.0)
- Capacitor (8.4.1):
- CapacitorCordova
- CapacitorApp (7.0.2):
- CapacitorApp (8.1.0):
- Capacitor
- CapacitorBrowser (7.0.2):
- CapacitorBrowser (8.0.3):
- Capacitor
- CapacitorCommunitySqlite (7.0.1):
- CapacitorCommunitySqlite (8.1.0):
- Capacitor
- SQLCipher
- ZIPFoundation
- CapacitorCordova (7.4.3)
- CapacitorDevice (7.0.2):
- CapacitorCordova (8.4.1)
- CapacitorDevice (8.0.2):
- Capacitor
- CapacitorLocalNotifications (7.0.2):
- CapacitorLocalLlm (1.0.0):
- Capacitor
- CapacitorNetwork (7.0.2):
- CapacitorLocalNotifications (8.2.0):
- Capacitor
- CapacitorPluginSafeArea (4.0.0):
- CapacitorNetwork (8.0.1):
- Capacitor
- CapacitorPreferences (7.0.2):
- CapacitorPluginSafeArea (5.0.0):
- Capacitor
- CapacitorPushNotifications (7.0.2):
- CapacitorPreferences (8.0.1):
- Capacitor
- CapacitorStatusBar (7.0.2):
- CapacitorPushNotifications (8.1.1):
- Capacitor
- CapgoCapacitorSocialLogin (7.11.2):
- CapacitorStatusBar (8.0.2):
- Capacitor
- CapgoCapacitorDocumentScanner (8.4.0):
- Capacitor
- CapgoCapacitorNfc (8.1.7):
- Capacitor
- CapgoCapacitorSocialLogin (8.3.34):
- Alamofire (~> 5.10.2)
- Capacitor
- FBSDKCoreKit (= 18.0.0)
- FBSDKLoginKit (= 18.0.0)
- FBSDKCoreKit (~> 18.0)
- FBSDKLoginKit (~> 18.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)
- FBAEMKit (18.1.0):
- FBSDKCoreKit_Basics (= 18.1.0)
- FBSDKCoreKit (18.1.0):
- FBAEMKit (= 18.1.0)
- FBSDKCoreKit_Basics (= 18.1.0)
- FBSDKCoreKit_Basics (18.1.0)
- FBSDKLoginKit (18.1.0):
- FBSDKCoreKit (= 18.1.0)
- FirebaseCore (12.15.0):
- FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.3.0):
- FirebaseCoreInternal (12.15.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.3.0):
- FirebaseCore (~> 12.3.0)
- FirebaseInstallations (12.15.0):
- FirebaseCore (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.3.0):
- FirebaseCore (~> 12.3.0)
- FirebaseInstallations (~> 12.3.0)
- FirebaseMessaging (12.15.0):
- FirebaseCore (~> 12.15.0)
- FirebaseInstallations (~> 12.15.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
@@ -77,60 +85,65 @@ PODS:
- AppCheckCore (~> 11.0)
- GTMAppAuth (~> 5.0)
- GTMSessionFetcher/Core (~> 3.3)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/AppDelegateSwizzler (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Environment (8.1.2):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Logger (8.1.2):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Network (8.1.2):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- "GoogleUtilities/NSData+zlib (8.1.2)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Privacy (8.1.2)
- GoogleUtilities/Reachability (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/UserDefaults (8.1.2):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GTMAppAuth (5.0.0):
- AppAuth/Core (~> 2.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- JcesarmobileCapacitorOcr (0.3.0):
- Capacitor
- 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)
- 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):
- PromisesObjC (2.4.1)
- PromisesSwift (2.4.1):
- PromisesObjC (= 2.4.1)
- PurchasesHybridCommon (17.55.1):
- RevenueCat (= 5.67.1)
- PurchasesHybridCommonUI (17.55.1):
- PurchasesHybridCommon (= 17.55.1)
- RevenueCatUI (= 5.67.1)
- RecaptchaInterop (101.0.0)
- RevenueCat (5.67.1)
- RevenuecatPurchasesCapacitor (12.3.2):
- Capacitor
- PurchasesHybridCommon (= 17.0.0)
- RevenuecatPurchasesCapacitorUi (11.1.2):
- PurchasesHybridCommon (= 17.55.1)
- RevenuecatPurchasesCapacitorUi (12.3.2):
- Capacitor
- PurchasesHybridCommonUI (= 17.0.0)
- RevenueCatUI (5.35.1):
- RevenueCat (= 5.35.1)
- PurchasesHybridCommonUI (= 17.55.1)
- RevenueCatUI (5.67.1):
- RevenueCat (= 5.67.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)
- ZIPFoundation (0.9.20)
DEPENDENCIES:
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
@@ -139,14 +152,18 @@ DEPENDENCIES:
- "CapacitorCommunitySqlite (from `../../node_modules/@capacitor-community/sqlite`)"
- "CapacitorCordova (from `../../node_modules/@capacitor/ios`)"
- "CapacitorDevice (from `../../node_modules/@capacitor/device`)"
- "CapacitorLocalLlm (from `../../node_modules/@capacitor/local-llm`)"
- "CapacitorLocalNotifications (from `../../node_modules/@capacitor/local-notifications`)"
- "CapacitorNetwork (from `../../node_modules/@capacitor/network`)"
- CapacitorPluginSafeArea (from `../../node_modules/capacitor-plugin-safe-area`)
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
- "CapgoCapacitorDocumentScanner (from `../../node_modules/@capgo/capacitor-document-scanner`)"
- "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)"
- "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)"
- FirebaseMessaging
- "JcesarmobileCapacitorOcr (from `../../node_modules/@jcesarmobile/capacitor-ocr`)"
- "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)"
- "RevenuecatPurchasesCapacitorUi (from `../../node_modules/@revenuecat/purchases-capacitor-ui`)"
@@ -170,8 +187,10 @@ SPEC REPOS:
- GTMSessionFetcher
- nanopb
- PromisesObjC
- PromisesSwift
- PurchasesHybridCommon
- PurchasesHybridCommonUI
- RecaptchaInterop
- RevenueCat
- RevenueCatUI
- SQLCipher
@@ -190,6 +209,8 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/ios"
CapacitorDevice:
:path: "../../node_modules/@capacitor/device"
CapacitorLocalLlm:
:path: "../../node_modules/@capacitor/local-llm"
CapacitorLocalNotifications:
:path: "../../node_modules/@capacitor/local-notifications"
CapacitorNetwork:
@@ -202,8 +223,14 @@ EXTERNAL SOURCES:
:path: "../../node_modules/@capacitor/push-notifications"
CapacitorStatusBar:
:path: "../../node_modules/@capacitor/status-bar"
CapgoCapacitorDocumentScanner:
:path: "../../node_modules/@capgo/capacitor-document-scanner"
CapgoCapacitorNfc:
:path: "../../node_modules/@capgo/capacitor-nfc"
CapgoCapacitorSocialLogin:
:path: "../../node_modules/@capgo/capacitor-social-login"
JcesarmobileCapacitorOcr:
:path: "../../node_modules/@jcesarmobile/capacitor-ocr"
RevenuecatPurchasesCapacitor:
:path: "../../node_modules/@revenuecat/purchases-capacitor"
RevenuecatPurchasesCapacitorUi:
@@ -211,45 +238,51 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Alamofire: 7193b3b92c74a07f85569e1a6c4f4237291e7496
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
AppCheckCore: cc8fd0a3a230ddd401f326489c99990b013f0c4f
Capacitor: 28d6c01026a9a3f7156529498ec1f389a2a28dbc
CapacitorApp: 1f6922c9c5c8b1c538d7fbe92ebe44a81b34bed3
CapacitorBrowser: 22541e48442de44dc629c214388290d6eecc6ae9
CapacitorCommunitySqlite: 8b2c6bab33e3519280811d481f8bd0fa90343e1b
CapacitorCordova: 435121e81a2df4d0034f0fb11fcefab5104cfdb5
CapacitorDevice: 81ae78d5d1942707caad79276badd458bf6ec603
CapacitorLocalNotifications: 665188ae8accd40806129073896fb2b39322d858
CapacitorNetwork: 695069886b3c5ed514db69aa3d026b8dc3c03a6b
CapacitorPluginSafeArea: 22031c3436269ca80fac90ec2c94bc7c1e59a81d
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
AppAuth: ef4da5a3fc2e10b90c09a0a94a9baeaedc0341d5
AppCheckCore: 214137f5c378d1dec88a68425c467fe65aaff637
Capacitor: 35242afe195b1e53c58ca1b827d1b444c5e6602b
CapacitorApp: 449ffe26375e96f8aaaee625ac6e01e5c57c8650
CapacitorBrowser: c987c73d09d8bd3b5ec13f06338b1e14d5d2be69
CapacitorCommunitySqlite: eac6acfb852f46e7988fc59604d7f900498d354e
CapacitorCordova: eebe6bcf807b1b06f3f48237650f96bbcd0eef09
CapacitorDevice: 14cba6f88d1c3074cbf825fea977c8c526453ff8
CapacitorLocalLlm: a05516151a02923a9e7dae9949d3817e85e321f0
CapacitorLocalNotifications: 2615aa008f608b95d3921a778ee1988abf1e6148
CapacitorNetwork: 8812ce60d11fb63d8f2e4ba51a49b2e59892ebe2
CapacitorPluginSafeArea: 9d0682951c011bf0b36e513a89103c5fbff7ac49
CapacitorPreferences: cca2021f386efb75947c850334447d9ff22b14f1
CapacitorPushNotifications: ec08d589c226a2c0db7c032ec1bf5b044ec85f8e
CapacitorStatusBar: 01d5763b4ed720de5ce2edbc938de6a98f4c8f32
CapgoCapacitorDocumentScanner: 7ad9e8ed9c054d551bfc948660d995b9545723d8
CapgoCapacitorNfc: 5bf5a951ca231d7c40f1ac20549d2abcd1b8f939
CapgoCapacitorSocialLogin: b0e67630b9d9b5f14fff774c825c1b8fa85eff7b
FBAEMKit: 64088ff0380f50e4a56e2ee07d984f7f1aef7595
FBSDKCoreKit: 8390ea3a8fac186f444275f31d7512e760b47cba
FBSDKCoreKit_Basics: 3a0005c78b355388bf2df5c6dbe6381b77ea4be3
FBSDKLoginKit: d8e2711a3a03b0026703735c8d28a2b5a0e1f4fd
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
FirebaseMessaging: 40017d7bc8457ee295b0f41d480a80fdabc9994e
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GoogleUtilities: 766ace00c6b10d8148408f329d10c4f051931850
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
JcesarmobileCapacitorOcr: d3668e104d7ad8968a9d00fd4766e18fea0fe305
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: 34d2f73712f4c02f2963fcfb62d43757a9136860
PurchasesHybridCommonUI: 16e162423c0010f9a6f2c1d6505dcc075f49dcbe
RevenueCat: 47d52620419dcf3dce4e724eade9d390e16ab4b0
RevenuecatPurchasesCapacitor: 51cd81cec7db693a2552b45c36261d849e1cf574
RevenuecatPurchasesCapacitorUi: 267758d61aba42a0a3f035e6ba3c4630112357e4
RevenueCatUI: 3c72ef61b54c3f4787afab6a3b0f00cbec192063
PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273
PromisesSwift: 217dea0fd5d2ad65222a109c48698add13cc1c5b
PurchasesHybridCommon: ecd9d7c586287625f1225b8de5e24c1021502270
PurchasesHybridCommonUI: 3056a3d261529f6adb8f74ecc3ab7f1cc0d3fab4
RecaptchaInterop: 11e0b637842dfb48308d242afc3f448062325aba
RevenueCat: 9357239a9f0978285e68983637b8a602e65c94f0
RevenuecatPurchasesCapacitor: c299be01b1a5c1d5eb118c30eadc84160944ecf2
RevenuecatPurchasesCapacitorUi: dc2ec0d97638ebe4ac0078452f5c212b1525ea1c
RevenueCatUI: 6f9291da540f98baf405b5bdfc46b2d5547b704a
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
ZIPFoundation: b8c29ea7ae353b309bc810586181fd073cb3312c
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
PODFILE CHECKSUM: bb6dcef70c8edc058fe3ba311f08a223fd7dbc66
PODFILE CHECKSUM: 21b805bdbbb6ac4b8a3527ee8ef346ba0a55ef65
COCOAPODS: 1.16.2

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>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>85F4.1</string>
</array>
</dict>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
</dict>
</plist>

0
ios/Podfile Normal file
View File

2959
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "donetick",
"private": true,
"version": "1.2.2",
"version": "1.2.3",
"type": "module",
"engines": {
"node": ">=20.0.0",
@@ -30,43 +30,49 @@
"ionic:build": "npm run build",
"ionic:serve": "npm run start",
"android": "npm run build && npx cap sync android",
"cap": "npm run build && npx cap sync",
"bump": "node bump-version.js",
"bump:minor": "node bump-version.js minor",
"bump:major": "node bump-version.js major"
"bump:major": "node bump-version.js major",
"bump:patch": "node bump-version.js patch"
},
"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",
"@capacitor/local-notifications": "^7.0.0",
"@capacitor/network": "^7.0.1",
"@capacitor/preferences": "^7.0.0",
"@capacitor/push-notifications": "^7.0.0",
"@capacitor/status-bar": "^7.0.0",
"@capgo/capacitor-social-login": "^7.5.3",
"@capacitor-community/sqlite": "^8.0.0",
"@capacitor/android": "^8.0.0",
"@capacitor/app": "^8.0.0",
"@capacitor/browser": "^8.0.0",
"@capacitor/core": "^8.0.0",
"@capacitor/device": "^8.0.0",
"@capacitor/ios": "^8.0.0",
"@capacitor/local-llm": "^1.0.0",
"@capacitor/local-notifications": "^8.0.0",
"@capacitor/network": "^8.0.0",
"@capacitor/preferences": "^8.0.0",
"@capacitor/push-notifications": "^8.0.0",
"@capacitor/status-bar": "^8.0.0",
"@capgo/capacitor-document-scanner": "^8.4.0",
"@capgo/capacitor-nfc": "^8.0.0",
"@capgo/capacitor-social-login": "^8.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@hello-pangea/dnd": "^18.0.1",
"@jcesarmobile/capacitor-ocr": "^0.3.0",
"@meauxt/react-swipeable-list": "^1.0.0",
"@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",
"@revenuecat/purchases-capacitor": "^12.0.0",
"@revenuecat/purchases-capacitor-ui": "^12.0.0",
"@swc/core": "^1.12.5",
"@tanstack/react-query": "^5.17.0",
"aos": "^2.3.4",
"browser-image-compression": "^2.0.2",
"caniuse-lite": "^1.0.30001769",
"capacitor-plugin-safe-area": "^4.0.0",
"capacitor-plugin-safe-area": "^5.0.0",
"chrono-node": "^2.7.7",
"dotenv": "^16.4.5",
"esm": "^3.2.25",
@@ -92,11 +98,12 @@
"reactjs-social-login": "^2.6.3",
"recharts": "^2.15.0",
"reusify": "^1.0.4",
"tesseract.js": "^7.0.0",
"vite-plugin-pwa": "^0.20.0"
},
"devDependencies": {
"@capacitor/assets": "^3.0.5",
"@capacitor/cli": "^7.0.0",
"@capacitor/cli": "^8.0.0",
"@tanstack/eslint-plugin-query": "^5.14.6",
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
@@ -104,6 +111,7 @@
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.16",
"baseline-browser-mapping": "^2.9.19",
"capacitor-set-version": "^2.2.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.2",
@@ -119,6 +127,10 @@
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.10",
"tailwindcss": "^3.4.0",
"typescript": "^5.8.0",
"vite": "^5.2.13"
},
"allowScripts": {
"tesseract.js@7.0.0": true
}
}

View File

@@ -0,0 +1,75 @@
{
"title": "Aufgaben",
"myChores": "Meine Aufgaben",
"allChores": "Alle Aufgaben",
"addChore": "Aufgabe hinzufügen",
"editChore": "Aufgabe bearbeiten",
"deleteChore": "Aufgabe löschen",
"completeChore": "Aufgabe abschließen",
"dueDate": "Fälligkeitsdatum",
"assignedTo": "Zugewiesen an",
"priority": "Priorität",
"status": "Status",
"description": "Beschreibung",
"choreView": {
"assignment": "Zuweisung",
"assigned": "Zugewiesen",
"last": "Letzte",
"schedule": "Zeitplan",
"due": "Fällig",
"statistics": "Statistiken",
"completed": "Abgeschlossen",
"times": "mal",
"details": "Details",
"createdBy": "Erstellt von",
"na": "Nicht verfügbar",
"taskCompleted": "Aufgabe abgeschlossen",
"taskCompletedMessage": "Deine Aufgabe wurde als abgeschlossen markiert",
"taskCompletionUndone": "Aufgabenabschluss wurde rückgängig gemacht.",
"taskSkipUndone": "Aufgabe überspringen wurde rückgängig gemacht.",
"undoSuccessful": "Rückgängig erfolgreich",
"undoFailed": "Rückgängig fehlgeschlagen",
"undoFailedMessage": "Die Aktion konnte nicht rückgängig gemacht werden. Bitte versuche es erneut.",
"resetTimer": "Timer zurücksetzen",
"resetTimerConfirmation": "Bist du sicher, dass du den Timer zurücksetzen möchtest? Dies löscht alle Zeitaufzeichnungen seit du die Aufgabe gestartet hast.",
"clearAllTimeRecords": "Alle Zeitaufzeichnungen löschen",
"clearAllTimeConfirmation": "Dies löscht dauerhaft alle Timer für diese Aufgabe und setzt sie zurück auf \"nicht gestartet\".",
"descriptionTitle": "Beschreibung",
"description": "Beschreibung:",
"previousNote": "Vorherige Notiz",
"previousNoteLabel": "Vorherige Notiz:",
"subtasksLabel": "Unteraufgaben:",
"taskActions": "Aufgabenaktionen",
"addNote": "Notiz hinzufügen",
"additionalNotes": "Zusätzliche Notizen:",
"notePlaceholder": "Notiz zur Fertigstellung hinzufügen...",
"setCustomCompletionTime": "Benutzerdefinierte Abschlusszeit festlegen",
"skipTask": "Aufgabe überspringen",
"skipTaskConfirmation": "Bist du sicher, dass du diese Aufgabe überspringen möchtest?",
"markComplete": "Als abgeschlossen markieren",
"markAsDone": "Als erledigt markieren",
"edit": "Bearbeiten",
"archive": "Archivieren",
"unarchive": "Archivierung aufheben",
"viewHistory": "Verlauf anzeigen",
"history": "Verlauf",
"startTimer": "Timer starten",
"start": "Starten",
"pauseTimer": "Timer pausieren",
"approve": "Genehmigen",
"reject": "Ablehnen",
"pendingApproval": "Genehmigung ausstehend",
"undo": "Rückgängig",
"skip": "Überspringen",
"cancel": "Abbrechen",
"noPriority": "Keine Priorität",
"subtasks": "Unteraufgaben",
"noDescription": "Keine Beschreibung verfügbar",
"timer": {
"active": "Timer aktiv",
"paused": "Timer pausiert",
"reset": "Timer zurücksetzen",
"delete": "Sitzung löschen"
}
}
}

View File

@@ -0,0 +1,33 @@
{
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"close": "Schließen",
"confirm": "Bestätigen",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"warning": "Warnung",
"refresh": "Aktualisieren",
"copy": "Kopieren",
"copied": "Kopiert!",
"settings": "Einstellungen",
"yes": "Ja",
"no": "Nein",
"back": "Zurück",
"backToCalendar": "Zurück zum Kalender",
"logout": "Abmelden",
"version": "Version",
"navigation": {
"allTasks": "Alle Aufgaben",
"archived": "Archiviert",
"things": "Things",
"labels": "Beschriftungen",
"projects": "Projekte",
"filters": "Filter",
"activities": "Aktivitäten",
"points": "Punkte",
"settings": "Einstellungen"
}
}

View File

@@ -0,0 +1,174 @@
{
"title": "Einstellungen",
"circleSettings": {
"title": "Kreis-Einstellungen",
"description": "Dein Account wird automatisch mit einem Kreis verbunden, wenn du einen erstellst oder beitrittst. Lade einfach Freunde ein, indem du den einzigartigen Kreis-Code oder Link unten teilst. Du erhältst eine Benachrichtigung, wenn jemand deinem Kreis beitreten möchte. Wenn du gehen möchtest, klicke einfach auf 'Kreis verlassen'.",
"circleCode": "Kreis-Code",
"copyCode": "Code kopieren",
"copyLink": "Link kopieren",
"codeCopied": "Kreis-Code kopiert!",
"linkCopied": "Kreis-Link kopiert!",
"joinCircle": "Einem Kreis beitreten",
"joinCirclePlaceholder": "Kreis-Code eingeben",
"join": "Beitreten",
"leave": "Kreis verlassen",
"leaveConfirmTitle": "Kreis verlassen",
"leaveConfirmMessage": "Bist du sicher, dass du diesen Kreis verlassen möchtest?",
"circleMembers": "Kreis-Mitglieder",
"circleMemberRequests": "Kreis-Beitrittsanfragen",
"admin": "Administrator",
"member": "Mitglied",
"pending": "Ausstehend",
"accept": "Akzeptieren",
"reject": "Ablehnen",
"makeAdmin": "Zum Administrator machen",
"makeMember": "Zum Mitglied machen",
"remove": "Entfernen",
"webhookURL": "Webhook-URL",
"webhookDescription": "Gib eine Webhook-URL ein, um Benachrichtigungen für Kreis-Ereignisse zu erhalten",
"webhookPlaceholder": "https://deine-webhook-url.com"
},
"accountSettings": {
"title": "Account-Einstellungen",
"subscription": "Abonnement",
"subscriptionStatus": "Aktueller Plan",
"free": "Kostenlos",
"plus": "Plus",
"upgrade": "Upgraden",
"cancel": "Kündigen",
"changePassword": "Passwort ändern",
"password": "Passwort",
"dangerZone": "Gefahrenbereich",
"dangerZoneDescription": "Sobald du dein Konto löschst, gibt es kein Zurück mehr. Bitte sei dir sicher.",
"deleteAccount": "Account löschen"
},
"localization": {
"title": "Sprach- und Formateinstellungen",
"description": "Passe Sprache, Datumsformat und regionale Einstellungen für dein Konto an.",
"language": "Sprache",
"languageDescription": "Wähle deine bevorzugte Sprache",
"dateFormat": "Datumsformat",
"dateFormatDescription": "Wähle, wie Daten in der gesamten Anwendung angezeigt werden sollen",
"timeFormat": "Zeitformat",
"timeFormatDescription": "Wähle 12-Stunden- oder 24-Stunden-Zeitformat",
"12hour": "12-Stunden (AM/PM)",
"24hour": "24-Stunden",
"firstDayOfWeek": "Erster Tag der Woche",
"firstDayOfWeekDescription": "Wähle, welcher Tag deine Woche beginnt",
"sunday": "Sonntag",
"monday": "Montag",
"saturday": "Samstag",
"formats": {
"mdy": "MM/TT/JJJJ (USA)",
"dmy": "TT/MM/JJJJ (Europa)",
"ymd": "JJJJ-MM-TT (ISO)",
"long": "Langes Format (z.B. 1. Januar 2024)",
"short": "Kurzes Format (z.B. 1. Jan 2024)"
}
},
"sidepanel": {
"title": "Seitenleisten-Anpassung",
"description": "Passe das Layout und die Sichtbarkeit von Karten in der Seitenleiste an. Dieser Bereich ist nur auf großen Bildschirmgeräten wie Tablets und Desktops verfügbar."
},
"theme": {
"title": "Theme-Einstellungen",
"description": "Wähle, wie die Seite für dich aussieht. Wähle ein einzelnes Theme oder synchronisiere mit deinem System und wechsle automatisch zwischen Tag- und Nacht-Themes.",
"themeMode": "Theme-Modus",
"light": "Hell",
"dark": "Dunkel",
"system": "System"
},
"notifications": {
"settingsSaved": "Einstellungen erfolgreich gespeichert",
"settingsSaveFailed": "Speichern der Einstellungen fehlgeschlagen",
"invalidWebhook": "Ungültige Webhook-URL"
},
"profile": {
"title": "Profil-Einstellungen",
"description": "Aktualisiere deinen Anzeigenamen und dein Profilbild.",
"photoUpdated": "Foto aktualisiert",
"photoUpdatedMessage": "Dein Profilbild wurde erfolgreich aktualisiert!",
"uploadFailed": "Upload fehlgeschlagen",
"uploadFailedMessage": "Das Hochladen deines Fotos ist fehlgeschlagen. Bitte versuche es erneut.",
"profileUpdated": "Profil aktualisiert",
"profileUpdatedMessage": "Deine Profilinformationen wurden erfolgreich gespeichert!",
"updateFailed": "Update fehlgeschlagen",
"updateFailedMessage": "Dein Profil konnte nicht aktualisiert werden. Bitte überprüfe deine Verbindung und versuche es erneut.",
"changePhoto": "Foto ändern",
"displayName": "Anzeigename",
"displayNamePlaceholder": "Gib deinen Anzeigenamen ein",
"timezone": "Zeitzone",
"timezonePlaceholder": "Wähle deine Zeitzone",
"save": "Speichern",
"cancel": "Abbrechen"
},
"overview": {
"title": "Einstellungen",
"subtitle": "Passe deine Erfahrung an und verwalte deine Account-Einstellungen",
"upgrade": {
"title": "Auf Plus upgraden",
"description": "Schalte mächtige Funktionen frei, um deine Produktivität zu steigern",
"button": "Jetzt upgraden",
"features": {
"richText": "Rich-Text-Beschreibungen",
"notifications": "Aufgaben-Benachrichtigungen",
"apiIntegrations": "API-Integrationen",
"advancedAutomation": "Erweiterte Automatisierung"
}
},
"sections": {
"profile": {
"title": "Profil-Einstellungen",
"description": "Aktualisiere deine Profilinformationen, Foto, Anzeigenamen und Zeitzonen-Einstellungen."
},
"circle": {
"title": "Kreis-Einstellungen",
"description": "Verwalte deinen Kreis, lade Mitglieder ein und bearbeite Beitrittsanfragen."
},
"account": {
"title": "Account-Einstellungen",
"description": "Verwalte dein Abonnement, ändere dein Passwort und Account-Löschoptionen."
},
"subaccounts": {
"title": "Verwaltete Accounts",
"description": "Erstelle und verwalte Unter-Accounts zum Anmelden und Erledigen zugewiesener Aufgaben."
},
"notifications": {
"title": "Benachrichtigungen",
"description": "Konfiguriere Push-Benachrichtigungen, E-Mail-Benachrichtigungen und Benachrichtigungsziele für Aufgaben."
},
"mfa": {
"title": "Multi-Faktor-Authentifizierung",
"description": "Füge eine zusätzliche Sicherheitsebene mit MFA über Authenticator-Apps hinzu."
},
"apitokens": {
"title": "API-Token",
"description": "Generiere und verwalte Zugriffstoken für Drittanbieter-Integrationen und API-Zugang."
},
"storage": {
"title": "Speicher-Einstellungen",
"description": "Sichere und stelle deine Daten wieder her, verwalte lokalen Speicher und Synchronisierungseinstellungen."
},
"sidepanel": {
"title": "Seitenleisten-Anpassung",
"description": "Passe das Layout und die Sichtbarkeit von Karten in der Seitenleisten-Oberfläche an."
},
"theme": {
"title": "Theme-Einstellungen",
"description": "Wähle dein bevorzugtes Theme und konfiguriere Dunkel-/Hell-Modus-Einstellungen."
},
"localization": {
"title": "Sprach- und Formateinstellungen",
"description": "Passe Sprache, Datumsformat, Zeitformat und regionale Einstellungen an."
},
"advanced": {
"title": "Erweiterte Einstellungen",
"description": "Konfiguriere Webhooks, Echtzeit-Updates und andere erweiterte Funktionen für erhöhte Produktivität."
},
"developer": {
"title": "Entwickler-Einstellungen",
"description": "Zeige technische Informationen über Authentifizierungs-Token, SSE-Verbindungen und Debug-Daten an."
}
}
}
}

View File

@@ -0,0 +1,75 @@
{
"title": "Tarefas",
"myChores": "Minhas tarefas",
"allChores": "Todas as tarefas",
"addChore": "Adicionar tarefa",
"editChore": "Editar tarefa",
"deleteChore": "Excluir tarefa",
"completeChore": "Concluir tarefa",
"dueDate": "Data de vencimento",
"assignedTo": "Atribuída a",
"priority": "Prioridade",
"status": "Status",
"description": "Descrição",
"choreView": {
"assignment": "Atribuição",
"assigned": "Atribuída",
"last": "Última",
"schedule": "Agendamento",
"due": "Vence em",
"statistics": "Estatísticas",
"completed": "Concluída",
"times": "vezes",
"details": "Detalhes",
"createdBy": "Criada por",
"na": "N/D",
"taskCompleted": "Tarefa concluída",
"taskCompletedMessage": "Sua tarefa foi marcada como concluída",
"taskCompletionUndone": "A conclusão da tarefa foi desfeita.",
"taskSkipUndone": "O pulo da tarefa foi desfeito.",
"undoSuccessful": "Desfeito com sucesso",
"undoFailed": "Falha ao desfazer",
"undoFailedMessage": "Não foi possível desfazer a ação. Tente novamente.",
"resetTimer": "Reiniciar cronômetro",
"resetTimerConfirmation": "Tem certeza de que deseja reiniciar o cronômetro? Isso apagará todos os registros de tempo desde o início da tarefa.",
"clearAllTimeRecords": "Apagar todos os registros de tempo",
"clearAllTimeConfirmation": "Isso apagará permanentemente todos os cronômetros desta tarefa e a marcará como \"não iniciada\".",
"descriptionTitle": "Descrição",
"description": "Descrição :",
"previousNote": "Nota anterior",
"previousNoteLabel": "Nota anterior:",
"subtasksLabel": "Subtarefas :",
"taskActions": "Ações da tarefa",
"addNote": "Adicionar uma nota",
"additionalNotes": "Notas adicionais:",
"notePlaceholder": "Adicione uma nota sobre a conclusão...",
"setCustomCompletionTime": "Definir horário de conclusão personalizado",
"skipTask": "Pular tarefa",
"skipTaskConfirmation": "Tem certeza de que deseja pular esta tarefa?",
"markComplete": "Marcar como concluída",
"markAsDone": "Marcar como concluída",
"edit": "Editar",
"archive": "Arquivar",
"unarchive": "Desarquivar",
"viewHistory": "Ver histórico",
"history": "Histórico",
"startTimer": "Iniciar cronômetro",
"start": "Iniciar",
"pauseTimer": "Pausar cronômetro",
"approve": "Aprovar",
"reject": "Rejeitar",
"pendingApproval": "Aguardando aprovação",
"undo": "Desfazer",
"skip": "Pular",
"cancel": "Cancelar",
"noPriority": "Sem prioridade",
"subtasks": "Subtarefas",
"noDescription": "Nenhuma descrição disponível",
"timer": {
"active": "Cronômetro ativo",
"paused": "Cronômetro pausado",
"reset": "Reiniciar cronômetro",
"delete": "Excluir sessão"
}
}
}

View File

@@ -0,0 +1,33 @@
{
"save": "Salvar",
"cancel": "Cancelar",
"delete": "Excluir",
"edit": "Editar",
"close": "Fechar",
"confirm": "Confirmar",
"loading": "Carregando...",
"error": "Erro",
"success": "Sucesso",
"warning": "Aviso",
"refresh": "Atualizar",
"copy": "Copiar",
"copied": "Copiado!",
"settings": "Configurações",
"yes": "Sim",
"no": "Não",
"back": "Voltar",
"backToCalendar": "Voltar ao calendário",
"logout": "Sair",
"version": "Versão",
"navigation": {
"allTasks": "Todas as tarefas",
"archived": "Arquivadas",
"things": "Itens",
"labels": "Etiquetas",
"projects": "Projetos",
"filters": "Filtros",
"activities": "Atividades",
"points": "Pontos",
"settings": "Configurações"
}
}

View File

@@ -0,0 +1,174 @@
{
"title": "Configurações",
"circleSettings": {
"title": "Configurações do círculo",
"description": "Sua conta é conectada automaticamente a um círculo quando você cria ou entra em um. Convide amigos facilmente compartilhando o código ou link exclusivo do círculo abaixo. Você receberá uma notificação abaixo quando alguém solicitar entrar no seu círculo. Se quiser sair, basta clicar no botão \"Sair do círculo\".",
"circleCode": "Código do círculo",
"copyCode": "Copiar código",
"copyLink": "Copiar link",
"codeCopied": "Código do círculo copiado!",
"linkCopied": "Link do círculo copiado!",
"joinCircle": "Entrar em um círculo",
"joinCirclePlaceholder": "Digite o código do círculo",
"join": "Entrar",
"leave": "Sair do círculo",
"leaveConfirmTitle": "Sair do círculo",
"leaveConfirmMessage": "Tem certeza de que deseja sair deste círculo?",
"circleMembers": "Membros do círculo",
"circleMemberRequests": "Solicitações de entrada no círculo",
"admin": "Administrador",
"member": "Membro",
"pending": "Pendente",
"accept": "Aceitar",
"reject": "Rejeitar",
"makeAdmin": "Tornar administrador",
"makeMember": "Tornar membro",
"remove": "Remover",
"webhookURL": "URL do webhook",
"webhookDescription": "Digite uma URL de webhook para receber notificações de eventos do círculo",
"webhookPlaceholder": "https://sua-url-de-webhook.com"
},
"accountSettings": {
"title": "Configurações da conta",
"subscription": "Assinatura",
"subscriptionStatus": "Plano atual",
"free": "Gratuito",
"plus": "Plus",
"upgrade": "Fazer upgrade",
"cancel": "Cancelar",
"changePassword": "Alterar senha",
"password": "Senha",
"dangerZone": "Zona de perigo",
"dangerZoneDescription": "Após excluir sua conta, não há como voltar atrás. Tenha certeza antes de confirmar.",
"deleteAccount": "Excluir conta"
},
"localization": {
"title": "Localização",
"description": "Personalize idioma, formato de data e preferências regionais da sua conta.",
"language": "Idioma",
"languageDescription": "Selecione seu idioma preferido",
"dateFormat": "Formato de data",
"dateFormatDescription": "Escolha como as datas devem ser exibidas no aplicativo",
"timeFormat": "Formato de hora",
"timeFormatDescription": "Selecione o formato de 12 ou 24 horas",
"12hour": "12 horas (AM/PM)",
"24hour": "24 horas",
"firstDayOfWeek": "Primeiro dia da semana",
"firstDayOfWeekDescription": "Selecione com qual dia sua semana começa",
"sunday": "Domingo",
"monday": "Segunda-feira",
"saturday": "Sábado",
"formats": {
"mdy": "MM/DD/AAAA (EUA)",
"dmy": "DD/MM/AAAA (Europa)",
"ymd": "AAAA-MM-DD (ISO)",
"long": "Formato longo (ex.: 1 de janeiro de 2024)",
"short": "Formato curto (ex.: 1 de jan de 2024)"
}
},
"sidepanel": {
"title": "Personalização do painel lateral",
"description": "Personalize o layout e a visibilidade dos cartões no painel lateral. Esta seção está disponível apenas em dispositivos com telas grandes, como tablets e computadores."
},
"theme": {
"title": "Preferências de tema",
"description": "Escolha a aparência do site. Selecione um único tema ou sincronize com o sistema para alternar automaticamente entre os temas claro e escuro.",
"themeMode": "Modo do tema",
"light": "Claro",
"dark": "Escuro",
"system": "Sistema"
},
"notifications": {
"settingsSaved": "Configurações salvas com sucesso",
"settingsSaveFailed": "Falha ao salvar as configurações",
"invalidWebhook": "URL de webhook inválida"
},
"profile": {
"title": "Configurações do perfil",
"description": "Atualize seu nome de exibição e foto de perfil.",
"photoUpdated": "Foto atualizada",
"photoUpdatedMessage": "Sua foto de perfil foi atualizada com sucesso!",
"uploadFailed": "Falha no envio",
"uploadFailedMessage": "Falha ao enviar sua foto. Tente novamente.",
"profileUpdated": "Perfil atualizado",
"profileUpdatedMessage": "As informações do seu perfil foram salvas com sucesso!",
"updateFailed": "Falha na atualização",
"updateFailedMessage": "Não foi possível atualizar seu perfil. Verifique sua conexão e tente novamente.",
"changePhoto": "Alterar foto",
"displayName": "Nome de exibição",
"displayNamePlaceholder": "Digite seu nome de exibição",
"timezone": "Fuso horário",
"timezonePlaceholder": "Selecione seu fuso horário",
"save": "Salvar",
"cancel": "Cancelar"
},
"overview": {
"title": "Configurações",
"subtitle": "Personalize sua experiência e gerencie as preferências da sua conta",
"upgrade": {
"title": "Faça upgrade para o Plus",
"description": "Desbloqueie recursos poderosos para aumentar sua produtividade",
"button": "Fazer upgrade agora",
"features": {
"richText": "Descrições com texto formatado",
"notifications": "Notificações de tarefas",
"apiIntegrations": "Integrações de API",
"advancedAutomation": "Automação avançada"
}
},
"sections": {
"profile": {
"title": "Configurações do perfil",
"description": "Atualize as informações do seu perfil, foto, nome de exibição e preferências de fuso horário."
},
"circle": {
"title": "Configurações do círculo",
"description": "Gerencie seu círculo, convide membros e cuide das solicitações de entrada."
},
"account": {
"title": "Configurações da conta",
"description": "Gerencie sua assinatura, altere a senha e as opções de exclusão de conta."
},
"subaccounts": {
"title": "Contas gerenciadas",
"description": "Crie e gerencie subcontas para fazer login e concluir tarefas atribuídas."
},
"notifications": {
"title": "Notificações",
"description": "Configure notificações push, alertas por e-mail e destinos de notificação para tarefas."
},
"mfa": {
"title": "Autenticação multifator",
"description": "Adicione uma camada extra de segurança com MFA usando aplicativos autenticadores."
},
"apitokens": {
"title": "Tokens de API",
"description": "Gere e gerencie tokens de acesso para integrações de terceiros e acesso à API."
},
"storage": {
"title": "Configurações de armazenamento",
"description": "Faça backup e restauração dos seus dados, gerencie o armazenamento local e as preferências de sincronização."
},
"sidepanel": {
"title": "Personalização do painel lateral",
"description": "Personalize o layout e a visibilidade dos cartões na interface do painel lateral."
},
"theme": {
"title": "Preferências de tema",
"description": "Escolha seu tema preferido e configure o modo claro/escuro."
},
"localization": {
"title": "Localização",
"description": "Personalize idioma, formato de data, formato de hora e preferências regionais."
},
"advanced": {
"title": "Configurações avançadas",
"description": "Configure webhooks, atualizações em tempo real e outros recursos avançados para mais produtividade."
},
"developer": {
"title": "Configurações de desenvolvedor",
"description": "Veja informações técnicas sobre tokens de autenticação, conexões SSE e dados de depuração."
}
}
}
}

View File

@@ -1,14 +1,18 @@
import NavBar from '@/views/components/NavBar'
import { Button, Typography, useColorScheme } from '@mui/joy'
import Tracker from '@openreplay/tracker'
import { useCallback, useEffect } from 'react'
import { Outlet } from 'react-router-dom'
import { useRegisterSW } from 'virtual:pwa-register/react'
import { registerCapacitorListeners } from './CapacitorListener'
import PageTransition from './components/animations/PageTransition'
import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext'
import SSEProvider from './contexts/SSEContext'
import { AuthProvider } from './hooks/useAuth.jsx'
import useStatusBar from './hooks/useStatusBar'
import { useResource } from './queries/ResourceQueries'
import './styles/safe-area.css'
import SSEProvider from './contexts/SSEContext'
import { useNotification } from './service/NotificationProvider'
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
@@ -25,19 +29,16 @@ const remove = className => {
// TODO: Update the interval to at 60 minutes
const intervalMS = 5 * 60 * 1000 // 5 minutes
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()
useSyncOnReconnect()
// Initialize status bar with theme-aware configuration
useStatusBar()
const {
offlineReady: [offlineReady, setOfflineReady], // eslint-disable-line no-unused-vars
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker,
} = useRegisterSW({
@@ -96,10 +97,11 @@ const AppContent = () => {
}
function App() {
// startOpenReplay()
const resource = useResource() // eslint-disable-line no-unused-vars
const { mode, systemMode } = useColorScheme()
// startOpenReplay()
const setThemeClass = useCallback(() => {
const value = JSON.parse(localStorage.getItem('themeMode')) || mode
@@ -126,7 +128,7 @@ function App() {
}, [])
return (
<>
<div>
<NetworkBanner />
<AuthProvider>
@@ -134,7 +136,7 @@ function App() {
<AppContent />
</SSEProvider>
</AuthProvider>
</>
</div>
)
}

View File

@@ -8,6 +8,35 @@ import { PushNotifications } from '@capacitor/push-notifications'
import { focusManager } from '@tanstack/react-query'
import { RegisterDeviceToken } from './utils/Fetcher'
// NFC chore deep link: donetick://chores/123?auto_complete=true
const handleNFCChoreDeepLink = url => {
try {
const urlObj = new URL(url)
// donetick://chores/123 → host='chores', pathname='/123'
const choreId = urlObj.pathname.slice(1)
const autoComplete = urlObj.searchParams.get('auto_complete')
const path = `/chores/${choreId}${autoComplete ? '?auto_complete=' + autoComplete : ''}`
// getLaunchUrl() persists across every WebView reload caused by window.location.href.
// If we're already on the target page, skip to avoid an infinite reload loop.
if (window.location.pathname + window.location.search === path) return
console.log('[NFC] navigating to', path)
window.location.href = path
} catch (error) {
console.error('[NFC] Error handling chore deep link:', error)
}
}
const handleUrlOpen = url => {
console.log('[NFC] handleUrlOpen:', url)
if (url.startsWith('donetick://chores/')) {
handleNFCChoreDeepLink(url)
} else if (url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(url)
}
}
// OAuth callback handler for deep links
const handleOAuthDeepLink = async url => {
console.log('OAuth deep link received:', url)
@@ -215,16 +244,20 @@ const registerCapacitorListeners = () => {
return
}
localNotificationListenerRegistration()
// Register deep link handler for OAuth and other deep links
mobileApp.addListener('appUrlOpen', event => {
console.log('App URL opened:', event.url)
// Handle OAuth callback
if (event.url.startsWith('donetick://auth/')) {
handleOAuthDeepLink(event.url)
// Cold-start: app was launched by tapping an NFC tag (or other deep link)
mobileApp.getLaunchUrl().then(result => {
if (result?.url) {
console.log('[NFC] getLaunchUrl:', result.url)
handleUrlOpen(result.url)
}
})
// Foreground / singleTask resume: app was already running when the tag was tapped
mobileApp.addListener('appUrlOpen', event => {
console.log('[NFC] appUrlOpen:', event.url)
handleUrlOpen(event.url)
})
mobileApp.addListener('appStateChange', ({ isActive }) => {
focusManager.setFocused(isActive)
@@ -233,6 +266,9 @@ const registerCapacitorListeners = () => {
mobileApp.addListener('backButton', ({ canGoBack }) => {
if (canGoBack) {
window.history.back()
} else if (window.location.pathname !== '/') {
// No history (e.g. app launched directly to a chore via NFC) — go home
window.location.href = '/'
} else {
mobileApp.exitApp()
}

View File

@@ -12,7 +12,7 @@ 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, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils'
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
top: 10,
left: 10,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',

View File

@@ -32,7 +32,7 @@ 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 { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
import UserModal from '../views/Modals/Inputs/UserModal'
import SubscriptionModal from './SubscriptionModal'
@@ -116,7 +116,7 @@ const UserProfileAvatar = () => {
{isImpersonating ? (
<Box sx={{ position: 'relative' }}>
<Avatar
src={currentUser?.image || currentUser?.avatar}
src={resolvePhotoURL(currentUser?.image)}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
@@ -127,7 +127,7 @@ const UserProfileAvatar = () => {
}}
/>
<Avatar
src={userProfile?.image || userProfile?.avatar}
src={resolvePhotoURL(userProfile?.image || userProfile?.avatar)}
alt={userProfile?.displayName || userProfile?.name}
size='sm'
sx={{
@@ -162,7 +162,7 @@ const UserProfileAvatar = () => {
</Box>
) : (
<Avatar
src={currentUser?.image || currentUser?.avatar}
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
alt={currentUser?.displayName || currentUser?.name}
size='md'
sx={{
@@ -189,7 +189,7 @@ const UserProfileAvatar = () => {
<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}
src={resolvePhotoURL(currentUser?.image || currentUser?.avatar)}
alt={currentUser?.displayName || currentUser?.name}
size='lg'
sx={{

View File

@@ -1,4 +1,4 @@
import { Modal, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Modal, ModalClose, ModalDialog, ModalOverflow, Typography } from '@mui/joy'
import { Z_INDEX } from '../../constants/zIndex'
/**
@@ -78,6 +78,7 @@ const FadeModal = ({
},
}}
>
<ModalClose />
{title && (
<Typography level='title-lg' sx={{ fontWeight: 600, mb: 2 }}>
{title}

View File

@@ -0,0 +1,506 @@
import { Check, FilterList, Tune } from '@mui/icons-material'
import {
Avatar,
Badge,
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
import ActiveFilterChips from './filter/ActiveFilterChips'
/**
* Reusable filter bar component.
*
* Props:
* filterDefs - array of filter definitions:
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
* icon, options?, defaultValue?, filterFn }
* options item: { value, label, color?, icon?, avatar? }
* defaultValue: if the active value equals this, no chip is shown
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
* activeFilters - current filter state object { [id]: value }
* onSetFilter - (filterId, value | null) => void
* onClearAll - () => void
* resultCount - optional number shown in "Show N results" button
* totalCount - optional total for "N of M" label
*/
// ── Date range presets (no moment dependency — pure Date) ────────────────────
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
const DATE_RANGE_PRESETS = [
{
value: 'today',
label: 'Today',
getRange: () => {
const t = d(new Date())
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'yesterday',
label: 'Yesterday',
getRange: () => {
const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-week',
label: 'This Week',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-7-days',
label: 'Last 7 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-month',
label: 'This Month',
getRange: () => {
const n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-30-days',
label: 'Last 30 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-3-months',
label: 'Last 3 Months',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
]
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
const fmtDisplayDate = iso => {
if (!iso) return null
const dt = new Date(iso)
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({
filterDefs,
activeFilters,
onSetFilter,
onClearAll,
resultCount,
totalCount,
}) => {
const [isOpen, setIsOpen] = useState(false)
// ── Active count ───────────────────────────────────────────────────────────
const activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return false
if (def.defaultValue !== undefined && value === def.defaultValue) return false
if (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to)
return true
}).length
const hasActive = activeFilterCount > 0
const selectableChipSx = {
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
alignItems: 'center',
'& .MuiChip-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
}
const sectionBadgeChipSx = {
ml: 'auto',
fontSize: '0.7rem',
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
const modalCountChipSx = {
ml: 0.5,
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
// ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return null
if (def.type === 'single-select') {
if (def.defaultValue !== undefined && value === def.defaultValue) return null
return def.options?.find(o => o.value === value)?.label ?? def.label
}
if (def.type === 'boolean') return def.label
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label
}
return `${def.label} (${value.length})`
}
if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null
if (value.preset) {
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
}
const from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to)
if (from && to) return `${from} ${to}`
if (from) return `From ${from}`
if (to) return `Until ${to}`
return null
}
return null
}
// ── Handlers ───────────────────────────────────────────────────────────────
const handleMultiToggle = (defId, optValue) => {
const current = activeFilters[defId] || []
const next = current.includes(optValue)
? current.filter(v => v !== optValue)
: [...current, optValue]
onSetFilter(defId, next.length > 0 ? next : null)
}
const handleSingleToggle = (defId, optValue) => {
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
}
const handleBoolToggle = defId => {
onSetFilter(defId, activeFilters[defId] ? null : true)
}
const handleDateRangePreset = (defId, presetValue) => {
const current = activeFilters[defId] || {}
if (current.preset === presetValue) {
onSetFilter(defId, null)
return
}
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
}
const handleDateRangeInput = (defId, field, dateStr) => {
const current = activeFilters[defId] || {}
if (!dateStr) {
const next = { ...current, preset: null, [field]: null }
onSetFilter(defId, next.from || next.to ? next : null)
} else {
const iso =
field === 'to'
? new Date(dateStr + 'T23:59:59').toISOString()
: new Date(dateStr + 'T00:00:00').toISOString()
onSetFilter(defId, { ...current, preset: null, [field]: iso })
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Button
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
<ActiveFilterChips
chips={filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal
open={isOpen}
onClose={() => setIsOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
{activeFilterCount}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasActive}
onClick={onClearAll}
>
Clear all
</Button>
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
{resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{filterDefs.map((def, idx) => (
<Box key={def.id}>
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{def.icon && (
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
{def.icon}
</Box>
)}
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{def.label}
</Typography>
{/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{activeFilters[def.id].length} selected
</Chip>
)}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{getActiveChipLabel(def)}
</Chip>
)}
</Box>
{/* multi-select */}
{def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* single-select */}
{def.type === 'single-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = activeFilters[def.id] === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* boolean */}
{def.type === 'boolean' && (
<Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleBoolToggle(def.id)}
sx={selectableChipSx}
>
{def.label}
</Chip>
)}
{/* date-range */}
{def.type === 'date-range' && (() => {
const val = activeFilters[def.id] || {}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box>
)
})()}
</Box>
))}
</Box>
</BottomSheetModal>
</>
)
}
export default FilterBar

View File

@@ -0,0 +1,131 @@
import { Close } from '@mui/icons-material'
import { Box, Button, Chip, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
resultCount,
totalCount,
maxVisible = 2,
chipSize = 'md',
clearButtonSize = 'sm',
clearButtonSx,
containerSx,
chipSx,
overflowChipSx,
resultSx,
}) => {
if (!chips.length) {
return null
}
const visible = chips.slice(0, maxVisible)
const overflow = chips.length - maxVisible
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
...containerSx,
}}
>
{visible.map(({ key, label, onClear, color = 'primary' }) => (
<Chip
key={key}
size={chipSize}
variant='soft'
color={color}
endDecorator={
<Close
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
onClick={e => {
e.stopPropagation()
onClear?.()
}}
/>
}
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
alignItems: 'center',
'& .MuiChip-endDecorator': {
display: 'flex',
alignItems: 'center',
ml: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
...chipSx,
}}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size={chipSize}
variant='soft'
color='neutral'
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
'&:hover': { opacity: 0.85 },
...overflowChipSx,
}}
>
+{overflow} more
</Chip>
)}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
ml: 'auto',
flexShrink: 0,
...resultSx,
}}
>
{resultCount} / {totalCount}
</Typography>
)}
{onClearAll && (
<Button
size={clearButtonSize}
variant='plain'
color='neutral'
onClick={onClearAll}
sx={{
px: 0.5,
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
color: 'text.secondary',
minHeight: 0,
flexShrink: 0,
...clearButtonSx,
}}
>
Clear all
</Button>
)}
</Box>
)
}
export default ActiveFilterChips

View File

@@ -21,11 +21,13 @@ export const TIME_FORMATS = {
export const RTL_LANGUAGES = ['ar', 'he', 'fa', 'ur']
export const AVAILABLE_LANGUAGES = [
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
{ code: 'en', name: 'English', nativeName: 'English' },
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
{ code: 'fr', name: 'French', nativeName: 'Français' },
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
]
export const LocalizationProvider = ({ children }) => {

View File

@@ -1,47 +1,83 @@
import { Network } from '@capacitor/network'
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
class NetworkManager {
constructor() {
this.isOnline = true
this.isNetworkOn = null
this.init()
this.deviceOnline = true
this.serverReachable = true
this.offlineReason = null // 'device' | 'server' | null
this.connectionStatusListeners = []
this.queueSyncListeners = []
this.lastChecked = null
this.offlineSince = null
this.init()
}
// Effective online status: both device network AND server must be reachable
get isOnline() {
return this.deviceOnline && this.serverReachable
}
// Alias for backward compatibility (DeveloperSettings uses this)
get isNetworkOn() {
return this.deviceOnline
}
async init() {
const status = await Network.getStatus()
this.isNetworkOn = status.connected
this.deviceOnline = status.connected
this.lastChecked = Date.now()
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
}
Network.addListener('networkStatusChange', status => {
if (this.isNetworkOn !== status.connected) {
this.isNetworkOn = status.connected
if (this.deviceOnline !== status.connected) {
this.deviceOnline = status.connected
this.lastChecked = Date.now()
this.isOnline = status.connected
if (!status.connected) {
this.offlineReason = 'device'
this.offlineSince = Date.now()
} else {
// Device came back online — update reason based on server state
this.offlineReason = this.serverReachable ? null : 'server'
}
this.notifyConnectionStatus()
}
})
}
setOffline() {
if (this.isOnline === true) {
this.isOnline = false
// Called when a fetch() response is received (any HTTP status = server is up)
setServerReachable() {
if (!this.serverReachable) {
this.serverReachable = true
this.offlineReason = this.deviceOnline ? null : 'device'
this.notifyConnectionStatus()
this.offlineSince = Date.now() // Record the time when we went offline
}
}
setOnline() {
if (this.isOnline === false) {
this.isOnline = true
// Called when fetch() throws a network error (server unreachable)
// Only takes effect when offline mode is enabled
setServerUnreachable() {
if (!isOfflineFeatureEnabled()) return
if (this.serverReachable) {
this.serverReachable = false
this.offlineReason = 'server'
this.offlineSince = Date.now()
this.notifyConnectionStatus()
}
}
// Legacy methods kept for compatibility
setOffline() {
this.setServerUnreachable()
}
setOnline() {
this.setServerReachable()
}
notifyConnectionStatus() {
this.connectionStatusListeners.forEach(callback => {
callback(this.isOnline)
@@ -63,7 +99,6 @@ class NetworkManager {
)
}
registerBackendSyncListener(callback) {
// if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) {
this.queueSyncListeners.push(callback)
}

View File

@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
// Ensure apiClient is initialized with the correct URL
await apiClient.init()
const currentBaseURL = apiClient.getApiURL()
const isNative =
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
const response = await fetch(`${currentBaseURL}/auth/login`, config)
if (!response.ok) {
const error = await response.json()
return { success: false, error: error.message || 'Login failed' }
const res = await response.json()
return { success: false, error: res?.error || 'Login failed' }
}
const data = await response.json()

View File

@@ -0,0 +1,50 @@
import { Capacitor } from '@capacitor/core'
/**
* Normalizes a raw image string from the native document scanner into a
* format that can be used as an <img> src and passed to Tesseract.js.
*
* Android returns file:// or absolute paths → convert via Capacitor.convertFileSrc
* iOS returns raw base64 (no data: prefix) → prepend the data URI scheme
*/
function normalizeScannedImage(raw) {
if (!raw) return null
if (raw.startsWith('data:')) return raw
if (raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('content://')) return raw
if (raw.startsWith('/') || raw.startsWith('file://')) return Capacitor.convertFileSrc(raw)
// iOS base64 without prefix
return `data:image/jpeg;base64,${raw}`
}
/**
* Hook for native document scanning via @capgo/capacitor-document-scanner.
*
* On native: opens the OS document scanner (edge detection, perspective correction).
* On web: `scanDocument` returns null — callers should fall back to their own camera UI.
*/
export function useDocumentScanner() {
const isNativeScanner = Capacitor.isNativePlatform()
const scanDocument = async ({ maxDocuments = 1, quality = 90, letUserAdjustCrop = true } = {}) => {
if (!isNativeScanner) return { image: null, cancelled: false }
try {
const { DocumentScanner } = await import('@capgo/capacitor-document-scanner')
const { scannedImages } = await DocumentScanner.scanDocument({
croppedImageQuality: quality,
maxNumDocuments: maxDocuments,
letUserAdjustCrop,
})
if (!scannedImages?.length) return { image: null, cancelled: true }
const normalized = normalizeScannedImage(scannedImages[0])
return { image: normalized, cancelled: false }
} catch (e) {
console.error('[DocumentScanner] scan failed:', e)
return { image: null, cancelled: false, error: e.message }
}
}
return { isNativeScanner, scanDocument }
}

View File

@@ -0,0 +1,92 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({ entityType = 'chore_attachment', entityId, draftId } = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const uploadFile = useCallback(
async file => {
if (!isPlusAccount(userProfile)) {
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', String(entityId))
if (draftId) formData.append('draftId', draftId)
const response = await apiClient.upload('/assets/chore', formData)
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
})
return null
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
})
return null
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return null
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return null
}
const data = await response.json()
return resolvePhotoURL(data.url || data.sign)
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
return null
}
},
[entityType, entityId, draftId, showError, userProfile],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }
}

59
src/hooks/useFilter.js Normal file
View File

@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
/**
* Generic client-side filter hook.
*
* @param {Array} data - the full list to filter
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
*
* Each filterDef must include:
* id - unique string key
* type - 'multi-select' | 'boolean'
* filterFn - (item, filterValue) => boolean
*/
export const useFilter = (data, filterDefs) => {
const [activeFilters, setActiveFilters] = useState({})
const setFilter = (filterId, value) => {
setActiveFilters(prev => {
const isEmpty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0)
if (isEmpty) {
const { [filterId]: _removed, ...rest } = prev
return rest
}
return { ...prev, [filterId]: value }
})
}
const clearAll = () => setActiveFilters({})
const filteredData = useMemo(() => {
if (!data) return []
if (!Object.keys(activeFilters).length) return data
return data.filter(item =>
filterDefs.every(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return true
if (Array.isArray(value) && value.length === 0) return true
return def.filterFn(item, value)
}),
)
}, [data, activeFilters, filterDefs])
const activeFilterCount = Object.keys(activeFilters).length
return {
filteredData,
activeFilters,
setFilter,
clearAll,
activeFilterCount,
hasActiveFilters: activeFilterCount > 0,
}
}

56
src/hooks/useStatusBar.js Normal file
View File

@@ -0,0 +1,56 @@
import { useColorScheme } from '@mui/joy'
import { useEffect } from 'react'
import statusBarManager from '../utils/StatusBarManager'
/**
* Custom hook to manage status bar integration with Joy UI themes
* This hook automatically syncs the status bar style with the current theme
*/
export const useStatusBar = () => {
const { mode, systemMode } = useColorScheme()
useEffect(() => {
// Initialize status bar on mount
const initializeStatusBar = async () => {
await statusBarManager.initialize(mode)
}
initializeStatusBar()
// Cleanup on unmount
return () => {
statusBarManager.cleanup()
}
}, [mode]) // Include mode dependency
useEffect(() => {
// Update status bar when theme changes
const updateStatusBarTheme = async () => {
let resolvedTheme = mode
// Handle system mode by using the detected system theme
if (mode === 'system') {
resolvedTheme = systemMode || 'light'
}
// Update the status bar with the resolved theme
await statusBarManager.updateResolvedTheme(resolvedTheme)
// Also update the base theme for future reference
await statusBarManager.setTheme(mode)
// Notify any custom listeners
statusBarManager.notifyThemeChange(resolvedTheme)
}
updateStatusBarTheme()
}, [mode, systemMode]) // Update when either mode or systemMode changes
return {
statusBarManager,
currentTheme: mode,
resolvedTheme: mode === 'system' ? systemMode : mode,
}
}
export default useStatusBar

View File

@@ -8,7 +8,8 @@ import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
import { syncEngine } from '../utils/SyncEngine'
import { networkManager } from './NetworkManager'
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
export const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
export const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
export function useSyncOnReconnect() {
@@ -18,6 +19,7 @@ export function useSyncOnReconnect() {
useEffect(() => {
let pendingPollInterval
let cacheRefreshInterval
let serverProbeInterval
let resumeListener
let networkListener
const handleVisibilityChange = () => {
@@ -77,13 +79,27 @@ export function useSyncOnReconnect() {
cacheRefreshInterval = setInterval(() => {
runSync()
}, CACHE_REFRESH_MS)
// 6. Probe server every 15s when server is unreachable but device has network
serverProbeInterval = setInterval(async () => {
if (!networkManager.isOnline && networkManager.deviceOnline) {
await runSync()
}
}, SERVER_PROBE_MS)
}
const runSync = async () => {
if (!isOfflineFeatureEnabled()) return
const wasOffline = !networkManager.isOnline
const didSync = await syncEngine.sync()
if (didSync) {
queryClient.invalidateQueries()
// After recovery from server-unreachable, run a second pass to flush
// any commands that were skipped while offline
if (wasOffline && networkManager.isOnline) {
const didSync2 = await syncEngine.sync()
if (didSync2) queryClient.invalidateQueries()
}
}
}
@@ -98,6 +114,10 @@ export function useSyncOnReconnect() {
clearInterval(cacheRefreshInterval)
}
if (serverProbeInterval) {
clearInterval(serverProbeInterval)
}
if (networkListener) {
networkManager.unregisterNetworkListener(networkListener)
}

View File

@@ -8,6 +8,7 @@ import {
CreateChore,
DeleteChore,
DeleteChoreHistory,
GetChoreAttachments,
GetChoreByID,
GetChoreDetailById,
GetChoreHistory,
@@ -58,7 +59,8 @@ const mergePendingCreates = async chores => {
}
const isNetworkError = error =>
error instanceof TypeError && error.message === 'Failed to fetch'
(error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError'
const buildOfflineChore = task => ({
...task,
@@ -191,13 +193,7 @@ export const useCreateChore = () => {
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', false], oldData => {
if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] }
})
return createdChore.res
return { ...newTask, id: createdChore.res }
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineCreate(newTask)
@@ -258,7 +254,7 @@ export const useUpdateChore = () => {
),
}
})
return updatedChoreRes?.res || updatedChoreRes
return updatedChoreRes?.res || updatedChore
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineUpdate()
@@ -577,7 +573,45 @@ export const useMarkChoreComplete = () => {
})
return { res: { _pending: 'complete' } }
}
return MarkChoreComplete(choreId, body, completedDate, performer)
const queueOfflineComplete = async () => {
await commandQueue.enqueue(CommandType.COMPLETE_CHORE, choreId, {
id: choreId,
body,
completedDate,
performer,
})
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: Number(choreId),
completedBy: body?.completedBy || 0,
performedAt: completedDate || new Date().toISOString(),
notes: body?.note || null,
status: 1,
points: 0,
pending: true,
})
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId
? { ...chore, _pending: 'complete' }
: chore,
),
}
})
return { res: { _pending: 'complete' } }
}
try {
return await MarkChoreComplete(choreId, body, completedDate, performer)
} catch (error) {
if (isNetworkError(error)) {
return queueOfflineComplete()
}
throw error
}
},
onSuccess: (_, { choreId }) => {
queryClient.invalidateQueries(['chores'])
@@ -608,7 +642,26 @@ export const useSkipChore = () => {
})
return { res: { _pending: 'skip' } }
}
return SkipChore(choreId)
try {
return await SkipChore(choreId)
} catch (error) {
if (isNetworkError(error)) {
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
id: choreId,
})
queryClient.setQueryData(['chores'], oldData => {
if (!oldData) return oldData
return {
res: oldData.res.map(chore =>
chore.id === choreId ? { ...chore, _pending: 'skip' } : chore,
),
}
})
return { res: { _pending: 'skip' } }
}
throw error
}
},
onSuccess: (_, choreId) => {
queryClient.invalidateQueries(['chores'])
@@ -644,3 +697,19 @@ export const useRejectChore = () => {
},
})
}
export const useChoreAttachments = (choreId, hasAttachments = true) => {
return useQuery({
queryKey: ['choreAttachments', choreId],
queryFn: async () => {
const response = await GetChoreAttachments(choreId)
if (response && response.ok) {
return await response.json()
}
throw new Error('Failed to fetch attachments')
},
enabled: !!choreId && hasAttachments,
staleTime: 10 * 60 * 1000,
gcTime: 15 * 60 * 1000,
})
}

View File

@@ -0,0 +1,72 @@
const ENABLED_KEY = 'ai_prompt_cache_enabled'
const ENTRY_PREFIX = 'ai_prompt_cache_'
const INDEX_KEY = 'ai_prompt_cache_index'
function djb2(str) {
let hash = 5381
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash) ^ str.charCodeAt(i)
hash = hash >>> 0
}
return hash.toString(36)
}
export function isCacheEnabled() {
try {
return localStorage.getItem(ENABLED_KEY) === 'true'
} catch {
return false
}
}
export function setCacheEnabled(enabled) {
try {
localStorage.setItem(ENABLED_KEY, String(enabled))
} catch { /* ignore */ }
}
export function hashContent(content) {
return djb2(typeof content === 'string' ? content : JSON.stringify(content))
}
function getIndex() {
try {
return JSON.parse(localStorage.getItem(INDEX_KEY) || '[]')
} catch {
return []
}
}
export function getCached(hash) {
if (!isCacheEnabled()) return null
try {
const raw = localStorage.getItem(ENTRY_PREFIX + hash)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
export function setCached(hash, value) {
if (!isCacheEnabled()) return
try {
localStorage.setItem(ENTRY_PREFIX + hash, JSON.stringify(value))
const index = getIndex()
if (!index.includes(hash)) {
index.push(hash)
localStorage.setItem(INDEX_KEY, JSON.stringify(index))
}
} catch { /* storage full, ignore */ }
}
export function getCacheStats() {
return { count: getIndex().length }
}
export function clearCache() {
const index = getIndex()
index.forEach(h => {
try { localStorage.removeItem(ENTRY_PREFIX + h) } catch { /* ignore */ }
})
try { localStorage.removeItem(INDEX_KEY) } catch { /* ignore */ }
}

View File

@@ -0,0 +1,141 @@
import { Capacitor } from '@capacitor/core'
import { getCached, hashContent, setCached } from './AIPromptCache'
// Native-only local AI service using @capacitor/local-llm.
// On web, all methods return 'unavailable' / null — no WebLLM.
class LocalAIService {
constructor() {
this._availability = null
this._sessionId = 'donetick-summary'
this._warmedUp = false
}
get isNative() {
return Capacitor.isNativePlatform()
}
async checkAvailability() {
if (!this.isNative) {
this._availability = 'unavailable'
return 'unavailable'
}
try {
const { LocalLLM } = await import('@capacitor/local-llm')
const { status } = await LocalLLM.systemAvailability()
this._availability = status
return status
} catch (e) {
this._availability = 'unavailable'
return 'unavailable'
}
}
async getStatus() {
if (this._availability !== null) return this._availability
return this.checkAvailability()
}
async isAvailable() {
return (await this.getStatus()) === 'available'
}
resetAvailability() {
this._availability = null
}
async download(onStatusChange) {
if (!this.isNative) return
try {
const { LocalLLM } = await import('@capacitor/local-llm')
if (onStatusChange) {
LocalLLM.addListener('systemAvailabilityChange', ({ status }) => {
this._availability = status
onStatusChange(status)
})
}
await LocalLLM.download()
} catch {
// download not available on iOS, ignore
}
}
async warmup() {
if (this._warmedUp || !this.isNative) return
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.warmup({ sessionId: this._sessionId })
this._warmedUp = true
} catch {
// non-fatal
}
}
async _nativePrompt(text) {
await this.warmup()
try {
const { LocalLLM } = await import('@capacitor/local-llm')
const { text: out } = await LocalLLM.prompt({ prompt: text, sessionId: this._sessionId })
return out?.trim() || null
} finally {
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
}
}
// Plain chat — no tools. Returns answer string or null.
async plainChat(messages) {
const available = await this.isAvailable()
if (!available) return null
const cacheHash = hashContent(['plain', ...messages])
const cached = getCached(cacheHash)
if (cached) return cached
if (!this.isNative) return null
try {
const systemMsg = messages.find(m => m.role === 'system')?.content || ''
const userMsg = messages.find(m => m.role === 'user')?.content || ''
const result = await this._nativePrompt(`${systemMsg}\n\nUser: ${userMsg}\nAssistant:`)
if (result) setCached(cacheHash, result)
return result
} catch (e) {
console.error('[LocalAI] plainChat() failed:', e)
return null
}
}
// Returns the summary string or null if LLM is unavailable
async summarize(prompt) {
const available = await this.isAvailable()
if (!available) return null
const cacheHash = hashContent(prompt)
const cached = getCached(cacheHash)
if (cached) return cached
if (!this.isNative) return null
try {
await this.warmup()
const { LocalLLM } = await import('@capacitor/local-llm')
const { text } = await LocalLLM.prompt({ prompt, sessionId: this._sessionId })
const result = text?.trim() || null
if (result) setCached(cacheHash, result)
return result
} catch {
return null
} finally {
try {
const { LocalLLM } = await import('@capacitor/local-llm')
await LocalLLM.endSession({ sessionId: this._sessionId })
this._warmedUp = false
} catch { /* ignore */ }
}
}
}
export const localAIService = new LocalAIService()

View File

@@ -1,11 +1,136 @@
import { CapacitorNfc } from '@capgo/capacitor-nfc'
// Encodes a URL into an NDEF URI record (TNF=0x01, type='U')
const buildUriRecord = url => {
const encoder = new TextEncoder()
let prefixByte = 0x00
let uriStr = url
if (url.startsWith('https://')) {
prefixByte = 0x04
uriStr = url.slice(8)
} else if (url.startsWith('http://')) {
prefixByte = 0x03
uriStr = url.slice(7)
}
return {
tnf: 0x01,
type: [0x55],
id: [],
payload: [prefixByte, ...Array.from(encoder.encode(uriStr))],
}
}
// Decodes a URL from an NDEF URI record payload. Returns null if not a URI record.
export const decodeNdefUrl = record => {
if (!record || record.tnf !== 0x01) return null
if (record.type.length !== 1 || record.type[0] !== 0x55) return null
const payload = record.payload
if (!payload || payload.length === 0) return null
const prefixes = [
'',
'http://www.',
'https://www.',
'http://',
'https://',
'tel:',
'mailto:',
]
const prefix = prefixes[payload[0]] ?? ''
const uri = new TextDecoder().decode(new Uint8Array(payload.slice(1)))
return prefix + uri
}
// Starts a native NFC write session. Calls onWaiting once scanning is active,
// then onSuccess or onError when the write completes. Returns a cancel function.
export const startNativeNFCWrite = async (url, { onWaiting, onSuccess, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async () => {
if (done) return
done = true
try {
await CapacitorNfc.write({ records: [buildUriRecord(url)] })
await cleanup()
onSuccess()
} catch (err) {
await cleanup()
onError(err.message || 'Failed to write to NFC tag')
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag to write',
invalidateAfterFirstRead: true,
// Without FLAG_READER_SKIP_NDEF_CHECK (0x80), Android enumerates
// Ndef/NdefFormatable tech so the plugin can format blank tags on write.
androidReaderModeFlags: 0x0f, // NFC_A | NFC_B | NFC_F | NFC_V
})
onWaiting()
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Starts a native NFC scan session for reading. Calls onTag(url) when a URL
// NDEF record is found, or onError on failure. Returns a cancel function.
export const startNativeScan = async ({ onTag, onError }) => {
let listener = null
let done = false
const cleanup = async () => {
if (listener) {
await listener.remove()
listener = null
}
await CapacitorNfc.stopScanning().catch(() => {})
}
try {
listener = await CapacitorNfc.addListener('nfcEvent', async event => {
if (done) return
const records = event.tag?.ndefMessage ?? []
for (const record of records) {
const url = decodeNdefUrl(record)
if (url) {
done = true
await cleanup()
onTag(url)
return
}
}
})
await CapacitorNfc.startScanning({
alertMessage: 'Hold your device near the NFC tag',
invalidateAfterFirstRead: true,
})
return cleanup
} catch (err) {
await cleanup()
onError(err.message || 'Failed to start NFC session')
return async () => {}
}
}
// Legacy default export for web/PWA (NDEFReader API)
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
alert('URL written to NFC tag successfully!')
await ndef.write({ records: [{ recordType: 'url', data: url }] })
} catch (error) {
console.error('Error writing to NFC tag:', error)
alert('Error writing to NFC tag. Please try again.')

127
src/styles/safe-area.css Normal file
View File

@@ -0,0 +1,127 @@
/*
* Safe Area CSS Utilities
* These utilities provide consistent safe area handling across the app
* using CSS custom properties set by StatusBarManager
*/
:root {
/* Fallback values for safe area insets when not on native platforms */
--safe-area-inset-top: 0px;
--safe-area-inset-right: 0px;
--safe-area-inset-bottom: 0px;
--safe-area-inset-left: 0px;
}
/* Utility classes for safe area handling */
.safe-area-top {
padding-top: var(--safe-area-inset-top);
}
.safe-area-right {
padding-right: var(--safe-area-inset-right);
}
.safe-area-bottom {
padding-bottom: var(--safe-area-inset-bottom);
}
.safe-area-left {
padding-left: var(--safe-area-inset-left);
}
.safe-area-x {
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
.safe-area-y {
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
}
.safe-area-all {
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
}
/* Margin variants */
.safe-margin-top {
margin-top: var(--safe-area-inset-top);
}
.safe-margin-right {
margin-right: var(--safe-area-inset-right);
}
.safe-margin-bottom {
margin-bottom: var(--safe-area-inset-bottom);
}
.safe-margin-left {
margin-left: var(--safe-area-inset-left);
}
.safe-margin-x {
margin-left: var(--safe-area-inset-left);
margin-right: var(--safe-area-inset-right);
}
.safe-margin-y {
margin-top: var(--safe-area-inset-top);
margin-bottom: var(--safe-area-inset-bottom);
}
.safe-margin-all {
margin-top: var(--safe-area-inset-top);
margin-right: var(--safe-area-inset-right);
margin-bottom: var(--safe-area-inset-bottom);
margin-left: var(--safe-area-inset-left);
}
/* Height utilities that account for safe areas */
.min-h-screen-safe {
min-height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
}
.h-screen-safe {
height: calc(100vh - var(--safe-area-inset-top) - var(--safe-area-inset-bottom));
}
/* Top positioning that accounts for safe area */
.top-safe {
top: var(--safe-area-inset-top);
}
/* Bottom positioning that accounts for safe area */
.bottom-safe {
bottom: var(--safe-area-inset-bottom);
}
/* Fixed positioning utilities that respect safe areas */
.fixed-top-safe {
position: fixed;
top: var(--safe-area-inset-top);
left: 0;
right: 0;
z-index: 1030;
}
.fixed-bottom-safe {
position: fixed;
bottom: var(--safe-area-inset-bottom);
left: 0;
right: 0;
z-index: 1030;
}
/* Container that provides full safe area coverage */
.container-safe {
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
min-height: 100vh;
box-sizing: border-box;
}

View File

@@ -1,5 +1,6 @@
import { Preferences } from '@capacitor/preferences'
import { API_URL } from '../Config'
import { networkManager } from '../hooks/NetworkManager'
import { logout, RefreshToken } from './Fetcher'
import {
clearAllTokens,
@@ -17,7 +18,7 @@ class ApiClient {
}
async init(force = false) {
if (!force && this.initPromise) {
if (this.initPromise && !force) {
return this.initPromise
}
@@ -25,7 +26,9 @@ class ApiClient {
return Promise.resolve()
}
this.initPromise = this._doInit()
this.initPromise = this._doInit().finally(() => {
this.initPromise = null
})
return this.initPromise
}
@@ -144,15 +147,22 @@ class ApiClient {
async request(endpoint, options = {}) {
await this.init()
const url = `${this.customServerURL}${endpoint}`
// Abort after 10s so a dead/unreachable server doesn't hang the UI
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10_000)
const config = {
// credentials: 'include',
...options,
headers: this.getHeaders(options.headers),
signal: options.signal ?? controller.signal,
}
try {
// 1. Initial Request
let response = await fetch(url, config)
clearTimeout(timeoutId)
// 2. Check for 401 (Unauthorized)
if (response.status === 401) {
@@ -220,6 +230,9 @@ class ApiClient {
return response
} catch (error) {
clearTimeout(timeoutId)
// fetch() threw = network-level failure or timeout — mark server unreachable
networkManager.setServerUnreachable()
console.error('Request failed', error)
throw error
}

View File

@@ -25,6 +25,67 @@ export const ChoreStatus = Object.freeze({
PAUSED: 2,
PENDING_APPROVAL: 3,
})
const getDateGroupKey = dueDate =>
moment(dueDate).startOf('day').format('YYYY-MM-DD')
const getDateGroupName = dateKey =>
moment(dateKey, 'YYYY-MM-DD').format('dddd, MMM D')
const getDateGroupColor = dateKey => {
const today = moment().startOf('day')
const tomorrow = moment().add(1, 'day').startOf('day')
const groupDate = moment(dateKey, 'YYYY-MM-DD')
if (groupDate.isBefore(today)) {
return TASK_COLOR.OVERDUE
}
if (groupDate.isSame(today)) {
return TASK_COLOR.TODAY
}
if (groupDate.isSame(tomorrow)) {
return TASK_COLOR.TOMORROW
}
if (groupDate.isBefore(moment(today).add(8, 'days'))) {
return TASK_COLOR.NEXT_7_DAYS
}
if (groupDate.isSame(today, 'month')) {
return TASK_COLOR.LATER_THIS_MONTH
}
return TASK_COLOR.FUTURE
}
const buildActualDateGroups = chores => {
const groupedByDate = {}
const anytime = []
chores.forEach(chore => {
if (!chore.nextDueDate) {
anytime.push(chore)
return
}
const dateKey = getDateGroupKey(chore.nextDueDate)
if (!groupedByDate[dateKey]) {
groupedByDate[dateKey] = []
}
groupedByDate[dateKey].push(chore)
})
const dateGroups = Object.keys(groupedByDate)
.sort(
(a, b) =>
moment(a, 'YYYY-MM-DD').valueOf() - moment(b, 'YYYY-MM-DD').valueOf(),
)
.map(dateKey => ({
name: getDateGroupName(dateKey),
content: groupedByDate[dateKey],
color: getDateGroupColor(dateKey),
}))
return { dateGroups, anytime }
}
export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
@@ -34,7 +95,7 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
chores.sort(ChoreSorter)
var groups = []
switch (groupBy) {
case 'default':
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: [],
@@ -147,82 +208,21 @@ export const ChoresGrouper = (groupBy, chores, filter) => {
})
}
break
}
case 'due_date':
var groupRaw = {
Today: [],
Tomorrow: [],
'Next 7 Days': [],
'Later This Month': [],
Future: [],
Overdue: [],
Anytime: [],
}
chores.forEach(chore => {
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 = [
{
name: 'Overdue',
content: groupRaw['Overdue'],
color: TASK_COLOR.OVERDUE,
},
{ name: 'Today', content: groupRaw['Today'], color: TASK_COLOR.TODAY },
{
name: 'Tomorrow',
content: groupRaw['Tomorrow'],
color: TASK_COLOR.TOMORROW,
},
{
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,
},
{
case 'due_date': {
var { dateGroups: dueDateGroups, anytime: dueAnytime } =
buildActualDateGroups(chores)
groups = [...dueDateGroups]
if (dueAnytime.length > 0) {
groups.push({
name: 'Anytime',
content: groupRaw['Anytime'],
content: dueAnytime,
color: TASK_COLOR.ANYTIME,
},
]
})
}
break
}
case 'priority':
groupRaw = {
p1: [],

View File

@@ -728,6 +728,30 @@ const DeleteUser = (password, confirmation, transferOptions = []) => {
})
}
const UploadChoreAttachment = (file, entityType, { entityId, draftId } = {}) => {
const formData = new FormData()
formData.append('file', file)
formData.append('entityType', entityType)
if (entityId != null) formData.append('entityId', String(entityId))
if (draftId != null) formData.append('draftId', draftId)
return apiClient.upload('/assets/chore', formData)
}
const GetChoreAttachments = choreId => {
return Fetch(`/chores/${choreId}/attachments`, {
method: 'GET',
headers: HEADERS(),
})
}
const DeleteChoreAttachment = (choreId, filePath) => {
return Fetch(`/chores/${choreId}/attachments`, {
method: 'DELETE',
headers: HEADERS(),
body: JSON.stringify({ file_path: filePath }),
})
}
const CreateBackup = (encryptionKey, includeAssets = true, backupName = '') => {
return Fetch(`/backup/create`, {
method: 'POST',
@@ -933,6 +957,9 @@ const TrackFilterUsage = id => {
export {
AcceptCircleMemberRequest,
DeleteChoreAttachment,
GetChoreAttachments,
UploadChoreAttachment,
ApproveChore,
ArchiveChore,
CancelSubscription,

View File

@@ -10,9 +10,114 @@ const resolvePhotoURL = url => {
if (url.startsWith('http') || url.startsWith('https')) {
return url
}
if (url.startsWith('assets')) {
return apiClient.getAssetURL(url)
}
return url
return apiClient.getAssetURL(url)
}
export { isPlusAccount, resolvePhotoURL }
// Detect cloud storage pre-signed URLs (S3, GCS, Azure) that carry expiry params.
const isCloudSignedUrl = url => {
if (!url) return false
try {
return (
url.includes('X-Amz-Signature') ||
url.includes('X-Amz-Expires') ||
url.includes('X-Goog-Expires') ||
url.includes('expires')
)
} catch(e) {
return false
}
}
// Extract the storage key from a cloud signed URL so we can route it through
// the backend proxy (which re-signs on every request and never expires).
//
// Handles:
// Virtual-hosted S3: https://{bucket}.s3[.region].amazonaws.com/{key}?...
// Path-style S3: https://s3[.region].amazonaws.com/{bucket}/{key}?...
// Cloudflare R2: https://{bucket}.{accountid}.r2.cloudflarestorage.com/{key}?...
// GCS: https://storage.googleapis.com/{bucket}/{key}?...
// Azure Blob: https://{account}.blob.core.windows.net/{container}/{blob}?...
//
// The app stores files under an "assets/" prefix in the bucket but the backend
// proxy already mounts at /assets/, so we strip that leading segment when present.
const extractStorageKey = url => {
try {
const u = new URL(url)
const host = u.hostname
const rawPath = u.pathname.replace(/^\//, '')
let key
if (host.endsWith('.r2.cloudflarestorage.com')) {
// Virtual-hosted R2: bucket is in the host, key is the full path
key = rawPath
} else if (host.endsWith('.amazonaws.com')) {
if (host.startsWith('s3') || host.includes('.s3.')) {
// Path-style S3: first segment is the bucket — strip it
key = rawPath.split('/').slice(1).join('/')
} else {
// Virtual-hosted S3: bucket is in the host, path is the key
key = rawPath
}
} else if (host === 'storage.googleapis.com') {
// First segment is the bucket
key = rawPath.split('/').slice(1).join('/')
} else if (host.endsWith('.blob.core.windows.net')) {
// First segment is the container name
key = rawPath.split('/').slice(1).join('/')
} else {
key = rawPath
}
// The bucket stores files under an "assets/" prefix; the backend /assets/
// endpoint already adds that prefix, so strip it to avoid duplication.
if (key.startsWith('assets/')) {
key = key.slice('assets/'.length)
}
return key || null
} catch {
return null
}
}
// Scan an HTML string for <img> tags whose src is a cloud signed URL and
// replace them with backend proxy URLs (which generate fresh signed URLs on
// each request). Returns the patched HTML, or the original if nothing changed.
const refreshSignedUrlsInHtml = html => {
if (!html) return html
if (
!html.includes('dt-data-path') &&
!html.includes('X-Amz-') &&
!html.includes('X-Goog-') &&
!html.includes('sig') &&
!html.includes('.blob.core.windows.net')
) {
return html
}
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
const imgs = doc.querySelectorAll('img[src]')
let changed = false
imgs.forEach(img => {
const stablePath = img.getAttribute('dt-data-path')
const src = img.getAttribute('src')
let nextSrc = src
if (stablePath) {
nextSrc = resolvePhotoURL(stablePath)
} else if (isCloudSignedUrl(src)) {
nextSrc = resolvePhotoURL(extractStorageKey(src))
}
if (nextSrc && nextSrc !== src) {
img.setAttribute('src', nextSrc)
changed = true
}
})
return changed ? doc.body.innerHTML : html
}
export { isPlusAccount, refreshSignedUrlsInHtml, resolvePhotoURL }

View File

@@ -26,17 +26,32 @@ class SQLiteBackend {
constructor() {
this.db = null
this.initialized = false
this._initPromise = null
}
async init() {
if (this.initialized) return
// Return the in-flight promise if init is already underway (prevents double createConnection)
if (this._initPromise) return this._initPromise
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
this._initPromise = this._doInit().finally(() => {
this._initPromise = null
})
return this._initPromise
}
async _doInit() {
try {
this.db = await CapacitorSQLite.createConnection({
database: DB_NAME,
version: DB_VERSION,
encrypted: false,
mode: 'no-encryption',
})
} catch (err) {
// Connection already open (e.g. React StrictMode double-mount) — reuse it
if (!err?.message?.includes('already exists')) throw err
}
await CapacitorSQLite.open({ database: DB_NAME })
await CapacitorSQLite.execute({

View File

@@ -26,7 +26,7 @@ class StatusBarManager {
}
try {
// Configure basic status bar settings - use overlay: true for precise control
// Configure basic status bar settings
await StatusBar.setOverlaysWebView({ overlay: false })
await StatusBar.show()

View File

@@ -55,6 +55,8 @@ class SyncEngine {
// Step 3: Delta sync from server
await this._deltaSync()
// Sync succeeded — server is reachable (only sync success restores online status)
networkManager.setServerReachable()
this._notify({ syncing: false, lastSync: Date.now() })
return true
} catch (err) {
@@ -182,7 +184,7 @@ class SyncEngine {
let hasMore = true
let currentCursor = cursor
while (hasMore && networkManager.isOnline) {
while (hasMore && networkManager.deviceOnline) {
// Use apiClient.get which handles auth and returns a fetch Response
const response = await apiClient.get(
`/sync/changes?since=${currentCursor}`,

View File

@@ -9,6 +9,8 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { useUserProfile } from '../../queries/UserQueries'
import { apiClient } from '../../utils/ApiClient'
import { GetUserProfile } from '../../utils/Fetcher'
import { saveTokens } from '../../utils/TokenStorage'
import MFAVerificationModal from './MFAVerificationModal'
const AuthenticationLoading = () => {
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
@@ -17,6 +19,8 @@ const AuthenticationLoading = () => {
const [message, setMessage] = useState('Authenticating')
const [subMessage, setSubMessage] = useState('Please wait')
const [status, setStatus] = useState('pending')
const [mfaModalOpen, setMfaModalOpen] = useState(false)
const [mfaSessionToken, setMfaSessionToken] = useState('')
const { provider } = useParams()
useEffect(() => {
if (provider === 'oauth2' && !hasCalledHandleOAuth2.current) {
@@ -43,6 +47,29 @@ const AuthenticationLoading = () => {
})
})
}
const handleMFASuccess = async data => {
await saveTokens({
accessToken: data.token,
accessTokenExpiry: data.expire,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
setMfaModalOpen(false)
setMfaSessionToken('')
getUserProfileAndNavigateToHome()
}
const handleMFAClose = () => {
setMfaModalOpen(false)
setMfaSessionToken('')
setMessage('Authentication failed')
setSubMessage('Two-factor authentication was cancelled')
setStatus('error')
}
const handleOAuth2 = async () => {
// get provider from params:
const urlParams = new URLSearchParams(window.location.search)
@@ -64,37 +91,71 @@ const AuthenticationLoading = () => {
const redirectURI = Capacitor.isNativePlatform()
? 'donetick://auth/oauth2'
: `${window.location.origin}/auth/oauth2`
fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
}).then(response => {
if (response.status === 200) {
return response.json().then(data => {
localStorage.setItem('token', data.token)
localStorage.setItem('token_expiry', data.expire)
try {
const response = await fetch(`${baseURL}/auth/oauth2/callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code,
state: returnedState,
redirect_uri: redirectURI,
}),
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
})
} else {
if (!response.ok) {
console.error('Authentication failed')
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
return
}
})
const data = await response.json()
if (data.mfaRequired) {
if (!data.sessionToken) {
setMessage('Authentication failed')
setSubMessage('MFA session is missing. Please try again')
setStatus('error')
return
}
setMfaSessionToken(data.sessionToken)
setMfaModalOpen(true)
setMessage('Two-Factor Authentication Required')
setSubMessage('Please verify your login to continue')
return
}
if (!data.token && !data.access_token) {
setMessage('Authentication failed')
setSubMessage('No valid authentication token returned')
setStatus('error')
return
}
await saveTokens({
accessToken: data.token || data.access_token,
accessTokenExpiry: data.expire || data.access_token_expiry,
refreshToken: data.refresh_token,
refreshTokenExpiry: data.refresh_token_expiry,
})
const redirectUrl = Cookies.get('ca_redirect')
if (redirectUrl) {
Cookies.remove('ca_redirect')
Navigate(redirectUrl)
} else {
getUserProfileAndNavigateToHome()
}
} catch (error) {
console.error('Authentication request failed', error)
setMessage('Authentication failed')
setSubMessage('Please try again')
setStatus('error')
}
}
}
@@ -138,6 +199,17 @@ const AuthenticationLoading = () => {
<Link to='/login'>Go back Login</Link>
</Button>
)}
<MFAVerificationModal
open={mfaModalOpen}
onClose={handleMFAClose}
sessionToken={mfaSessionToken}
onSuccess={handleMFASuccess}
onError={() => {
setMessage('Authentication failed')
setSubMessage('Two-factor authentication failed. Please try again')
}}
/>
</Box>
</Container>
)

View File

@@ -1,17 +1,32 @@
import { Preferences } from '@capacitor/preferences'
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
import WifiIcon from '@mui/icons-material/Wifi'
import {
Alert,
Box,
Button,
CircularProgress,
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 { useResource } from '../../queries/ResourceQueries'
import { useNotification } from '../../service/NotificationProvider'
import { apiClient } from '../../utils/ApiClient'
const CONNECTION_TIMEOUT_MS = 8000
const LoginSettings = () => {
const Navigate = useNavigate()
const { refetch: refetchResource } = useResource()
const [serverURL, setServerURL] = React.useState('')
const { showError } = useNotification()
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
const [errorMessage, setErrorMessage] = React.useState('')
React.useEffect(() => {
Preferences.get({ key: 'customServerUrl' }).then(result => {
@@ -19,10 +34,95 @@ const LoginSettings = () => {
})
}, [])
const isValidServerURL = () => {
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
const isValidURL = url => {
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
}
const testConnection = async url => {
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
CONNECTION_TIMEOUT_MS,
)
try {
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
const response = await fetch(testURL, {
method: 'GET',
signal: controller.signal,
})
clearTimeout(timeoutId)
// Any HTTP response (even 401/404) means the server is reachable
if (response.status < 500) {
return { ok: true }
}
return {
ok: false,
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
}
} catch (err) {
clearTimeout(timeoutId)
if (err.name === 'AbortError') {
return {
ok: false,
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
}
}
return {
ok: false,
message:
'Unable to reach the server. Check the URL, port, and network connection.',
}
}
}
const handleSave = async () => {
const trimmedURL = serverURL.trim()
if (trimmedURL === '') {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
Navigate('/login')
return
}
if (!isValidURL(trimmedURL)) {
setStatus('error')
setErrorMessage(
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
)
return
}
setStatus('testing')
setErrorMessage('')
const result = await testConnection(trimmedURL)
if (!result.ok) {
setStatus('error')
setErrorMessage(result.message)
return
}
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
await apiClient.init(true)
refetchResource()
setStatus('success')
setTimeout(() => {
Navigate('/login')
}, 1200)
}
const handleURLChange = e => {
setServerURL(e.target.value)
if (status !== 'idle') {
setStatus('idle')
setErrorMessage('')
}
}
const isTesting = status === 'testing'
return (
<Container component='main' maxWidth='xs'>
<Box
@@ -38,7 +138,6 @@ const LoginSettings = () => {
sx={{
mt: 1,
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -51,13 +150,7 @@ const LoginSettings = () => {
<Typography level='h2'>
Done
<span
style={{
color: '#06b6d4',
}}
>
tick
</span>
<span style={{ color: '#06b6d4' }}>tick</span>
</Typography>
<Typography level='body2' alignSelf={'start'} mt={4}>
@@ -71,9 +164,22 @@ const LoginSettings = () => {
name='serverURL'
autoFocus
value={serverURL}
onChange={e => {
setServerURL(e.target.value)
}}
onChange={handleURLChange}
disabled={isTesting}
color={
status === 'success'
? 'success'
: status === 'error'
? 'danger'
: 'neutral'
}
endDecorator={
status === 'success' ? (
<CheckCircleOutlineIcon color='success' fontSize='small' />
) : status === 'error' ? (
<ErrorOutlineIcon color='error' fontSize='small' />
) : null
}
/>
<Typography mt={1} level='body-xs'>
@@ -81,72 +187,68 @@ const LoginSettings = () => {
own self-hosted Donetick server.
</Typography>
<Typography mt={1} level='body-xs'>
Please ensure to include the protocol (http:// or https://) and the
port number if necessary (default Donetick port is 2021).
Include the protocol (http:// or https://) and port if necessary
(default Donetick port is 2021).
</Typography>
{status === 'error' && (
<Alert
color='danger'
variant='soft'
startDecorator={<ErrorOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
{errorMessage}
</Alert>
)}
{status === 'success' && (
<Alert
color='success'
variant='soft'
startDecorator={<CheckCircleOutlineIcon />}
sx={{ mt: 2, width: '100%' }}
>
Connected! Redirecting to login...
</Alert>
)}
{status === 'testing' && (
<Alert
color='neutral'
variant='soft'
startDecorator={<WifiIcon />}
sx={{ mt: 2, width: '100%' }}
>
Testing connection to server...
</Alert>
)}
<Button
fullWidth
size='lg'
variant='solid'
sx={{
width: '100%',
mt: 3,
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
if (serverURL === '') {
Preferences.set({
key: 'customServerUrl',
value: API_URL,
}).then(() => {
Navigate('/login')
})
return
}
if (!isValidServerURL()) {
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(async () => {
// apiClient.customServerURL = serverURL + '/api/v1's
// Force re-initialization to reload from Preferences
await apiClient.init(true)
// refetch resource queries to update the API URL
refetchResource()
Navigate('/login')
})
}}
disabled={isTesting || status === 'success'}
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
onClick={handleSave}
startDecorator={
isTesting ? <CircularProgress size='sm' /> : undefined
}
>
Save
{isTesting ? 'Testing...' : 'Save & Connect'}
</Button>
<Button
fullWidth
size='lg'
variant='soft'
color='danger'
sx={{
width: '100%',
mb: 2,
border: 'moccasin',
borderRadius: '8px',
}}
onClick={() => {
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
() => {
refetchResource()
Navigate('/login')
},
)
disabled={isTesting}
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
onClick={async () => {
await Preferences.set({ key: 'customServerUrl', value: API_URL })
await apiClient.init(true)
refetchResource()
Navigate('/login')
}}
>
Cancel and Reset

View File

@@ -1,4 +1,12 @@
import { Add, ArrowDropDown, HorizontalRule, Save } from '@mui/icons-material'
import {
Add,
ArrowDropDown,
AttachFile,
Delete,
HorizontalRule,
Save,
UploadFile,
} from '@mui/icons-material'
import {
Avatar,
Box,
@@ -43,8 +51,13 @@ import {
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useNotification } from '../../service/NotificationProvider'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
import { GetAllCircleMembers, GetThings } from '../../utils/Fetcher'
import { isPlusAccount } from '../../utils/Helpers'
import {
DeleteChoreAttachment,
GetAllCircleMembers,
GetThings,
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
@@ -53,6 +66,7 @@ import LoadingComponent from '../components/Loading.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import { useLabels } from '../Labels/LabelQueries'
import AttachmentViewerModal from '../Modals/Inputs/AttachmentViewerModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import LabelModal from '../Modals/Inputs/LabelModal'
import { useProjects } from '../Projects/ProjectQueries'
@@ -83,7 +97,8 @@ const ChoreEdit = () => {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const [assignees, setAssignees] = useState([])
const [anyone, setAnyone] = useState(false)
const [assignableTo, setAssignableTo] = useState([])
const [performers, setPerformers] = useState([])
const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
const [dueDate, setDueDate] = useState(null)
@@ -116,7 +131,13 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
const [draftId] = useState(() => crypto.randomUUID())
const [attachments, setAttachments] = useState([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
const [attachmentViewerConfig, setAttachmentViewerConfig] = useState({
isOpen: false,
})
const [showSavePrivacyDefault, setShowSavePrivacyDefault] = useState(false)
const [privacySaved, setPrivacySaved] = useState(false)
const [showSaveNotificationDefault, setShowSaveNotificationDefault] =
@@ -158,6 +179,7 @@ const ChoreEdit = () => {
const Navigate = useNavigate()
const assignees = anyone ? performers : assignableTo
const HandleValidateChore = () => {
const errors = {}
@@ -330,6 +352,7 @@ const ChoreEdit = () => {
if (searchParams.get('clone') === 'true') {
newChoreId = null
}
const assignees = anyone ? [] : assignableTo
const chore = {
id: Number(newChoreId),
name: name,
@@ -359,6 +382,7 @@ const ChoreEdit = () => {
deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset,
priority: priority,
projectId: projectId === 'default' ? null : projectId,
draftId: newChoreId > 0 ? undefined : draftId,
}
let SaveFunction = createChoreMutation.mutateAsync
if (newChoreId > 0) {
@@ -419,15 +443,29 @@ const ChoreEdit = () => {
setIsNotificable(JSON.parse(defaultNotificationSetting))
}
const defaultAnyoneSetting = localStorage.getItem('defaultAnyoneSetting')
if (defaultAnyoneSetting != null) {
const savedAnyone = JSON.parse(defaultAnyoneSetting)
setAnyone(savedAnyone)
}
const defaultAssigneeSetting = localStorage.getItem(
'defaultAssigneeSetting',
)
if (defaultAssigneeSetting !== null) {
const savedAssignees = JSON.parse(defaultAssigneeSetting)
setAssignees(savedAssignees)
setAssignableTo(savedAssignees)
}
}
}, [])
useEffect(() => {
const anyoneSetting = localStorage.getItem('defaultAnyoneSetting')
const anyoneDirty = anyoneSetting !== JSON.stringify(anyone)
const assigneeSetting = localStorage.getItem('defaultAssigneeSetting')
const assigneeDirty = assigneeSetting !== JSON.stringify(assignableTo)
const dirty = anyoneDirty || (!anyone && assigneeDirty)
setShowSaveAssigneeDefault(dirty)
}, [anyone, assignableTo])
// Keyboard shortcuts
useEffect(() => {
@@ -477,7 +515,8 @@ const ChoreEdit = () => {
setChore(data.res)
setName(data.res.name ? data.res.name : '')
setDescription(data.res.description ? data.res.description : '')
setAssignees(data.res.assignees ? data.res.assignees : [])
setAssignableTo(data.res.assignees ? data.res.assignees : [])
setAnyone((data.res.assignees?.length || 0) === 0)
setAssignedTo(data.res.assignedTo)
setFrequencyType(data.res.frequencyType ? data.res.frequencyType : 'once')
@@ -558,6 +597,7 @@ const ChoreEdit = () => {
setCreatedBy(data.res.createdBy)
setUpdatedBy(data.res.updatedBy)
setAttachments(data.res.attachments || [])
}
}, [choreData, isChoreLoading, searchParams])
@@ -591,13 +631,15 @@ const ChoreEdit = () => {
if (assignees.length === 0) {
setAssignStrategy('no_assignee')
setAssignedTo(null)
} else if (assignees.length === 1) {
setAssignedTo(assignees[0].userId)
} else {
if (!assignees.some(a => a.userId === assignedTo)) {
setAssignedTo(assignees[0].userId)
}
if (assignStrategy === 'no_assignee') {
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
}
}
}, [assignees, assignStrategy])
}, [assignStrategy, assignedTo, assignees])
// useEffect(() => {
// if (performers.length > 0 && assignees.length === 0 && userProfile) {
@@ -614,7 +656,7 @@ const ChoreEdit = () => {
if (attemptToSave) {
HandleValidateChore()
}
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
const handleDelete = () => {
setConfirmModelConfig({
@@ -921,6 +963,175 @@ const ChoreEdit = () => {
/>
</Card>
</Box>
<Box mt={3}>
<Typography level='h4'>Attachments</Typography>
<Typography level='body-md'>Files attached to this task</Typography>
<Card variant='outlined' sx={{ mt: 2, p: 1.5 }}>
{attachments.length > 0 && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 1.5 }}>
{attachments.map((att, idx) => (
<Box
key={att.file_path || idx}
onClick={() => {
const url = resolvePhotoURL(att.sign || att.file_path)
const ext = (att.file_name || '')
.split('.')
.pop()
.toLowerCase()
const isImage = [
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'svg',
].includes(ext)
if (isImage) {
setAttachmentViewerConfig({
isOpen: true,
url,
fileName: att.file_name,
onClose: () =>
setAttachmentViewerConfig({ isOpen: false }),
})
} else {
const a = document.createElement('a')
a.href = url
a.download = att.file_name || 'attachment'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 1,
borderRadius: 'sm',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
cursor: 'pointer',
'&:hover': { bgcolor: 'neutral.softHoverBg' },
}}
>
<AttachFile sx={{ fontSize: 18, color: 'neutral.500' }} />
<Typography
level='body-sm'
sx={{ flex: 1, wordBreak: 'break-all' }}
>
{att.file_name}
</Typography>
{att.size_bytes && (
<Typography level='body-xs' color='neutral'>
{(att.size_bytes / 1024).toFixed(1)} KB
</Typography>
)}
{choreId && (
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={event => {
event.stopPropagation()
DeleteChoreAttachment(choreId, att.file_path)
.then(() => {
setAttachments(prev =>
prev.filter(a => a.file_path !== att.file_path),
)
})
.catch(() => {
showError({
title: 'Delete Failed',
message: 'Failed to delete attachment.',
})
})
}}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
)}
{!choreId && (
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={event => {
event.stopPropagation()
setAttachments(prev =>
prev.filter((_, i) => i !== idx),
)
}}
>
<Delete sx={{ fontSize: 18 }} />
</IconButton>
)}
</Box>
))}
</Box>
)}
<Button
component='label'
variant='outlined'
color='neutral'
size='sm'
startDecorator={
isUploadingAttachment ? null : <UploadFile />
}
loading={isUploadingAttachment}
sx={{ alignSelf: 'flex-start' }}
>
Upload File
<input
type='file'
hidden
onChange={async e => {
const file = e.target.files[0]
if (!file) return
setIsUploadingAttachment(true)
try {
const response = choreId
? await UploadChoreAttachment(file, 'chore_attachment', {
entityId: choreId,
})
: await UploadChoreAttachment(
file,
'chore_attachment_draft',
{ draftId },
)
if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
return
}
const data = await response.json()
setAttachments(prev => [
...prev,
{
file_path: data.path,
file_name: data.file_name,
size_bytes: data.size_bytes,
sign: data.sign,
},
])
} catch {
showError({
title: 'Upload Failed',
message: 'Failed to upload attachment.',
})
} finally {
setIsUploadingAttachment(false)
e.target.value = ''
}
}}
/>
</Button>
</Card>
</Box>
</Box>
{/* Section 2: Assignment & Responsibility */}
@@ -941,9 +1152,9 @@ const ChoreEdit = () => {
<ListItem key={'anyone'}>
<Checkbox
checked={assignees.length === 0}
checked={anyone}
onClick={() => {
setAssignees([])
setAnyone(!anyone)
setIsPrivate(false)
}}
overlay
@@ -956,19 +1167,25 @@ const ChoreEdit = () => {
{performers?.map((item, index) => (
<ListItem key={item.id}>
<Checkbox
checked={
assignees.find(a => a.userId == item.userId) != null
}
checked={assignableTo.some(a => a.userId == item.userId)}
disabled={anyone}
onClick={() => {
if (anyone) {
setAnyone(false)
setAssignableTo([{ userId: item.userId }])
return
}
const assignees = assignableTo
const setAssignees = setAssignableTo
if (assignees.some(a => a.userId === item.userId)) {
const newAssignees = assignees.filter(
a => a.userId !== item.userId,
)
setAnyone(newAssignees.length === 0)
setAssignees(newAssignees)
} else {
setAssignees([...assignees, { userId: item.userId }])
}
setShowSaveAssigneeDefault(true)
}}
overlay
disableIcon
@@ -998,9 +1215,13 @@ const ChoreEdit = () => {
},
}}
onClick={() => {
localStorage.setItem(
'defaultAnyoneSetting',
JSON.stringify(anyone),
)
localStorage.setItem(
'defaultAssigneeSetting',
JSON.stringify(assignees),
JSON.stringify(assignableTo),
)
setShowSaveAssigneeDefault(false)
}}
@@ -1026,17 +1247,12 @@ const ChoreEdit = () => {
}
disabled={assignees.length === 0}
value={assignedTo > -1 ? assignedTo : null}
onChange={(_, selectedUserId) => setAssignedTo(selectedUserId)}
>
{performers
?.filter(p => assignees.find(a => a.userId == p.userId))
?.filter(p => assignees.some(a => a.userId == p.userId))
.map((item, index) => (
<Option
value={item.userId}
key={item.displayName}
onClick={() => {
setAssignedTo(item.userId)
}}
>
<Option value={item.userId} key={item.displayName}>
{item.displayName}
</Option>
))}
@@ -1707,6 +1923,7 @@ const ChoreEdit = () => {
)}
</Button>
</Sheet>
<AttachmentViewerModal config={attachmentViewerConfig} />
<ConfirmationModal config={confirmModelConfig} />
{addLabelModalOpen && (
<LabelModal

View File

@@ -1,5 +1,6 @@
import {
Archive,
AttachFile,
CalendarMonth,
Check,
Checklist,
@@ -78,6 +79,7 @@ import {
import { offlineDB } from '../../utils/OfflineDB'
import Priorities from '../../utils/Priorities'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
import AttachmentBrowserModal from '../Modals/Inputs/AttachmentBrowserModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import LoadingComponent from '../components/Loading.jsx'
@@ -86,6 +88,7 @@ import RichTextEditor from '../components/RichTextEditor.jsx'
import SubTasks from '../components/SubTask.jsx'
import TimePassedCard from './TimePassedCard.jsx'
import TimerSplitButton from './TimerSplitButton.jsx'
import { refreshSignedUrlsInHtml } from '../../utils/Helpers.jsx'
const isNetworkError = err =>
err instanceof TypeError && err.message === 'Failed to fetch'
@@ -124,6 +127,7 @@ const ChoreView = () => {
const [chorePriority, setChorePriority] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [timerActionConfig, setTimerActionConfig] = useState({ isOpen: false })
const [attachmentBrowserOpen, setAttachmentBrowserOpen] = useState(false)
const { data: circleMembersData, isLoading: isCircleMembersLoading } =
useCircleMembers()
const { data: userProfile } = useUserProfile()
@@ -132,6 +136,7 @@ const ChoreView = () => {
const { data: choreData, isLoading: isChoreLoading } =
useChoreDetails(choreId)
const { data: choreHistoryData } = useChoreHistory(choreId)
const { data: pendingCmds } = usePendingCommands(choreId)
const choreHistory = choreHistoryData?.res || []
@@ -158,8 +163,8 @@ const ChoreView = () => {
document.title = 'Donetick: ' + choreData.res.name
setPerformers(circleMembersData.res)
const auto_complete = searchParams.get('auto_complete')
if (auto_complete === 'true') {
if (searchParams.get('auto_complete') === 'true') {
navigate({ search: '' }, { replace: true })
handleTaskCompletion()
}
}, [choreData, circleMembersData])
@@ -651,16 +656,14 @@ const ChoreView = () => {
justifyContent: 'center',
alignItems: 'center',
mb: 1,
flexWrap: 'wrap',
gap: 0.5,
}}
>
{chore?.labelsV2?.map((label, index) => (
<Chip
key={index}
sx={{
position: 'relative',
ml: index === 0 ? 0 : 0.5,
top: 2,
zIndex: 1,
backgroundColor: label?.color,
color: getTextColorFromBackgroundColor(label?.color),
}}
@@ -668,6 +671,20 @@ const ChoreView = () => {
{label?.name}
</Chip>
))}
{chore?.attachments?.length > 0 && (
<Chip
startDecorator={<AttachFile />}
size='md'
variant='soft'
color='neutral'
onClick={() => setAttachmentBrowserOpen(true)}
sx={{ cursor: 'pointer' }}
>
{chore.attachments.length}{' '}
{chore.attachments.length === 1 ? 'attachment' : 'attachments'}
</Chip>
)}
</Box>
</Box>
@@ -918,7 +935,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
@@ -986,7 +1003,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
dangerouslySetInnerHTML={{ __html: raw }}
dangerouslySetInnerHTML={{ __html: refreshSignedUrlsInHtml(raw) }}
/>
) : (
<Typography
@@ -1324,6 +1341,11 @@ const ChoreView = () => {
<ConfirmationModal config={confirmModelConfig} />
<ConfirmationModal config={timerActionConfig} />
<NoteViewerModal config={noteViewerConfig} />
<AttachmentBrowserModal
choreId={choreId}
isOpen={attachmentBrowserOpen}
onClose={() => setAttachmentBrowserOpen(false)}
/>
</Card>
</Container>
)

View File

@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,

View File

@@ -1,13 +1,16 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
Label,
Person,
PriorityHigh,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Box,
@@ -22,15 +25,18 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -107,6 +113,95 @@ const ArchivedTasks = () => {
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
// Unique labels present across all archived chores
const availableLabels = useMemo(() => {
const seen = {}
archivedChores.forEach(c => {
c.labelsV2?.forEach(l => { seen[l.id] = l })
})
return Object.values(seen)
}, [archivedChores])
const filterDefs = useMemo(
() => [
{
id: 'assignee',
label: 'Assignee',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.assignedTo),
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(availableLabels.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Label />,
options: availableLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'archivedAt',
label: 'Archived Date',
type: 'date-range',
icon: <Archive />,
filterFn: (item, value) => {
const date = new Date(item.updatedAt)
if (value.from && date < new Date(value.from)) return false
if (value.to && date > new Date(value.to)) return false
return true
},
},
],
[performers, availableLabels],
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
hasActiveFilters,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
@@ -335,11 +430,8 @@ const ArchivedTasks = () => {
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id)))
}
}
@@ -673,6 +765,15 @@ const ArchivedTasks = () => {
</Box>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={finalChores.length}
totalCount={filteredChores.length}
/>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
@@ -745,7 +846,7 @@ const ArchivedTasks = () => {
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
disabled={selectedChores.size === finalChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
)}
{/* Content */}
{filteredChores.length === 0 ? (
{finalChores.length === 0 ? (
<Box
sx={{
display: 'flex',
@@ -886,42 +987,43 @@ const ArchivedTasks = () => {
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
{searchTerm || hasActiveFilters
? 'No archived tasks found'
: 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: '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>
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
Clear search
</Button>
)}
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{finalChores.length} archived task
{finalChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
<ChoreListView
chores={filteredChores}
chores={finalChores}
// viewOnly={true}
showActions={false}
viewMode={viewMode}

View File

@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
for (let i = 0; i < chores.length; i++) {
const chore = chores[i]
try {
if (chore.notification === false || chore.nextDueDate === null) {
if (
chore.notification === false ||
chore.nextDueDate === null ||
chore.isActive === false
) {
continue
}
scheduleNotificationFromTemplate(

View File

@@ -2,18 +2,12 @@ import {
Add,
Bolt,
CalendarMonth,
CancelRounded,
CheckBox,
CheckBoxOutlineBlank,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
Sort,
Style,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
@@ -24,9 +18,6 @@ import {
Container,
Divider,
IconButton,
List,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
@@ -43,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
@@ -55,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
@@ -78,7 +68,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -107,7 +96,6 @@ const MyChores = () => {
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [showSearchFilter, setShowSearchFilter] = useState(false)
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
@@ -135,15 +123,12 @@ const MyChores = () => {
const {
searchTerm,
searchFilter,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm,
setSearchFilter,
setSelectedChoreFilterWithCache,
clearFilters,
} = useChoreFilters({
chores,
selectedProject,
@@ -193,6 +178,102 @@ const MyChores = () => {
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const quickFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Due Date',
type: 'single-select',
icon: <CalendarMonth />,
options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' },
{ value: 'Due Later', label: 'Due Later' },
{ value: 'No Due Date', label: 'No Due Date' },
{ value: 'Pending Approval', label: 'Pending Approval' },
],
filterFn: (item, value) => {
const now = new Date()
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
switch (value) {
case 'Overdue':
return d !== null && d < now
case 'Due today':
return d !== null && d.toDateString() === now.toDateString()
case 'Due in week':
return (
d !== null &&
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
d > now
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
case 'Pending Approval':
return item.status === 3
default:
return true
}
},
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
],
[userLabels],
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
if (!choresData?.res) {
return []
@@ -222,9 +303,9 @@ const MyChores = () => {
if (tempFilter || activeFilterId) {
// Advanced/custom filter active
choresToGroup = customFilteredChores
} else if (searchFilter !== 'All') {
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
choresToGroup = filteredChores
} else if (hasQuickFilters) {
// Quick filter active (Due date, Priority, Labels)
choresToGroup = quickFilteredChores
} else if (!selectedProject || selectedProject.id === 'default') {
// No project selected or default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
@@ -244,11 +325,11 @@ const MyChores = () => {
return sections
}, [
chores,
filteredChores,
quickFilteredChores,
customFilteredChores,
tempFilter,
activeFilterId,
searchFilter,
hasQuickFilters,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
@@ -398,7 +479,7 @@ const MyChores = () => {
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
if (oldFilter && !hasQuickFilters && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
@@ -409,12 +490,8 @@ const MyChores = () => {
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName && FILTERS[filterName]) {
const filtered = FILTERS[filterName](
selectedProject ? projectFilteredChores : chores,
)
setFilteredChores(filtered)
setSearchFilter(filterName)
if (filterName) {
setQuickFilter('status', filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
@@ -422,7 +499,7 @@ const MyChores = () => {
}, [
searchParams,
chores,
searchFilter,
hasQuickFilters,
activeFilterId,
savedFilters,
applyCustomFilter,
@@ -430,8 +507,7 @@ const MyChores = () => {
clearActiveFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
setFilteredChores,
setQuickFilter,
setViewMode,
setSelectedCalendarDate,
])
@@ -496,13 +572,47 @@ const MyChores = () => {
clearSelection,
})
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchTerm,
])
const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
choreSections,
openChoreSections,
handlers: {
@@ -553,25 +663,10 @@ const MyChores = () => {
const handleLabelFiltering = chipClicked => {
clearActiveFilter()
const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) {
const label = chipClicked.label
const labelFiltered = baseChores.filter(chore =>
chore.labelsV2.some(
l => l.id === label.id && l.created_by === label.created_by,
),
)
setFilteredChores(labelFiltered)
setSearchFilter('Label: ' + label.name)
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
const priority = chipClicked.priority
const priorityFiltered = baseChores.filter(
chore => chore.priority === priority,
)
setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority)
setQuickFilter('priority', [chipClicked.priority])
}
setSelectedCalendarDate(null)
}
@@ -623,8 +718,8 @@ const MyChores = () => {
const handleSearchChange = e => {
clearActiveFilter()
if (searchFilter !== 'All') {
setSearchFilter('All')
if (hasQuickFilters) {
clearQuickFilters()
}
const search = e.target.value
if (search === '') {
@@ -672,14 +767,13 @@ const MyChores = () => {
localStorage.setItem('openChoreSections', JSON.stringify(value))
}
const toggleViewMode = () => {
const modes = ['default', 'compact', 'calendar']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
@@ -740,42 +834,6 @@ const MyChores = () => {
// )
// }
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
let baseChores = projectFilteredChores
if (searchTerm?.length > 0 || searchFilter !== 'All') {
if (searchTerm?.length > 0) {
const projectFilteredForSearch = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(projectFilteredForSearch, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
} else if (searchFilter !== 'All') {
return filteredChores
}
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
projectFilteredChores,
searchTerm,
searchFilter,
filteredChores,
])
const getChoresForDate = useCallback(
date => {
const filteredChoresData = getFilteredChores
@@ -800,7 +858,7 @@ const MyChores = () => {
setChores(newChores)
setFilteredChores(newChores)
setSearchFilter('All')
clearQuickFilters()
}
// Show error state when API is unreachable
@@ -875,322 +933,96 @@ const MyChores = () => {
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
<ChoreToolbar
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',
message: `"${name}" has been saved`,
})
}
onClearAllFilters={() => {
clearQuickFilters()
clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(
projectsWithDefault.find(p => p.id === 'default') || null,
)
updateFilterUrl(null, null)
}}
>
<SearchBar
value={searchTerm}
onChange={handleSearchChange}
onClose={handleSearchClose}
onFocus={() => setShowSearchFilter(true)}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}
icon={<Sort />}
selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
setSelectedChoreFilterWithCache(filter)
// Clear active custom filter when quick filter is applied
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
onItemSelect={selected => {
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
setSearchFilter('All')
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
mouseClickHandler={handleMenuOutsideClick}
/>
{/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 &&
!hasProjectConditions &&
!hasFilterApplied && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* 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'
: viewMode === 'compact'
? 'Switch to Calendar View'
: 'Switch to Card View'
resultCount={
hasQuickFilters || hasFilterApplied
? getFilteredChores.length
: undefined
}
totalCount={
hasQuickFilters || hasFilterApplied
? projectFilteredChores.length
: undefined
}
selectedProject={selectedProject}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
selectedAssigneeFilter={selectedChoreFilter}
onAssigneeFilterChange={filter => {
setSelectedChoreFilterWithCache(filter)
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
>
{viewMode === 'default' ? (
<ViewAgenda />
) : viewMode === 'compact' ? (
<CalendarMonth />
) : (
<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>
{/* Search Filter with animation */}
<Box
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: showSearchFilter ? '150px' : '0',
opacity: showSearchFilter ? 1 : 0,
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
marginBottom: showSearchFilter ? 1 : 0,
}}
>
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
label={' Priority'}
k={'icon-menu-priority-filter'}
icon={<PriorityHigh />}
options={Priorities}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={searchFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
k={'icon-menu-labels-filter'}
label={' Labels'}
icon={<Style />}
options={userLabels}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={searchFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<Button
onClick={handleFilterMenuOpen}
variant='outlined'
startDecorator={<Grain />}
color={
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{' Other'}
</Button>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const baseChores = selectedProject
? projectFilteredChores
: chores
const filteredChores =
filterFunction.length === 2
? filterFunction(baseChores, userProfile?.id)
: filterFunction(baseChores)
setFilteredChores(filteredChores)
setSearchFilter(filter)
handleFilterMenuClose()
// Update URL with legacy filter parameter
const filterMap = {
'No Due Date': 'unplanned',
Overdue: 'overdue',
'Due today': 'today',
'Due in week': 'week',
'Due Later': 'later',
'Pending Approval': 'pending',
}
const urlFilter = filterMap[filter]
if (urlFilter) {
updateFilterUrl('filter', urlFilter)
}
}}
>
{filter}
<Chip
color={searchFilter === filter ? 'primary' : 'neutral'}
>
{(() => {
const baseChores = selectedProject
? projectFilteredChores
: chores
return FILTERS[filter].length === 2
? FILTERS[filter](baseChores, userProfile?.id)
.length
: FILTERS[filter](baseChores).length
})()}
</Chip>
</MenuItem>
))}
{searchFilter.startsWith('Label: ') ||
(searchFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
</div>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setShowSearchFilter(false)
setSearchTerm('')
setFilteredChores(chores)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
<CancelRounded />
</IconButton>
</div>
</Box>
{/* Custom Filters Section */}
<FilterSection
savedFilters={savedFilters}
activeFilterId={activeFilterId}
activeFilter={activeFilter}
hasProjectConditions={hasProjectConditions}
onFilterClick={filterId => {
onSavedFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
// Reset quick filter to 'anyone' when custom filter is applied
if (selectedChoreFilter !== 'anyone') {
setSelectedChoreFilterWithCache('anyone')
}
// Clear project selection if the filter has project conditions
const filter = savedFilters.find(f => f.id === filterId)
if (filter?.conditions?.some(c => c.type === 'project')) {
setSelectedProjectWithCache(null)
}
applyCustomFilter(filterId)
updateFilterUrl('filterId', filterId)
}
}}
onFilterDelete={deleteFilter}
onFilterPin={pinFilter}
onFilterEdit={filter => {
onSavedFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onClearActiveFilter={clearActiveFilter}
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
updateFilterUrl={updateFilterUrl}
onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter}
selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value)
setFilteredChores(chores)
clearQuickFilters()
}}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={toggleMultiSelectMode}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
onSearchClose={handleSearchClose}
searchInputRef={searchInputRef}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
<MultiSelectToolbar
@@ -1204,41 +1036,15 @@ const MyChores = () => {
onDelete={handleBulkDelete}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || searchFilter !== 'All'
? selectedChores.size === filteredChores.length
searchTerm?.length > 0 || hasQuickFilters
? selectedChores.size === getFilteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
/>
{/* Additional Filters Display */}
{searchFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={searchFilter}
onDelete={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Additional Filter: {searchFilter}
</Chip>
)}
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
@@ -1266,10 +1072,9 @@ const MyChores = () => {
<>
<Button
onClick={() => {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
// reset project and filters :
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
@@ -1604,6 +1409,7 @@ const MyChores = () => {
/>
</IconButton>
<IconButton
data-testid='open-add-task-modal'
color='primary'
variant='soft'
sx={{
@@ -1719,59 +1525,4 @@ const MyChores = () => {
)
}
const FILTERS = {
All: function (chores) {
return chores
},
Overdue: function (chores) {
return chores.filter(chore => {
if (chore.nextDueDate === null) return false
return new Date(chore.nextDueDate) < new Date()
})
},
'Due today': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
)
})
},
'Due in week': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
)
})
},
'Due Later': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
)
})
},
'Created By Me': function (chores, userID) {
return chores.filter(chore => {
return chore.createdBy === userID
})
},
'Assigned To Me': function (chores, userID) {
return chores.filter(chore => {
return chore.assignedTo === userID
})
},
'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})
},
'Pending Approval': function (chores) {
return chores.filter(chore => {
return chore.status === 3
})
},
}
export default MyChores

View File

@@ -1,9 +1,15 @@
import { Capacitor } from '@capacitor/core'
import DateModal from '../../Modals/Inputs/DateModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal'
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
const getNFCUrl = choreId =>
Capacitor.getPlatform() === 'android'
? `donetick://chores/${choreId}`
: `${window.location.origin}/chores/${choreId}`
const ChoreModals = ({
activeModal,
modalChore,
@@ -65,12 +71,13 @@ const ChoreModals = ({
<WriteNFCModal
config={{
isOpen: true,
url: `${window.location.origin}/chores/${modalChore.id}`,
url: getNFCUrl(modalChore.id),
onClose: onClose,
}}
/>
)}
{activeModal === 'nudge' && modalChore && (
<NudgeModal
config={{

View File

@@ -0,0 +1,920 @@
/**
* PROTOTYPE Unified Chore Toolbar
*
* Proposed design to replace the current 3-surface layout:
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
* + FilterBar (Due Date / Priority / Labels chips row)
* + FilterSection (saved/pinned filter chips row)
*
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
* + active filter chips appear inline next to Filter button
* + Filter button opens ONE unified bottom sheet containing:
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
* + Saved Filters section
*
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
* and the two rows below it (FilterBar + FilterSection) with:
* <ChoreToolbar ... />
*/
import {
ArrowDropDown,
CalendarMonth,
Check,
CheckBox,
CheckBoxOutlineBlank,
FilterList,
Save,
Sort,
Tune,
ViewAgenda,
ViewComfy,
ViewModule,
} from '@mui/icons-material'
import {
Badge,
Box,
Button,
ButtonGroup,
Chip,
Divider,
IconButton,
Input,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import FilterBuilderContent, {
CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from './FilterBuilderContent'
import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
// ─── sub-components for the Display sheet ────────────────────────────────────
const SectionHeader = ({ icon, label, badge }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon && (
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
)}
<Typography level='title-sm' fontWeight={600}>
{label}
</Typography>
{badge != null && (
<Chip
size='sm'
variant='solid'
color='primary'
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
>
{badge}
</Chip>
)}
</Box>
)
const OptionChips = ({ options, selected, multi, onToggle }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = multi
? (selected || []).includes(opt.value)
: selected === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
startDecorator={
opt.icon != null
? isSelected
? <Check sx={{ fontSize: 14 }} />
: opt.icon
: undefined
}
onClick={() => onToggle(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
// ─── main component ───────────────────────────────────────────────────────────
/**
* Props:
* -- Advanced filter (filter sheet) --
* members circle members for Assignee / Created By sections
* labels user labels for Labels section
* projects projects list (projectsWithDefault) for Projects section + Display sheet
* tempFilter current temp filter object { conditions, operator } or null
* tempFilterMeta metadata for temp filter, including saved-filter edit source when applicable
* applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter
* updateFilter (filterId, filterData) => Promise — updates an existing saved filter
* onFilterSaved (name) => void — called after successful save (for notifications)
*
* -- Result counts --
* resultCount / totalCount
*
* -- Clear all --
* onClearAllFilters () => void
*
* -- Saved filters --
* savedFilters [{ id, name, color, count, isPinned }]
* activeFilterId number | null
* onSavedFilterClick (id) => void
* onSavedFilterEdit (filter) => void
* onSavedFilterDelete (id) => void
* onSavedFilterPin (id) => void
*
* -- Display sheet --
* selectedProject current project object (for Display sheet section)
* onProjectSelect (project) => void
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
* selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels'
* onGroupBySelect (value) => void
* viewMode 'default' | 'compact' | 'calendar'
* onToggleViewMode (value?) => void
*
* -- Multi-select --
* isMultiSelectMode bool
* onToggleMultiSelect () => void
*
* -- Search --
* searchTerm / onSearchChange / onSearchClose / searchInputRef
* showKeyboardShortcuts
*/
const ChoreToolbar = ({
// advanced filter
members = [],
labels = [],
projects = [],
tempFilter,
tempFilterMeta,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
onFilterSaved,
// result counts
resultCount,
totalCount,
// clear all
onClearAllFilters,
// project (for Display sheet)
selectedProject,
onProjectSelect,
// assignee (for Display sheet)
selectedAssigneeFilter = 'anyone',
onAssigneeFilterChange,
// saved / custom
savedFilters = [],
activeFilterId,
onSavedFilterClick,
onSavedFilterEdit,
onSavedFilterDelete,
onSavedFilterPin,
// grouping
selectedGroupBy = 'default',
onGroupBySelect,
// view + multiselect
viewMode = 'default',
onToggleViewMode,
isMultiSelectMode,
onToggleMultiSelect,
// search
searchTerm,
onSearchChange,
onSearchClose,
searchInputRef,
showKeyboardShortcuts,
}) => {
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
const [localSelections, setLocalSelections] = useState(defaultSelections())
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ─────────────────────────────────────────────────────────────
const tempConditionCount = tempFilter?.conditions?.length || 0
const savedFilterActive = activeFilterId != null ? 1 : 0
const totalActiveCount = tempConditionCount + savedFilterActive
const hasAnyActive = totalActiveCount > 0
// ── inline chip strip ────────────────────────────────────────────────────────
const inlineChips = []
const getConditionChipLabel = condition => {
if (!condition?.type) return 'Filter'
const typeLabels = {
assignee: 'Assignee',
createdBy: 'Created By',
status: 'Status',
priority: 'Priority',
label: 'Labels',
project: 'Project',
dueDate: 'Due Date',
points: 'Points',
}
const typeLabel = typeLabels[condition.type] || 'Filter'
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
if (condition.type === 'dueDate') {
const dueDateLabel =
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
'Custom'
return `${typeLabel}: ${dueDateLabel}`
}
if (condition.type === 'points') {
const pointsOp =
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
condition.operator ||
''
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
}
const rawValues = Array.isArray(condition.value)
? condition.value
: condition.value != null
? [condition.value]
: []
const resolveLabel = value => {
if (condition.type === 'assignee' || condition.type === 'createdBy') {
const member = members.find(m => m.userId === value)
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
}
if (condition.type === 'label') {
return labels.find(l => l.id === value)?.name || String(value)
}
if (condition.type === 'project') {
if (value === 'default') return 'Default Project'
return projects.find(p => p.id === value)?.name || String(value)
}
return String(value)
}
if (rawValues.length === 0) {
return `${prefix}${typeLabel}`
}
if (rawValues.length === 1) {
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
}
return `${prefix}${typeLabel} (${rawValues.length})`
}
const clearConditionAtIndex = index => {
const nextConditions = (tempFilter?.conditions || []).filter(
(_condition, conditionIndex) => conditionIndex !== index,
)
if (nextConditions.length === 0) {
setLocalSelections(defaultSelections())
clearTempFilter?.()
return
}
const nextFilter = {
...tempFilter,
operator: tempFilter?.operator || 'AND',
conditions: nextConditions,
}
setLocalSelections(conditionsToSelections(nextConditions))
applyTempFilter?.(nextFilter)
}
const activeSavedFilter = savedFilterActive
? savedFilters.find(f => f.id === activeFilterId)
: null
const activeChipConditions = savedFilterActive
? activeSavedFilter?.conditions || []
: tempFilter?.conditions || []
activeChipConditions.forEach((condition, index) => {
inlineChips.push({
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: getConditionChipLabel(condition),
onClear: () => {
if (savedFilterActive) {
onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
},
})
})
// ── open filter sheet ────────────────────────────────────────────────────────
const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) {
const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
null
setEditingSavedFilter(
sourceFilter ||
(tempFilterMeta.sourceFilterId
? {
id: tempFilterMeta.sourceFilterId,
name: tempFilterMeta.sourceFilterName,
description: tempFilterMeta.sourceFilterDescription,
color: tempFilterMeta.sourceFilterColor,
}
: null),
)
} else {
setEditingSavedFilter(null)
}
} else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId)
setEditingSavedFilter(sf || null)
setLocalSelections(
sf?.conditions
? conditionsToSelections(sf.conditions)
: defaultSelections(),
)
} else {
setEditingSavedFilter(null)
setLocalSelections(defaultSelections())
}
setSavingFilter(false)
setSaveFilterName('')
setSaveMenuAnchorEl(null)
setFilterSheetOpen(true)
}
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
// ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => {
setLocalSelections(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next)
if (conditions.length > 0) {
applyTempFilter?.(
{ conditions, operator: 'AND' },
editingSavedFilter
? {
name: editingSavedFilter.name,
description: editingSavedFilter.description,
sourceFilterId: editingSavedFilter.id,
sourceFilterName: editingSavedFilter.name,
sourceFilterDescription: editingSavedFilter.description,
sourceFilterColor: editingSavedFilter.color,
isEditingSavedFilter: true,
}
: null,
)
} else {
clearTempFilter?.()
}
return next
})
}
// ── save filter ───────────────────────────────────────────────────────────────
const handleSaveFilter = () => {
const name = saveFilterName.trim()
if (!name) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
const usedColors = savedFilters.map(f => f.color)
const color =
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].value
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name)
})
setSavingFilter(false)
setSaveFilterName('')
setFilterSheetOpen(false)
}
const handleUpdateFilter = () => {
if (!editingSavedFilter?.id || !updateFilter) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
updateFilter(
editingSavedFilter.id,
{
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
},
)?.then?.(() => {
clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name)
})
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}
// ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const groupByOptions = [
{ value: 'default', label: 'Smart' },
{ value: 'due_date', label: 'Due Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'labels', label: 'Labels' },
]
const assigneeOptions = [
{ value: 'anyone', label: 'Everyone' },
{ value: 'assigned_to_me', label: 'Mine' },
{ value: 'available_for_me', label: 'Available to me' },
{ value: 'assigned_to_others', label: 'Others' },
]
const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
]
return (
<>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
justifyContent: 'space-between',
}}
>
{/* Search takes available space */}
<SearchBar
value={searchTerm}
onChange={onSearchChange}
onClose={onSearchClose}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
{/* Filter button */}
<Badge
badgeContent={totalActiveCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<IconButton
variant={hasAnyActive ? 'solid' : 'outlined'}
color={hasAnyActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={openFilterSheet}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
color={displayActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
title='View & Group'
>
{viewMode === 'calendar' ? (
<CalendarMonth />
) : viewMode === 'compact' ? (
<ViewModule />
) : (
<ViewAgenda />
)}
</IconButton>
{/* Multiselect */}
<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={onToggleMultiSelect}
title={
isMultiSelectMode
? 'Exit multi-select (Ctrl+S)'
: 'Multi-select (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
/>
</Box>
</Box>
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && (
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}
onClearAll={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
resultCount={resultCount}
totalCount={totalCount}
maxVisible={2}
chipSize='md'
clearButtonSize='sm'
clearButtonSx={{ color: 'text.secondary' }}
/>
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
open={filterSheetOpen}
onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasAnyActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
{totalActiveCount}
</Chip>
)}
</Box>
}
footer={
savingFilter ? (
<Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
>
<Input
size='sm'
placeholder='Filter name…'
value={saveFilterName}
onChange={e => setSaveFilterName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
autoFocus
sx={{ flex: 1 }}
/>
<Button
size='sm'
onClick={handleSaveFilter}
disabled={!saveFilterName.trim()}
>
Save
</Button>
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => setSavingFilter(false)}
>
Cancel
</Button>
</Box>
) : (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => {
setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
>
Clear all
</Button>
{activeConditions.length > 0 ? (
<>
<ButtonGroup variant='solid' color='primary'>
<Button
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
{resultCount != null
? `Show ${resultCount}`
: 'Done'}
</Button>
<IconButton
ref={saveMenuRef}
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
<Menu
anchorEl={saveMenuAnchorEl}
open={Boolean(saveMenuAnchorEl)}
onClose={() => setSaveMenuAnchorEl(null)}
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save Filter
</MenuItem>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
: '',
)
setSavingFilter(true)
}}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save as New Filter
</MenuItem>
</Menu>
</>
) : (
<Button
variant='solid'
color='primary'
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
Done
</Button>
)}
</Box>
)
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Full advanced filter content */}
<FilterBuilderContent
selections={localSelections}
onSelectionsChange={handleSelectionsChange}
members={members}
labels={labels}
projects={projects}
/>
{/* Saved filters section */}
{savedFilters.length > 0 && (
<>
<Divider sx={{ my: 2.5 }} />
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
Saved Filters
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{savedFilters.map(filter => {
const isActive = activeFilterId === filter.id
return (
<Chip
key={filter.id}
variant={isActive ? 'solid' : 'soft'}
color='neutral'
startDecorator={
isActive ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Chip size='sm' variant='plain' color='neutral'>
{filter.count ?? 0}
</Chip>
)
}
onClick={() => {
onSavedFilterClick?.(filter.id)
if (!isActive) setFilterSheetOpen(false)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
...(filter.color && !isActive
? { borderColor: filter.color }
: {}),
}}
>
{filter.name}
</Chip>
)
})}
</Box>
</>
)}
</Box>
</BottomSheetModal>
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal
open={displaySheetOpen}
onClose={() => setDisplaySheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ViewAgenda sx={{ fontSize: 20 }} />
Display
</Box>
}
footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
Done
</Button>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* View section */}
<SectionHeader label='View' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{viewOptions.map(opt => (
<Chip
key={opt.value}
variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={
viewMode === opt.value
? <Check sx={{ fontSize: 14 }} />
: opt.icon
}
onClick={() => onToggleViewMode?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Group by section */}
<SectionHeader
icon={<Sort />}
label='Group by'
badge={
selectedGroupBy !== 'default'
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
: null
}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{groupByOptions.map(opt => (
<Chip
key={opt.value}
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
onClick={() => onGroupBySelect?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
{/* Show tasks for section */}
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FilterList />}
label='Show tasks for'
badge={
selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
: null
}
/>
<OptionChips
options={assigneeOptions}
selected={selectedAssigneeFilter}
multi={false}
onToggle={v => onAssigneeFilterChange?.(v)}
/>
</Box>
</BottomSheetModal>
</>
)
}
export default ChoreToolbar

View File

@@ -9,11 +9,11 @@ import {
import {
Box,
Chip,
IconButton,
Menu,
MenuItem,
Tooltip,
Typography,
IconButton,
} from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
@@ -116,6 +116,8 @@ const CustomFilterChips = ({
px: 1.0,
py: 0.5,
height: 32,
display: 'flex',
alignItems: 'center',
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
...(hasCustomColor && {
@@ -185,6 +187,10 @@ const CustomFilterChips = ({
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'flex',
alignItems: 'center',
height: '100%',
lineHeight: 1,
...(hasCustomColor && {
color: textColor,
}),

View File

@@ -0,0 +1,477 @@
import {
CalendarMonth,
Check,
FolderOpen,
Label,
Person,
PriorityHigh,
Stars,
TaskAlt,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
import Priorities from '../../../utils/Priorities'
export const DUE_DATE_OPTIONS = [
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
{ value: 'isDueToday', label: 'Today', color: 'warning' },
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
]
export const POINTS_OPERATORS = [
{ value: 'greaterThan', label: '>' },
{ value: 'greaterThanOrEqual', label: '>=' },
{ value: 'equals', label: '=' },
{ value: 'lessThanOrEqual', label: '<=' },
{ value: 'lessThan', label: '<' },
]
export const CHORE_STATUSES = [
{ value: 0, label: 'Active' },
{ value: 1, label: 'Started' },
{ value: 2, label: 'In Progress' },
{ value: 3, label: 'Pending Approval' },
]
export const defaultSelections = () => ({
assignee: { operator: 'is', values: [] },
createdBy: { operator: 'is', values: [] },
status: { operator: 'is', values: [] },
priority: { operator: 'is', values: [] },
label: { operator: 'is', values: [] },
project: { operator: 'is', values: [] },
dueDate: { operator: null },
points: { operator: 'greaterThan', value: 0, active: false },
})
export const conditionsToSelections = conditions => {
const sel = defaultSelections()
if (!conditions) return sel
conditions.forEach(c => {
if (c.type === 'dueDate') {
sel.dueDate = { operator: c.operator }
} else if (c.type === 'points') {
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
} else if (c.type in sel) {
sel[c.type] = {
operator: c.operator ?? 'is',
values: Array.isArray(c.value)
? c.value
: c.value != null
? [c.value]
: [],
}
}
})
return sel
}
export const selectionsToConditions = selections => {
const conditions = []
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
type => {
if (selections[type].values?.length > 0) {
conditions.push({
type,
operator: selections[type].operator,
value: selections[type].values,
})
}
},
)
if (selections.dueDate.operator) {
conditions.push({
type: 'dueDate',
operator: selections.dueDate.operator,
value: null,
})
}
if (selections.points.active) {
conditions.push({
type: 'points',
operator: selections.points.operator,
value: selections.points.value,
})
}
return conditions
}
const SectionHeader = ({ icon, label, children }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
{children}
</Box>
)
const IncludeExcludeToggle = ({
value,
onChange,
labels = ['Include', 'Exclude'],
}) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[
{ op: 'is', label: labels[0] },
{ op: 'isNot', label: labels[1] },
].map(o => (
<Chip
key={o.op}
size='sm'
variant={value === o.op ? 'solid' : 'soft'}
color={
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
}
onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{o.label}
</Chip>
))}
</Box>
)
/**
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
* and the AdvancedFilterBuilder save modal.
*
* `onSelectionsChange` must accept either a new selections object or a
* functional updater `prev => next` (same contract as React's setState setter).
*/
const FilterBuilderContent = ({
selections,
onSelectionsChange,
members = [],
labels = [],
projects = [],
}) => {
const toggleValue = (type, value) =>
onSelectionsChange(prev => {
const cur = prev[type].values || []
const next = cur.includes(value)
? cur.filter(v => v !== value)
: [...cur, value]
return { ...prev, [type]: { ...prev[type], values: next } }
})
const setOperator = (type, op) =>
onSelectionsChange(prev => ({
...prev,
[type]: { ...prev[type], operator: op },
}))
const toggleDueDate = op =>
onSelectionsChange(prev => ({
...prev,
dueDate: { operator: prev.dueDate.operator === op ? null : op },
}))
const setPointsOperator = op =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, operator: op, active: true },
}))
const setPointsValue = val =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, value: val, active: val > 0 },
}))
const chipRow = (type, options, getChipProps) => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = selected.includes(opt.value)
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={
isSelected
? <Check sx={{ fontSize: 14 }} />
: (extra.startDecorator ?? null)
}
onClick={() => toggleValue(type, opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
}
const personChipRow = type => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{members.map(m => {
const isSelected = selected.includes(m.userId)
return (
<Chip
key={m.userId}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar
src={m.image}
alt={m.displayName}
sx={{ '--Avatar-size': '20px' }}
/>
)
}
onClick={() => toggleValue(type, m.userId)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{m.displayName || m.username}
</Chip>
)
})}
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Assignee */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Assignee'>
<IncludeExcludeToggle
value={selections.assignee.operator}
onChange={op => setOperator('assignee', op)}
/>
</SectionHeader>
{personChipRow('assignee')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Created By */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Created By'>
<IncludeExcludeToggle
value={selections.createdBy.operator}
onChange={op => setOperator('createdBy', op)}
/>
</SectionHeader>
{personChipRow('createdBy')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Status */}
<SectionHeader icon={<TaskAlt />} label='Status'>
<IncludeExcludeToggle
value={selections.status.operator}
onChange={op => setOperator('status', op)}
/>
</SectionHeader>
{chipRow('status', CHORE_STATUSES)}
<Divider sx={{ my: 2.5 }} />
{/* Priority */}
<SectionHeader icon={<PriorityHigh />} label='Priority'>
<IncludeExcludeToggle
value={selections.priority.operator}
onChange={op => setOperator('priority', op)}
/>
</SectionHeader>
{chipRow(
'priority',
Priorities.map(p => ({ value: p.value, label: p.name })),
(opt, isSelected) => ({
color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
: 'neutral',
startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon
: null,
}),
)}
<Divider sx={{ my: 2.5 }} />
{/* Due Date */}
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => toggleDueDate(opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Labels */}
{labels.length > 0 && (
<>
<SectionHeader icon={<Label />} label='Labels'>
<IncludeExcludeToggle
value={selections.label.operator}
onChange={op => setOperator('label', op)}
labels={['Has', "Doesn't Have"]}
/>
</SectionHeader>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{labels.map(lbl => {
const isSelected = selections.label.values.includes(lbl.id)
return (
<Chip
key={lbl.id}
variant={isSelected ? 'solid' : 'soft'}
color='neutral'
startDecorator={
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: lbl.color || '#90a4ae',
flexShrink: 0,
}}
/>
}
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
onClick={() => toggleValue('label', lbl.id)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
...(isSelected && {
outline: '2px solid',
outlineColor: 'primary.400',
}),
}}
>
{lbl.name}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Projects */}
{projects.length > 0 && (
<>
<SectionHeader icon={<FolderOpen />} label='Projects'>
<IncludeExcludeToggle
value={selections.project.operator}
onChange={op => setOperator('project', op)}
/>
</SectionHeader>
{chipRow('project', [
{ value: 'default', label: 'Default Project' },
...projects
.filter(p => p.id !== 'default')
.map(p => ({ value: p.id, label: p.name })),
])}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Points */}
<SectionHeader icon={<Stars />} label='Points' />
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
{POINTS_OPERATORS.map(op => (
<Chip
key={op.value}
size='sm'
variant={
selections.points.operator === op.value && selections.points.active
? 'solid'
: 'soft'
}
color={
selections.points.operator === op.value && selections.points.active
? 'primary'
: 'neutral'
}
onClick={() => setPointsOperator(op.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontWeight: 600,
}}
>
{op.label}
</Chip>
))}
<Input
type='number'
size='sm'
value={selections.points.value}
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
sx={{ width: 80 }}
slotProps={{ input: { min: 0 } }}
/>
{selections.points.active && (
<Chip
size='sm'
variant='soft'
color='danger'
onClick={() =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, active: false, value: 0 },
}))
}
sx={{ cursor: 'pointer' }}
>
Clear
</Chip>
)}
</Box>
</Box>
)
}
export default FilterBuilderContent

View File

@@ -9,12 +9,10 @@ const MyChoreHeader = ({
tempFilter,
tempFilterMeta,
}) => {
if (
!activeFilterId &&
!tempFilter &&
(!selectedProject || selectedProject.id === 'default')
)
return null
const isVisible =
!!activeFilterId ||
!!tempFilter ||
(!!selectedProject && selectedProject.id !== 'default')
const renderIcon = () => {
if (tempFilter) {
@@ -53,18 +51,41 @@ const MyChoreHeader = ({
: activeFilter?.description || selectedProject?.description
return (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{renderIcon()}
<Stack sx={{ flex: 1 }}>
<Typography level='h3' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
{name}
</Typography>
{description && (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{description}
<Box
sx={{
overflow: 'hidden',
maxHeight: isVisible ? '120px' : '0',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-8px)',
transition:
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out, transform 0.3s ease-in-out',
marginBottom: isVisible ? 2 : 0,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{renderIcon()}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
{name}
</Typography>
)}
</Stack>
<Box
sx={{
overflow: 'hidden',
maxHeight: description ? '40px' : '0',
opacity: description ? 1 : 0,
transition:
'max-height 0.3s ease-in-out, opacity 0.3s ease-in-out',
}}
>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
{description}
</Typography>
</Box>
</Stack>
</Box>
</Box>
)
}

View File

@@ -12,6 +12,7 @@ import {
MarkChoreComplete,
NudgeChore,
RejectChore,
SaveChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
@@ -420,8 +421,8 @@ export const useChoreActions = ({
c => c.id !== chore.id,
)
setChores(newChores)
updateChoreInState(chore.id, 'deleted')
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries(['chores'])
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
@@ -471,7 +472,7 @@ export const useChoreActions = ({
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState(data, 'archive')
updateChoreInState(chore, 'archive')
resolve(data)
},
onError: async error => {
@@ -664,6 +665,28 @@ export const useChoreActions = ({
}
break
case 'moveToProject': {
const project = extraData?.project
const projectId = project?.id === null ? null : project?.id
const updatedChore = { ...chore, projectId }
try {
const response = await SaveChore(updatedChore)
if (response.ok) {
updateChoreInState(updatedChore, 'moved-to-project')
showSuccess({
title: 'Task Moved',
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: 'Failed to move task',
message: error?.message || 'Unable to move task to project',
})
}
break
}
case 'completeWithNote':
case 'completeWithPastDate':
case 'changeAssignee':

View File

@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
}, [chores, activeFilter, tempFilter, context])
const applyCustomFilter = useCallback(filterId => {
setTempFilter(null)
setTempFilterMeta(null)
setActiveFilterId(filterId)
}, [])

View File

@@ -1,52 +1,226 @@
import { HomeRounded, Login } from '@mui/icons-material'
import { Box, Button, CircularProgress, Container, Typography } from '@mui/joy'
import { Link } from 'react-router-dom'
import Logo from '../Logo' // Adjust the import path as necessary
import {
BugReportRounded,
CloudOffRounded,
ContentCopyRounded,
ErrorRounded,
ExpandMoreRounded,
HomeRounded,
LockRounded,
RefreshRounded,
SearchOffRounded,
} from '@mui/icons-material'
import { Box, Button, IconButton, Snackbar, Typography } from '@mui/joy'
import { useState } from 'react'
import { Link, useRouteError } from 'react-router-dom'
const getErrorKind = error => {
if (!error)
return { label: 'Unknown Error', color: 'danger', Icon: ErrorRounded }
const status = error?.status ?? error?.response?.status
if (status === 404)
return {
label: '404 · Not Found',
color: 'warning',
Icon: SearchOffRounded,
}
if (status === 401 || status === 403)
return {
label: `${status} · Unauthorized`,
color: 'warning',
Icon: LockRounded,
}
if (status >= 500)
return {
label: `${status} · Server Error`,
color: 'danger',
Icon: CloudOffRounded,
}
if (error?.name === 'TypeError')
return { label: 'Runtime Error', color: 'danger', Icon: BugReportRounded }
if (error?.name === 'SyntaxError')
return { label: 'Syntax Error', color: 'danger', Icon: BugReportRounded }
return { label: 'Unexpected Error', color: 'danger', Icon: ErrorRounded }
}
const safeMessage = error => {
const msg = error?.message ?? error?.statusText
if (!msg || msg === '[object Object]') return null
return msg
}
const buildErrorText = (error, url) => {
const lines = [
`URL: ${url}`,
`Time: ${new Date().toISOString()}`,
`Error: ${safeMessage(error) ?? String(error)}`,
]
if (error?.stack) lines.push(`\nStack:\n${error.stack}`)
return lines.join('\n')
}
const Error = () => {
const error = useRouteError()
const [showDetails, setShowDetails] = useState(false)
const [copied, setCopied] = useState(false)
const { color, Icon } = getErrorKind(error)
const message = safeMessage(error)
const url = window.location.href
const handleCopy = () => {
navigator.clipboard.writeText(buildErrorText(error, url)).then(() => {
setCopied(true)
})
}
return (
<Container className='flex h-full items-center justify-center'>
<Box
sx={{
position: 'relative',
minHeight: '100dvh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
px: 3,
py: 6,
maxWidth: 440,
mx: 'auto',
overflow: 'hidden',
}}
>
{/* Decorative dots */}
<Box
className='flex flex-col items-center justify-center'
sx={{
minHeight: '80vh',
position: 'absolute',
top: '14%',
left: '6%',
width: 14,
height: 14,
borderRadius: '50%',
bgcolor: `${color}.100`,
opacity: 0.7,
}}
/>
<Box
sx={{
position: 'absolute',
top: '22%',
right: '8%',
width: 9,
height: 9,
borderRadius: '50%',
bgcolor: `${color}.200`,
}}
/>
<Box
sx={{
position: 'absolute',
bottom: '28%',
right: '6%',
width: 7,
height: 7,
borderRadius: '50%',
bgcolor: `${color}.100`,
}}
/>
{/* Icon with concentric rings */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
mb: 4,
}}
>
<CircularProgress
value={100}
color='danger' // Set the color to 'error' for danger color
sx={{ '--CircularProgress-size': '200px' }}
>
<Logo />
</CircularProgress>
<Box
className='flex items-center gap-2'
sx={{
fontWeight: 700,
fontSize: 24,
mt: 2,
width: 172,
height: 172,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.100`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
Ops, something went wrong
</Box>
<Typography level='body-md' fontWeight={500} textAlign={'center'}>
if you think this is a mistake, please contact us or{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
style={{
textDecoration: 'underline',
<Box
sx={{
width: 128,
height: 128,
borderRadius: '50%',
border: '1.5px solid',
borderColor: `${color}.200`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
open issue here
</a>{' '}
</Typography>
<Box
sx={{
width: 84,
height: 84,
borderRadius: '50%',
bgcolor: `${color}.50`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Icon sx={{ fontSize: 42, color: `${color}.500` }} />
</Box>
</Box>
</Box>
</Box>
{/* Title */}
<Typography
level='h3'
fontWeight={700}
textAlign='center'
sx={{ mb: 1.5 }}
>
Something went wrong
</Typography>
{/* Error message */}
<Typography
level='body-sm'
textAlign='center'
sx={{
color: 'text.secondary',
mb: 4,
maxWidth: 320,
minHeight: '2.5em',
wordBreak: 'break-word',
}}
>
{message ??
'An unexpected error occurred. Try reloading — it usually fixes it.'}
</Typography>
{/* Primary CTA */}
<Button
variant='solid'
color='primary'
size='lg'
startDecorator={<RefreshRounded />}
onClick={() => window.location.reload()}
sx={{ width: '100%', mb: 2 }}
>
Try again
</Button>
{/* Secondary actions */}
<Box sx={{ display: 'flex', gap: 3, mb: 5 }}>
<Button
component={Link}
to='/chores'
variant='outlined'
color='primary'
sx={{ mt: 4 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<HomeRounded />}
>
@@ -55,16 +229,108 @@ const Error = () => {
<Button
component={Link}
to='/login'
variant='outlined'
color='primary'
sx={{ mt: 1 }}
variant='plain'
color='neutral'
size='lg'
startDecorator={<Login />}
>
Login
</Button>
</Box>
</Container>
{/* Report hint + collapsible details */}
<Box
sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography
level='body-xs'
textAlign='center'
sx={{ color: 'text.tertiary', mb: 1.5 }}
>
If this keeps happening,{' '}
<a
href='https://github.com/donetick/donetick/issues/new'
target='_blank'
rel='noopener noreferrer'
style={{ textDecoration: 'underline' }}
>
open an issue
</a>{' '}
and include the error details below.
</Typography>
{(error?.stack || message) && (
<>
<Button
variant='plain'
color='neutral'
size='sm'
onClick={() => setShowDetails(v => !v)}
endDecorator={
<ExpandMoreRounded
sx={{
transition: 'transform 0.2s',
transform: showDetails ? 'rotate(180deg)' : 'rotate(0deg)',
}}
/>
}
sx={{ mb: 1 }}
>
{showDetails ? 'Hide' : 'Show'} error details
</Button>
{showDetails && (
<Box
sx={{
position: 'relative',
bgcolor: 'background.level2',
borderRadius: 'sm',
p: 2,
width: '100%',
}}
>
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleCopy}
sx={{ position: 'absolute', top: 8, right: 8 }}
title='Copy to clipboard'
>
<ContentCopyRounded fontSize='small' />
</IconButton>
<Typography
level='body-xs'
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
pr: 4,
color: 'text.secondary',
}}
>
{buildErrorText(error, url)}
</Typography>
</Box>
)}
</>
)}
</Box>
<Snackbar
open={copied}
autoHideDuration={2500}
onClose={() => setCopied(false)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
size='sm'
>
Error details copied to clipboard
</Snackbar>
</Box>
)
}

View File

@@ -8,11 +8,21 @@ import {
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Analytics,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
FilterList,
Group,
History,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Star,
ThumbDown,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard'
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const { showSuccess } = useNotification()
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
}, {})
}, [pendingCmds])
const filterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <FilterList />,
options: [
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'completedBy',
label: 'Completed By',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.completedBy),
},
{
id: 'dateRange',
label: 'Completed At',
type: 'date-range',
icon: <CalendarMonth />,
filterFn: (item, value) => {
const performed = new Date(item.performedAt || item.updatedAt)
if (value.from && performed < new Date(value.from)) return false
if (value.to && performed > new Date(value.to)) return false
return true
},
},
],
[performers],
)
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
useFilter(choreHistory, filterDefs)
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
}
return (
<Container maxWidth='md'>
<Container maxWidth='md' sx={{ px: 0 }}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ gap: 2, p: 2 }}>
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
</Box>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
Task Activity
</Typography>
</Box>
<Box sx={{ px: 2 }}>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={filteredHistory.length}
totalCount={choreHistory.length}
/>
</Box>
{filteredHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
No results match your filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
Try adjusting or clearing the active filters.
</Typography>
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
Clear filters
</Button>
</Box>
)}
{filteredHistory.length > 0 && (
<Sheet
variant='plain'
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
>
{/* Chore History List (Updated Style) */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{choreHistory.map((historyEntry, index) => (
{filteredHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
swipeActionOpen={
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
performers={performers}
allHistory={choreHistory}
index={index}
onViewDetails={() => {
setDetailModalConfig({
isOpen: true,
entry: historyEntry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig({ isOpen: false })
setEditHistory(record)
setIsEditModalOpen(true)
},
})
}}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
))}
</SwipeableList>
</Sheet>
)}
<EditHistoryModal
config={{
isOpen: isEditModalOpen,
@@ -481,7 +603,8 @@ const ChoreHistory = () => {
/>
<ConfirmationModal config={confirmModalConfig} />
<NoteViewerModal config={noteViewerConfig} />
</Container>
<HistoryDetailModal config={detailModalConfig} />
</Container>
)
}

View File

@@ -1,99 +1,47 @@
import {
AccessTime,
CalendarMonth,
Check,
EventNote,
HourglassEmpty,
MoreVert,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Timelapse,
Toll,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge'
const getCompletedChip = historyEntry => {
if (
historyEntry.status === 0 ||
historyEntry.status === 5 ||
historyEntry.status === 6
) {
return null
}
if (!historyEntry.dueDate) {
return null
// <Chip
// size='sm'
// variant='soft'
// color='neutral'
// startDecorator={<CalendarViewDay />}
// >
// No Due Date
// </Chip>
}
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
// TODO: make this a config at some point
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
return (
<Chip
size='sm'
variant='soft'
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
startDecorator={<Check />}
>
Early
</Chip>
)
} else {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
startDecorator={<Timelapse />}
>
Late
</Chip>
)
}
}
const formatTime = seconds => {
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
return null
}
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')}`
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const stripHtmlTags = html => {
if (!html) return ''
if (typeof document === 'undefined') {
return String(html).replace(/<[^>]*>/g, '')
}
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
const statusConfig = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
/**
* Compact HistoryCard component - content only
*/
const HistoryCard = ({
allHistory,
performers,
@@ -102,235 +50,137 @@ const HistoryCard = ({
pendingCommands,
onToggleActions,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
const displayLabel =
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
const formatTimeDifference = (startDate, endDate) => {
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
let timeValue = diffInMinutes
let unit = 'minute'
const getTimingLine = () => {
const { status, performedAt, dueDate } = historyEntry
if (!dueDate) return null
if (diffInMinutes >= 60) {
const diffInHours = moment(startDate).diff(endDate, 'hours')
timeValue = diffInHours
unit = 'hour'
if (diffInHours >= 24) {
const diffInDays = moment(startDate).diff(endDate, 'days')
timeValue = diffInDays
unit = 'day'
}
if (status === 6) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
if (status === 5) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
if ((status === 1 || status === 2 || status === 0) && performedAt) {
const diffHours = moment(performedAt).diff(dueDate, 'hours')
const abs = Math.abs(diffHours)
if (abs <= 6) return null // chip already says "On Time"
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
}
return null
}
const getStatusAvatar = () => {
const statusMap = {
0: { icon: <AccessTime />, color: 'primary' }, // Started
1: { icon: <Check />, color: 'success' }, // Completed
2: { icon: <Redo />, color: 'warning' }, // Skipped
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
}
const timingLine = getTimingLine()
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
const config = statusMap[historyEntry.status] || statusMap[1]
return (
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{
width: 24,
height: 24,
'& svg': { fontSize: '14px' },
}}
>
{config.icon}
</Avatar>
)
}
const metaTextParts = [
fmt.dateTime(actionDate),
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
? `Assigned to ${assignedTo.displayName}`
: null,
historyEntry?.duration > 0 ? `${formatTime(historyEntry.duration)}` : null,
historyEntry?.points > 0 ? `${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
].filter(Boolean)
return (
<Box
onClick={() => onViewDetails?.()}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
minWidth: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
borderBottom: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: `${config.color}.400`,
cursor: onViewDetails ? 'pointer' : 'default',
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
{/* Status + timing chip */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
>
{getStatusAvatar()}
{config.icon}
</Avatar>
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
{displayLabel}
</Typography>
</Box>
</Box>
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: historyEntry.status === 2
? 'Skipped'
: historyEntry.status === 3
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: historyEntry.status === 5
? 'Missed'
: historyEntry.status === 6
? 'Rescheduled'
: 'Completed'}
</Typography>
{/* Timing relationship line */}
{timingLine && (
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
{timingLine}
</Typography>
)}
<Chip size='sm' startDecorator={<EventNote />}>
{fmt.dateTime(
historyEntry.performedAt || historyEntry.updatedAt,
)}
</Chip>
{/* Notes inline */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
</Box>
</Box>
</Grid>
{plainTextNotes && (
<Card
variant='soft'
color='neutral'
size='sm'
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
<Typography
level='body-xs'
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
>
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}` : plainTextNotes}
</Typography>
</Card>
)}
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
{/* Metadata strip: performer chip + date + extras */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{performer && (
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{fmt.dateTime(historyEntry.dueDate)}
</Chip>
)}
</Box>
</Grid>
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
{performer && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={
<Avatar
src={performer?.image}
alt={performer?.displayName}
/>
}
>
{performer?.displayName || 'Unknown'}
</Chip>
)}
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<Chip
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Person />}
>
Assigned to {assignedTo.displayName}
</Chip>
)}
{historyEntry.notes && (
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{
maxWidth: '120px',
overflow: 'hidden',
cursor: 'pointer',
}}
onClick={e => {
e.stopPropagation()
onViewNote?.(historyEntry.notes)
}}
>
Note
</Chip>
)}
{/* add a duration chip if we have duration */}
{historyEntry?.duration > 0 && (
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<AccessTime />}
>
{formatTime(historyEntry.duration)}
</Chip>
)}
{historyEntry?.points > 0 && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Toll />}
>
{historyEntry.points} pt
{historyEntry.points > 1 ? 's' : ''}
</Chip>
)}
</Box>
</Grid>
</Grid>
{performer.displayName}
</Chip>
)}
{metaTextParts.length > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{metaTextParts.join(' · ')}
</Typography>
)}
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
{onToggleActions && (
<IconButton
color='neutral'
variant='plain'
size='sm'
onClick={e => {
e.stopPropagation()
onToggleActions()
}}
onClick={e => { e.stopPropagation(); onToggleActions() }}
>
<MoreVert sx={{ fontSize: 18 }} />
</IconButton>

View File

@@ -1,14 +1,18 @@
import { Card, Grid, Typography } from '@mui/joy'
import moment from 'moment'
import { useState } from 'react'
import ChoreCard from '../Chores/ChoreCard'
const DemoMyChore = () => {
const [selectedCalendarDate, setSelectedCalendarDate] = useState(null)
const cards = [
{
id: 12,
name: '♻️ Take out recycle ',
frequencyType: 'days_of_the_week',
frequency: 1,
priority: 1,
frequencyMetadata:
'{"days":["thursday"],"time":"2024-07-07T22:00:00-04:00"}',
nextDueDate: moment().add(1, 'days').hour(8).minute(0).toISOString(),
@@ -96,6 +100,17 @@ const DemoMyChore = () => {
]
const users = [{ displayName: 'Me', id: 1, userId: 1 }]
// Helper function to get chores for a specific date
const getChoresForDate = date => {
return cards.filter(chore => {
if (!chore.nextDueDate) return false
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
const selectedDate = date.toLocaleDateString()
return choreDate === selectedDate
})
}
return (
<>
<Grid item xs={12} sm={5} data-aos-first-tasks-list>

View File

@@ -17,6 +17,10 @@ import TabletInstallationSection from './TabletInstallationSection'
const Landing = () => {
const Navigate = useNavigate()
useEffect(() => {
// if the host is https://app.donetick.com/ then redirect to https://app.donetick.com/my/chores:
if (window.location.host === 'app.donetick.com') {
Navigate('/chores')
}
AOS.init({
once: false, // whether animation should happen only once - while scrolling down
})

View File

@@ -1,5 +1,6 @@
import ipad_screenshot from '@/assets/ipad_dashbard_calendar.png'
import { Box, Container, Typography } from '@mui/joy'
const TabletInstallationSection = () => {
return (
<Container maxWidth='xl' sx={{ py: { xs: 6, sm: 8, md: 12 } }}>

View File

@@ -0,0 +1,230 @@
import {
AccessTime,
CalendarMonth,
Check,
Edit,
HourglassEmpty,
OpenInNew,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Update,
} from '@mui/icons-material'
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
{children ?? (
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
)}
</Box>
</Box>
)
const TimingBadge = ({ historyEntry }) => {
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
if ([0, 5, 6].includes(historyEntry.status)) return null
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
const diffHours = performedAt.diff(dueDate, 'hours')
const gracePeriod = 6 * 60 * 60 * 1000
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
} else if (performedAt.isBefore(dueDate)) {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
} else {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
}
}
function HistoryDetailModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const navigate = useNavigate()
const entry = config?.entry
const performers = config?.performers ?? []
if (!entry) return null
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
const isFirstSchedule = entry.status === 6 && !entry.dueDate
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
// updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt =
entry.updatedAt &&
entry.performedAt &&
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
const formatDuration = seconds => {
if (!seconds || seconds <= 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
>
{/* Status header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon}
</Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
{statusLabel}
</Typography>
</Box>
<TimingBadge historyEntry={entry} />
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0}>
{/* Who performed it */}
{performer && (
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
</Box>
</DetailRow>
)}
{/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && (
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
)}
<Divider />
{/* Performed at */}
{entry.performedAt && (
<DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />}
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
value={fmt.dateTime(entry.performedAt)}
/>
)}
{/* Due date */}
{entry.dueDate && (
<DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
value={fmt.dateTime(entry.dueDate)}
/>
)}
{/* Last updated (only if meaningfully different from performedAt) */}
{showUpdatedAt && (
<DetailRow
icon={<Update sx={{ fontSize: 16 }} />}
label='Last updated'
value={fmt.dateTime(entry.updatedAt)}
/>
)}
{/* Duration */}
{entry.duration > 0 && (
<DetailRow
icon={<Schedule sx={{ fontSize: 16 }} />}
label='Duration'
value={formatDuration(entry.duration)}
/>
)}
{/* Points */}
{entry.points > 0 && (
<DetailRow
icon={<Typography sx={{ fontSize: 14 }}></Typography>}
label='Points earned'
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
/>
)}
{/* Notes */}
{entry.notes && (
<>
<Divider />
<Box sx={{ pt: 1 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
</Box>
</>
)}
</Stack>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
{entry.choreId && (
<Button
variant='soft'
color='neutral'
size='sm'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
variant='soft'
color='neutral'
size='md'
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</Box>
</ResponsiveModal>
)
}
export default HistoryDetailModal

View File

@@ -1,22 +1,21 @@
import { Add, Delete } from '@mui/icons-material'
import { Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Divider,
Input,
List,
ListItem,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from '../../Chores/components/FilterBuilderContent'
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
import { useFilters } from '../../Filters/FilterQueries'
const AdvancedFilterBuilder = ({
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const listContainerRef = useRef(null)
const conditionRefs = useRef([])
const [filterName, setFilterName] = useState('')
const [filterDescription, setFilterDescription] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [selections, setSelections] = useState(defaultSelections())
const [error, setError] = useState('')
const { data: existedFilters = [] } = useFilters()
const filterNameExists = (name, excludeId = null) => {
return existedFilters.some(
filter =>
filter.name.toLowerCase() === name.toLowerCase() &&
filter.id !== excludeId,
const filterNameExists = (name, excludeId = null) =>
existedFilters.some(
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
)
}
// Initialize refs array when conditions change
useEffect(() => {
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
}, [conditions.length])
// Initialize state when editing a filter
useEffect(() => {
if (!isOpen) return
if (editingFilter) {
setFilterName(editingFilter.name)
setFilterDescription(editingFilter.description || '')
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
setConditions(editingFilter.conditions || [])
setError('')
setSelections(conditionsToSelections(editingFilter.conditions))
} else {
setFilterName('')
setFilterDescription('')
// find color no filter has it :
const potentialColor = FILTER_COLORS.find(
color => !existedFilters.some(filter => filter.color === color.value),
c => !existedFilters.some(f => f.color === c.value),
)
setFilterColor(
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
)
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
setSelections(defaultSelections())
}
setError('')
}, [editingFilter, isOpen])
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
const previewChores = useMemo(() => {
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate' || c.type === 'points') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (validConditions.length === 0) return []
const result = applyFilter(
if (conditions.length === 0) return []
return applyFilter(
allChores,
{ conditions: validConditions, operator: 'AND' },
{
userId: userProfile?.id,
members,
labels,
projects,
},
{ conditions, operator: 'AND' },
{ userId: userProfile?.id, members, labels, projects },
)
return result
}, [conditions, allChores, userProfile, members, labels, projects])
const previewCount = previewChores.length
const previewOverdueCount = previewChores.filter(
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
).length
const addCondition = () => {
setConditions([
...conditions,
{ type: 'assignee', operator: 'is', value: [] },
])
// Scroll to the new condition after it's rendered
setTimeout(() => {
const newIndex = conditions.length
const newConditionElement = conditionRefs.current[newIndex]
if (newConditionElement && listContainerRef.current) {
newConditionElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}, 100)
}
const removeCondition = index => {
setConditions(conditions.filter((_, i) => i !== index))
}
const updateCondition = (index, field, value) => {
const updated = [...conditions]
updated[index] = { ...updated[index], [field]: value }
if (field === 'type') {
updated[index].value = []
if (value === 'dueDate') {
updated[index].operator = 'isOverdue'
updated[index].value = null
} else if (value === 'status') {
updated[index].value = []
} else if (value === 'points') {
updated[index].operator = 'greaterThan'
updated[index].value = 0
}
}
setConditions(updated)
}
const activeConditionCount = conditions.length
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
// Check for duplicate name, excluding current filter if editing
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
return
}
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate' || c.type === 'points') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (conditions.length === 0 || validConditions.length === 0) {
setError('Please add at least one filter condition')
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
return
}
const filterData = {
onSave({
name: filterName.trim(),
description: filterDescription.trim(),
description: editingFilter?.description ?? '',
color: filterColor,
conditions: validConditions,
conditions,
operator: 'AND',
}
// Include ID if editing
if (editingFilter) {
filterData.id = editingFilter.id
}
onSave(filterData)
...(editingFilter ? { id: editingFilter.id } : {}),
})
onClose()
}
const renderValueSelector = (condition, index) => {
switch (condition.type) {
case 'assignee':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select assignees'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`member-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username} ({member.userId})
</Option>
))}
</Select>
)
case 'createdBy':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select creators'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`creator-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username}
</Option>
))}
</Select>
)
case 'priority':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select priorities'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const priority = Priorities.find(p => p.value === value)
return (
<Chip key={`priority-${value}-${idx}`} size='sm'>
{priority?.name || `Priority ${value}`}
</Chip>
)
})}
</Box>
)}
>
{Priorities.map((priority, idx) => (
<Option
key={`priority-opt-${priority.value}-${idx}`}
value={priority.value}
>
{priority.name}
</Option>
))}
</Select>
)
case 'label':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select labels'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const label = labels.find(l => String(l.id) === String(value))
return (
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
{label?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{labels.map((label, idx) => (
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
{label.name}
</Option>
))}
</Select>
)
case 'project':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select projects'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((event, idx) => {
const value = event.value
if (value === 'default')
return (
<Chip key={`default-${idx}`} size='sm'>
Default
</Chip>
)
const project = projects.find(
p => String(p.id) === String(value),
)
return (
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
{project?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='default'>Default Project</Option>
{projects
.filter(p => p.id !== 'default')
.map((project, idx) => (
<Option
key={`project-opt-${project.id}-${idx}`}
value={project.id}
>
{project.name}
</Option>
))}
</Select>
)
case 'status':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select statuses'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const statusLabels = {
0: 'Active',
1: 'Started',
2: 'In Progress',
3: 'Pending Approval',
}
return (
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
{statusLabels[value] || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value={0}>Active</Option>
<Option value={1}>Started</Option>
<Option value={2}>In Progress</Option>
<Option value={3}>Pending Approval</Option>
</Select>
)
case 'dueDate':
return (
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='isOverdue'>Is Overdue</Option>
<Option value='isDueToday'>Is Due Today</Option>
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
<Option value='isDueThisWeek'>Is Due This Week</Option>
<Option value='isDueThisMonth'>Is Due This Month</Option>
<Option value='hasNoDueDate'>Has No Due Date</Option>
<Option value='hasDueDate'>Has Due Date</Option>
</Select>
)
case 'points':
return (
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ flex: 1 }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='equals'>Equals</Option>
<Option value='greaterThan'>Greater Than</Option>
<Option value='lessThan'>Less Than</Option>
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
</Select>
<Input
type='number'
value={condition.value ?? 0}
onChange={e =>
updateCondition(index, 'value', parseInt(e.target.value) || 0)
}
sx={{ flex: 1 }}
slotProps={{
input: {
min: 0,
},
}}
/>
</Box>
)
default:
return null
}
}
return (
<ResponsiveModal
<BottomSheetModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button variant='solid' color='primary' onClick={handleSave}>
Save
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
{/* Preview */}
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
</Typography>
)}
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='sm'
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
</Button>
</Box>
</Box>
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
}}
>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Name */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
</Typography>
<Input
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
placeholder='e.g. Overdue tasks for Alice'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Description (Optional)
</Typography>
<Textarea
placeholder='Optional description for this filter...'
value={filterDescription}
onChange={e => setFilterDescription(e.target.value)}
minRows={2}
maxRows={3}
/>
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{/* Color */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
</Typography>
<Select
value={filterColor}
onChange={(_, value) => value && setFilterColor(value)}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Typography
startDecorator={
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
background: selected.value,
}}
/>
}
>
{selected.label}
</Typography>
)}
>
{FILTER_COLORS.map(color => (
<Option key={color.value} value={color.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
background: color.value,
}}
/>
<Typography>{color.name}</Typography>
</Box>
</Option>
))}
</Select>
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions (All must match)
</Typography>
<List
ref={listContainerRef}
sx={{
gap: 1,
overflowY: 'auto',
overflowX: 'hidden',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
position: 'relative',
}}
>
{conditions.map((condition, index) => (
<ListItem
key={index}
ref={el => (conditionRefs.current[index] = el)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
onClick={() => setFilterColor(c.value)}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.5,
bgcolor: 'background.level1',
borderRadius: 'sm',
position: 'relative',
width: 26,
height: 26,
borderRadius: '50%',
background: c.value,
cursor: 'pointer',
outline:
filterColor === c.value
? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent',
outlineOffset: '2px',
transition: 'all 0.15s ease',
flexShrink: 0,
'&:hover': { transform: 'scale(1.2)' },
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Typography level='body-xs' color='neutral'>
Condition {index + 1}
</Typography>
<IconButton
size='sm'
color='danger'
variant='plain'
onClick={() => removeCondition(index)}
disabled={conditions.length === 1}
>
<Delete />
</IconButton>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
Field
</Typography>
<Select
value={condition.type}
onChange={(_, newValue) =>
updateCondition(index, 'type', newValue)
}
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='assignee'>Assignee</Option>
<Option value='createdBy'>Created By</Option>
<Option value='priority'>Priority</Option>
<Option value='label'>Label</Option>
<Option value='project'>Project</Option>
<Option value='status'>Status</Option>
<Option value='dueDate'>Due Date</Option>
<Option value='points'>Points</Option>
</Select>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
{condition.type === 'dueDate' || condition.type === 'points'
? 'Condition'
: 'Value'}
</Typography>
{renderValueSelector(condition, index)}
</Box>
</ListItem>
/>
))}
</List>
<Button
size='sm'
variant='outlined'
startDecorator={<Add />}
onClick={addCondition}
sx={{ mt: 1 }}
>
Add Condition
</Button>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 150,
overflowY: 'auto',
overflowX: 'hidden',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
position: 'relative',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Divider sx={{ mb: 2.5 }} />
<FilterBuilderContent
selections={selections}
onSelectionsChange={setSelections}
members={members}
labels={labels}
projects={projects}
/>
</Box>
</ResponsiveModal>
</BottomSheetModal>
)
}

View File

@@ -0,0 +1,138 @@
import { AttachFile, Close, Image } from '@mui/icons-material'
import { Box, Button, CircularProgress, List, ListItem, ListItemButton, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { GetChoreAttachments } from '../../../utils/Fetcher'
import { resolvePhotoURL } from '../../../utils/Helpers'
import AttachmentViewerModal from './AttachmentViewerModal'
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']
const isImageFile = fileName => {
if (!fileName) return false
const ext = fileName.split('.').pop().toLowerCase()
return IMAGE_EXTENSIONS.includes(ext)
}
const downloadFile = (url, fileName) => {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
function AttachmentBrowserModal({ choreId, isOpen, onClose }) {
const { ResponsiveModal } = useResponsiveModal()
const [attachments, setAttachments] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [viewerConfig, setViewerConfig] = useState({ isOpen: false })
useEffect(() => {
if (!isOpen || !choreId) return
setIsLoading(true)
GetChoreAttachments(choreId)
.then(async res => {
if (!res.ok) throw new Error('Failed to fetch attachments')
return res.json()
})
.then(data => setAttachments(Array.isArray(data) ? data : []))
.catch(() => setAttachments([]))
.finally(() => setIsLoading(false))
}, [isOpen, choreId])
const handleClose = () => {
setAttachments([])
onClose?.()
}
const handleAttachmentClick = attachment => {
const url = resolvePhotoURL(attachment.sign)
if (isImageFile(attachment.file_name)) {
setViewerConfig({
isOpen: true,
url,
fileName: attachment.file_name,
onClose: () => setViewerConfig({ isOpen: false }),
})
} else {
downloadFile(url, attachment.file_name)
}
}
return (
<>
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title='Attachments'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
</Box>
}
>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size='md' />
</Box>
) : attachments.length === 0 ? (
<Typography
level='body-sm'
sx={{ color: 'text.secondary', py: 2, textAlign: 'center' }}
>
No attachments found.
</Typography>
) : (
<List sx={{ '--ListItem-paddingX': '0px' }}>
{attachments.map((attachment, index) => (
<ListItem
key={
attachment.id ||
attachment.file_path ||
attachment.sign ||
attachment.file_name ||
index
}
sx={{ p: 0 }}
>
<ListItemButton
onClick={() => handleAttachmentClick(attachment)}
sx={{ borderRadius: 'sm', gap: 1.5, py: 1 }}
>
{isImageFile(attachment.file_name) ? (
<Image fontSize='small' />
) : (
<AttachFile fontSize='small' />
)}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-sm' noWrap>
{attachment.file_name || `File ${index + 1}`}
</Typography>
{attachment.size_bytes > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{(attachment.size_bytes / 1024).toFixed(1)} KB
</Typography>
)}
</Box>
</ListItemButton>
</ListItem>
))}
</List>
)}
</ResponsiveModal>
<AttachmentViewerModal config={viewerConfig} />
</>
)
}
export default AttachmentBrowserModal

View File

@@ -0,0 +1,116 @@
import { Browser } from '@capacitor/browser'
import { Capacitor } from '@capacitor/core'
import { Close, Download } from '@mui/icons-material'
import { Box, Button, CircularProgress, Typography } from '@mui/joy'
import { useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
const openUrl = async url => {
if (Capacitor.isNativePlatform()) {
await Browser.open({ url })
} else {
window.open(url, '_blank', 'noopener,noreferrer')
}
}
const downloadUrl = (url, fileName) => {
if (Capacitor.isNativePlatform()) {
Browser.open({ url })
} else {
const a = document.createElement('a')
a.href = url
a.download = fileName || 'attachment'
a.rel = 'noopener'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
}
function AttachmentViewerModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [imgLoaded, setImgLoaded] = useState(false)
const [imgError, setImgError] = useState(false)
const { isOpen, url, fileName, onClose } = config || {}
const handleClose = () => {
setImgLoaded(false)
setImgError(false)
onClose?.()
}
return (
<ResponsiveModal
open={!!isOpen}
onClose={handleClose}
title={fileName || 'Attachment'}
maxHeight='92vh'
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
variant='plain'
color='neutral'
startDecorator={<Close />}
onClick={handleClose}
>
Close
</Button>
<Button
variant='soft'
color='neutral'
startDecorator={<Download />}
onClick={() => downloadUrl(url, fileName)}
disabled={!url}
>
Download
</Button>
</Box>
}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: 200,
position: 'relative',
}}
>
{!imgLoaded && !imgError && (
<CircularProgress
sx={{ position: 'absolute' }}
size='md'
/>
)}
{imgError ? (
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Failed to load image.
</Typography>
) : (
<Box
component='img'
src={url}
alt={fileName}
onClick={() => url && openUrl(url)}
onLoad={() => setImgLoaded(true)}
onError={() => {
setImgLoaded(true)
setImgError(true)
}}
sx={{
cursor: url ? 'zoom-in' : 'default',
maxWidth: '100%',
maxHeight: '65vh',
borderRadius: 'md',
objectFit: 'contain',
display: imgLoaded && !imgError ? 'block' : 'none',
}}
/>
)}
</Box>
</ResponsiveModal>
)
}
export default AttachmentViewerModal

View File

@@ -1,114 +1,175 @@
import { CopyAll } from '@mui/icons-material'
import { Box, Button, Checkbox, Input, ListItem, Typography } from '@mui/joy'
import { useState } from 'react'
import {
Box,
Button,
Checkbox,
CircularProgress,
Input,
ListItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
function WriteNFCModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle', 'writing', 'success', 'error'
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
const [errorMessage, setErrorMessage] = useState('')
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
const cancelScanRef = useRef(null)
const isNative = Capacitor.isNativePlatform()
const requestNFCAccess = async () => {
if ('NDEFReader' in window) {
// Assuming permission request is implicit in 'write' or 'scan' methods
setNfcStatus('idle')
} else {
alert('NFC is not supported by this browser.')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) url += '?auto_complete=true'
return url
}
const writeToNFC = async url => {
if ('NDEFReader' in window) {
try {
const ndef = new window.NDEFReader()
await ndef.write({
records: [{ recordType: 'url', data: url }],
})
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
const handleClose = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
}
const handleClose = () => {
config.onClose()
setNfcStatus('idle')
setErrorMessage('')
}
const getURL = () => {
let url = config.url
if (isAutoCompleteWhenScan) {
url = url + '?auto_complete=true'
const handleCancel = async () => {
if (cancelScanRef.current) {
await cancelScanRef.current()
cancelScanRef.current = null
}
setNfcStatus('idle')
}
const writeToNFC = async () => {
const url = getURL()
if (isNative) {
setNfcStatus('writing')
const cancel = await startNativeNFCWrite(url, {
onWaiting: () => setNfcStatus('waiting_for_tag'),
onSuccess: () => {
cancelScanRef.current = null
setNfcStatus('success')
},
onError: msg => {
cancelScanRef.current = null
setNfcStatus('error')
setErrorMessage(msg)
},
})
cancelScanRef.current = cancel
} else {
if ('NDEFReader' in window) {
try {
setNfcStatus('writing')
const ndef = new window.NDEFReader()
await ndef.write({ records: [{ recordType: 'url', data: url }] })
setNfcStatus('success')
} catch (error) {
console.error('Error writing to NFC tag:', error)
setNfcStatus('error')
setErrorMessage('Error writing to NFC tag. Please try again.')
}
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
)
}
}
}
const renderBody = () => {
if (nfcStatus === 'success') {
return (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
)
}
return url
if (nfcStatus === 'waiting_for_tag') {
return (
<>
<Box
display='flex'
flexDirection='column'
alignItems='center'
gap={2}
py={3}
>
<CircularProgress size='lg' />
<Typography level='body-md' textAlign='center'>
Hold your device near the NFC tag
</Typography>
</Box>
<Button
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
</>
)
}
return (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display='flex' justifyContent='space-around' mt={1}>
<Button
size='lg'
onClick={writeToNFC}
fullWidth
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
</Box>
</>
)
}
return (
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Typography level='h4' mb={1}>
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
</Typography>
{nfcStatus === 'success' ? (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
) : (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display={'flex'} justifyContent={'space-around'} mt={1}>
<Button
size='lg'
onClick={() => writeToNFC(getURL())}
fullWidth
sx={{ mr: 1 }}
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
<Button size='lg' onClick={requestNFCAccess} variant='outlined'>
Request Access
</Button>
</Box>
</>
)}
{renderBody()}
</ResponsiveModal>
)
}

View File

@@ -1,5 +1,3 @@
import React from 'react'
const PrivacyPolicyView = () => {
return (
<div>

View File

@@ -29,7 +29,10 @@ const AccountSettings = () => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile?.id),
})
}

View File

@@ -25,13 +25,12 @@ import SettingsLayout from './SettingsLayout'
const ProfileSettings = () => {
const { t } = useTranslation('settings')
const queryClient = useQueryClient()
const { data: userProfile } = useUserProfile()
const { data: userProfile, refetch: refetchUserProfile } = useUserProfile()
const { showSuccess, showError } = useNotification()
const [displayName, setDisplayName] = useState(userProfile?.displayName || '')
const [timezone, setTimezone] = useState(
userProfile?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone,
)
const [photoURL, setPhotoURL] = useState(userProfile?.image || '')
const [isUploading, setIsUploading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const fileInputRef = useRef()
@@ -89,10 +88,9 @@ const ProfileSettings = () => {
formData.append('file', compressedFile, 'profile.jpg')
const response = await apiClient.upload('/users/profile_photo', formData)
if (!response.ok) throw new Error('Upload failed')
const data = await response.json()
const url = resolvePhotoURL(data.url || data.sign)
await response.json()
setPhotoURL(url)
refetchUserProfile() // Refresh user profile to get the new photoURL
showSuccess({
title: t('profile.photoUpdated'),
message: t('profile.photoUpdatedMessage'),
@@ -155,7 +153,7 @@ const ProfileSettings = () => {
maxWidth: 400,
}}
>
<Avatar src={photoURL} sx={{ width: 64, height: 64 }} />
<Avatar src={resolvePhotoURL(userProfile?.image)} sx={{ width: 64, height: 64 }} />
<Box sx={{ flex: 1 }}>
<Button
variant='soft'

View File

@@ -139,7 +139,10 @@ const Settings = () => {
async function configurePurchases() {
if (Capacitor.isNativePlatform() && userProfile) {
await Purchases.configure({
apiKey: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY,
apiKey:
Capacitor.getPlatform() === 'ios'
? import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_IOS
: import.meta.env.VITE_REACT_APP_REVENUECAT_API_KEY_ANDROID,
appUserID: String(userProfile?.id),
})
}

View File

@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import {
AccessTime,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
Group,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Style,
ThumbDown,
Timeline,
Toll,
@@ -24,23 +27,27 @@ import {
Divider,
Grid,
Link,
Option,
Select,
Stack,
Tab,
TabList,
Tabs,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import {
useChores,
useChoresHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading'
const groupByDate = history => {
@@ -58,47 +65,57 @@ const groupByDate = history => {
return aggregated
}
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
const getStatusIcon = status => {
switch (status) {
case 0:
return <AccessTime color='primary' />
case 1:
return <Check color='success' />
case 2:
return <Redo color='warning' />
case 3:
return <HourglassEmpty color='neutral' />
case 4:
return <ThumbDown color='error' />
case 5:
return <RunningWithErrors color='error' />
case 6:
return <Schedule color='warning' />
default:
return <Check color='success' />
}
}
const statusConfig = {
0: { color: 'primary', icon: <AccessTime /> },
1: { color: 'success', icon: <Check /> },
2: { color: 'warning', icon: <Redo /> },
3: { color: 'neutral', icon: <HourglassEmpty /> },
4: { color: 'danger', icon: <ThumbDown /> },
5: { color: 'danger', icon: <RunningWithErrors /> },
6: { color: 'warning', icon: <Schedule /> },
}
const ChoreHistoryItem = ({
time,
name,
points,
status,
notes,
onViewNote,
onViewDetails,
}) => {
const cfg = statusConfig[status] ?? statusConfig[1]
return (
<Stack direction='row' alignItems='center' spacing={2}>
<Stack
direction='row'
alignItems='center'
spacing={1}
onClick={onViewDetails}
sx={{
cursor: onViewDetails ? 'pointer' : 'default',
borderRadius: 'sm',
'&:hover': onViewDetails
? { backgroundColor: 'background.level1' }
: {},
}}
>
<Typography level='body-md' sx={{ minWidth: 80 }}>
{time}
</Typography>
<Box
<Avatar
size='sm'
color={cfg.color}
variant='soft'
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 32,
minHeight: 32,
borderRadius: '50%',
backgroundColor: 'background.level2',
boxShadow: 'sm',
width: 32,
height: 32,
flexShrink: 0,
'& svg': { fontSize: '16px' },
}}
>
{getStatusIcon(status)}
</Box>
{cfg.icon}
</Avatar>
<Box
sx={{
display: 'flex',
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
)
}
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
const ChoreHistoryTimeline = ({
history,
performers,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const groupedHistory = groupByDate(history)
const sortedEntries = Object.entries(groupedHistory).sort(
([a], [b]) => new Date(b) - new Date(a),
)
return (
<Container sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Activities Timeline
</Typography>
</Box>
<Box sx={{ py: 2, width: '100%' }}>
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
<Typography level='title-sm' sx={{ mb: 0.5 }}>
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
<Divider />
<Stack spacing={1}>
{items.map(record => (
<>
<ChoreHistoryItem
key={record.id}
time={fmt.time(
record.performedAt || record.updatedAt,
)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
/>
</>
<ChoreHistoryItem
key={record.id}
time={fmt.time(record.performedAt || record.updatedAt)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
onViewDetails={() => onViewDetails?.(record, performers)}
/>
))}
</Stack>
</Box>
))}
</Container>
</Box>
)
}
@@ -402,6 +408,11 @@ const UserActivites = () => {
const [enrichedHistory, setEnrichedHistory] = React.useState([])
const [selectedChart, setSelectedChart] = React.useState('history')
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
@@ -416,6 +427,7 @@ const UserActivites = () => {
choresAssigneeBreakdownChartData,
setChoresAssigneeBreakdownChartData,
] = React.useState([])
const { data: userLabels } = useLabels()
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
const {
data: choresHistory,
@@ -432,6 +444,142 @@ const UserActivites = () => {
}
}, [circleMembersData])
// Client-side filters applied on top of the user+time-window slice
const clientFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <Checklist />,
options: [
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'hasPoints',
label: 'Has Points',
type: 'boolean',
icon: <Toll />,
filterFn: item => (item.points ?? 0) > 0,
},
],
[userLabels],
)
const {
filteredData: filteredTimeline,
activeFilters: clientActiveFilters,
setFilter: setClientFilter,
clearAll: clearClientFilters,
} = useFilter(selectedHistory, clientFilterDefs)
// All filter defs merged for FilterBar display
const filterDefs = useMemo(
() => [
{
id: 'timePeriod',
label: 'Time Period',
type: 'single-select',
icon: <CalendarMonth />,
defaultValue: 7,
options: [
{ value: 7, label: '7 Days' },
{ value: 30, label: '30 Days' },
{ value: 90, label: '90 Days' },
{ value: 365, label: 'All Time' },
],
},
{
id: 'completedBy',
label: 'User',
type: 'single-select',
icon: <Person />,
options: circleUsers.map(u => ({
value: u.userId,
label: u.displayName,
avatar: u.image,
})),
},
...clientFilterDefs,
],
[circleUsers, clientFilterDefs],
)
// Merge server-driven and client-driven active filter states for the bar
const activeFilters = useMemo(
() => ({
timePeriod: tabValue,
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
...clientActiveFilters,
}),
[tabValue, selectedUser, clientActiveFilters],
)
const handleSetFilter = (id, value) => {
if (id === 'completedBy') {
const userId = value ?? 'all'
setSelectedUser(userId)
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
} else if (id === 'timePeriod') {
const days = value ?? 7
setTabValue(days)
refetchHistory(days)
} else {
setClientFilter(id, value)
}
}
const handleClearAll = () => {
setSelectedUser('all')
setSelectedHistory(enrichedHistory)
setTabValue(7)
refetchHistory(7)
clearClientFilters()
}
useEffect(() => {
if (
!isChoresHistoryLoading &&
@@ -444,6 +592,7 @@ const UserActivites = () => {
return {
...item,
choreName: chore?.name,
labelsV2: chore?.labelsV2,
}
})
setEnrichedHistory(enrichedHistory)
@@ -823,222 +972,24 @@ const UserActivites = () => {
}}
>
<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' }}
>
User Activities
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Overview of user activities and task statistics
</Typography>
</Stack>
</Box>
{/* Filter Controls - Always visible */}
<Card
variant='outlined'
sx={{
width: '100%',
p: 2,
mb: 3,
borderRadius: 12,
background:
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
}}
>
<Stack spacing={2}>
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
Filter Activities
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', sm: 'center' }}
>
{/* User Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Show activities for:
</Typography>
<Select
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
enrichedHistory.filter(h => USER_FILTER(h, selected)),
)
}}
renderValue={() => {
if (selectedUser === undefined || selectedUser === 'all') {
return (
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
)
}
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)
}}
>
<Option value='all'>
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
</Option>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
</Box>
{/* Time Period Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Time period:
</Typography>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
refetchHistory(tabValue)
}}
value={tabValue}
sx={{
borderRadius: 8,
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<TabList
disableUnderline
sx={{
borderRadius: 8,
backgroundColor: 'transparent',
p: 0.5,
gap: 0.5,
}}
>
{[
{ label: '7 Days', value: 7 },
{ label: '30 Days', value: 30 },
{ label: '90 Days', value: 90 },
{ label: 'All Time', value: 365 },
].map((tab, index) => (
<Tab
key={index}
sx={{
borderRadius: 6,
minWidth: 'auto',
px: 2,
py: 1,
fontSize: 'sm',
fontWeight: 500,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.plainColor',
backgroundColor: 'primary.softBg',
fontWeight: 600,
},
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
},
}}
disableIndicator
value={tab.value}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
</Stack>
</Stack>
</Card>
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Showing activities for{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{selectedUser === undefined || selectedUser === 'all'
? 'All Users'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
</Typography>{' '}
over the{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
</Typography>
<Timeline sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Activities
</Typography>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={handleSetFilter}
onClearAll={handleClearAll}
resultCount={filteredTimeline.length}
totalCount={selectedHistory.length}
/>
{/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
<Container
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline
history={selectedHistory}
history={filteredTimeline}
performers={circleUsers}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
onViewDetails={(entry, performers) => {
setDetailModalConfig({
isOpen: true,
entry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
setEditHistoryRecord(record)
setEditModalConfig({
isOpen: true,
onClose: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
onSave: updated => {
updateChoreHistory.mutate(
{
choreId: record.choreId,
historyId: record.id,
historyData: {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
},
},
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
onDelete: () => {
deleteChoreHistory.mutate(
{ choreId: record.choreId, historyId: record.id },
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
})
},
})
}}
/>
</Box>
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
<Box
sx={{
width: { xs: '100%', lg: '350px' },
position: { xs: 'static', lg: 'sticky' },
top: { lg: '60px' },
alignSelf: { lg: 'flex-start' },
maxHeight: { lg: 'calc(100vh - 40px)' },
overflowY: { lg: 'auto' },
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
display: { xs: 'none', lg: 'block' },
width: '350px',
position: 'sticky',
top: '60px',
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
}}
>
{/* Charts Container */}
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
</>
)}
<NoteViewerModal config={noteViewerConfig} />
<HistoryDetailModal config={detailModalConfig} />
<EditHistoryModal
config={editModalConfig}
historyRecord={editHistoryRecord}
/>
</Container>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,43 @@
import { Person } from '@mui/icons-material'
import BaseOptionPicker from './BaseOptionPicker'
const AssigneePickerField = ({
value = null,
onChange,
onClear,
members = [],
includeAnyone = true,
emptyDisplay,
currentUserId = null,
}) => {
const options = [
...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
...members.map(member => ({
userId: member.userId,
displayName: member.displayName || member.username || 'Unknown',
})),
]
const displayValue = currentUserId && value === currentUserId ? null : value
return (
<BaseOptionPicker
items={options}
value={displayValue}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
getItemValue={item => item.userId}
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
menuMinWidth={220}
/>
)
}
export default AssigneePickerField

View File

@@ -0,0 +1,259 @@
import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
import {
Box,
Button,
CircularProgress,
IconButton,
Sheet,
Typography,
} from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
import { useFileUpload } from '../../hooks/useFileUpload'
const AttachmentPickerField = ({
attachments = [],
onChange,
onClear,
emptyDisplay = 'icon-text',
entityType = 'chore_attachment',
entityId,
draftId,
}) => {
const [isOpen, setIsOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const buttonRef = useRef(null)
const { uploadFile } = useFileUpload({ entityType, entityId, draftId })
useEffect(() => {
if (!isOpen) return
const handleEscape = e => {
if (e.key === 'Escape') setIsOpen(false)
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen])
const handleAddFile = () => {
const input = document.createElement('input')
input.setAttribute('type', 'file')
input.setAttribute('accept', 'image/*')
input.click()
input.onchange = async () => {
const file = input.files?.[0]
if (!file) return
setIsUploading(true)
try {
const url = await uploadFile(file)
if (url) {
onChange([...attachments, { url, name: file.name }])
}
} finally {
setIsUploading(false)
}
}
}
const handleRemove = index => {
const updated = attachments.filter((_, i) => i !== index)
onChange(updated)
if (updated.length === 0) setIsOpen(false)
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
setIsOpen(false)
}
const isEmpty = attachments.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
ref={buttonRef}
size='sm'
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
{isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
) : (
<AttachFile sx={{ fontSize: '20px' }} />
)}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{isEmpty
? 'Attachments'
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{ name: 'offset', options: { offset: [0, 8] } },
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 240,
maxWidth: 320,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{attachments.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{attachments.map((attachment, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.5,
borderRadius: 'sm',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
<Box
sx={{
display: 'none',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'sm',
bgcolor: 'background.level2',
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
</Box>
<Typography
level='body-xs'
sx={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{attachment.name}
</Typography>
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={() => handleRemove(index)}
sx={{ flexShrink: 0 }}
>
<DeleteOutline sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default AttachmentPickerField

View File

@@ -0,0 +1,253 @@
import { Close } from '@mui/icons-material'
import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
import { ClickAwayListener, Popper } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BaseOptionPicker = ({
items = [],
value = null,
values = [],
multiple = false,
onChange,
onValuesChange,
emptyDisplay = 'icon',
emptyLabel = 'Select',
placement = 'top-start',
menuMinWidth = 180,
menuMaxHeight = 280,
getItemValue = item => item.id,
getItemLabel = item => item.label,
renderItemStart,
renderTriggerIcon,
getItemColor,
getTriggerText,
onClear,
}) => {
const [isOpen, setIsOpen] = useState(false)
const buttonRef = useRef(null)
useEffect(() => {
if (!isOpen) return
const handleEscape = event => {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('keydown', handleEscape)
}
}, [isOpen])
const selectedItems = useMemo(() => {
if (multiple) {
const selectedSet = new Set(values)
return items.filter(item => selectedSet.has(getItemValue(item)))
}
if (value === null || value === undefined) return []
return items.filter(item => getItemValue(item) === value)
}, [items, multiple, value, values, getItemValue])
const isEmpty = selectedItems.length === 0
const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
const triggerText = getTriggerText
? getTriggerText({ selectedItems, isEmpty })
: isEmpty
? emptyLabel
: getItemLabel(selectedItems[0])
const triggerColor = isEmpty
? undefined
: getItemColor
? getItemColor(selectedItems[0])
: undefined
const handleSelect = selectedValue => {
if (multiple) {
const selectedSet = new Set(values)
if (selectedSet.has(selectedValue)) {
selectedSet.delete(selectedValue)
} else {
selectedSet.add(selectedValue)
}
onValuesChange?.(Array.from(selectedSet))
return
}
onChange?.(selectedValue)
setIsOpen(false)
}
const isSelected = item => {
const optionValue = getItemValue(item)
if (multiple) {
return values.includes(optionValue)
}
return value === optionValue
}
const handleClear = e => {
e.stopPropagation()
onClear?.()
}
return (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={'sm'}
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
borderColor: triggerColor || undefined,
color: triggerColor || undefined,
'&:hover': {
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
borderColor: triggerColor || undefined,
},
}}
>
{renderTriggerIcon?.({ selectedItems, isEmpty })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{triggerText}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement={placement}
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: menuMinWidth,
maxHeight: menuMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
p: 0.75,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{items.map((item, index) => {
const optionValue = getItemValue(item)
const selected = isSelected(item)
const itemColor = getItemColor ? getItemColor(item) : undefined
return (
<Button
key={optionValue ?? index}
variant={selected ? 'soft' : 'plain'}
color='neutral'
onClick={() => handleSelect(optionValue)}
sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
whiteSpace: 'nowrap',
mb: index === items.length - 1 ? 0 : 0.5,
color: selected
? itemColor || 'text.primary'
: 'text.primary',
}}
>
{renderItemStart?.({ item, selected })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getItemLabel(item)}
</Typography>
</Button>
)
})}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default BaseOptionPicker

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