Merge branch 'develop' into advance-filtering
This commit is contained in:
9
.editorconfig
Normal file
9
.editorconfig
Normal 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
|
||||
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -2,9 +2,7 @@ name: Build validation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main", "develop" ]
|
||||
pull_request:
|
||||
branches: [ "main", "develop" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies {
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-push-notifications')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capgo-capacitor-nfc')
|
||||
implementation project(':capgo-capacitor-social-login')
|
||||
implementation project(':revenuecat-purchases-capacitor')
|
||||
implementation project(':revenuecat-purchases-capacitor-ui')
|
||||
|
||||
@@ -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
|
||||
@@ -56,4 +63,5 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
</manifest>
|
||||
|
||||
@@ -29,6 +29,9 @@ 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-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')
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script
|
||||
defer
|
||||
data-domain="donetick.com"
|
||||
src="https://collector.wannaknow.link/js/script.js"
|
||||
></script>
|
||||
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/logo.svg" /> -->
|
||||
<meta
|
||||
|
||||
@@ -359,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 = 14.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";
|
||||
@@ -379,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 = 14.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 = "";
|
||||
|
||||
@@ -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>NDEF</string>
|
||||
</array>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.applesignin</key>
|
||||
|
||||
@@ -2,6 +2,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>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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -35,6 +35,8 @@ PODS:
|
||||
- Capacitor
|
||||
- CapacitorStatusBar (7.0.2):
|
||||
- Capacitor
|
||||
- CapgoCapacitorNfc (7.2.0):
|
||||
- Capacitor
|
||||
- CapgoCapacitorSocialLogin (7.11.2):
|
||||
- Alamofire (~> 5.10.2)
|
||||
- Capacitor
|
||||
@@ -145,6 +147,7 @@ DEPENDENCIES:
|
||||
- "CapacitorPreferences (from `../../node_modules/@capacitor/preferences`)"
|
||||
- "CapacitorPushNotifications (from `../../node_modules/@capacitor/push-notifications`)"
|
||||
- "CapacitorStatusBar (from `../../node_modules/@capacitor/status-bar`)"
|
||||
- "CapgoCapacitorNfc (from `../../node_modules/@capgo/capacitor-nfc`)"
|
||||
- "CapgoCapacitorSocialLogin (from `../../node_modules/@capgo/capacitor-social-login`)"
|
||||
- FirebaseMessaging
|
||||
- "RevenuecatPurchasesCapacitor (from `../../node_modules/@revenuecat/purchases-capacitor`)"
|
||||
@@ -202,6 +205,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../node_modules/@capacitor/push-notifications"
|
||||
CapacitorStatusBar:
|
||||
:path: "../../node_modules/@capacitor/status-bar"
|
||||
CapgoCapacitorNfc:
|
||||
:path: "../../node_modules/@capgo/capacitor-nfc"
|
||||
CapgoCapacitorSocialLogin:
|
||||
:path: "../../node_modules/@capgo/capacitor-social-login"
|
||||
RevenuecatPurchasesCapacitor:
|
||||
@@ -225,6 +230,7 @@ SPEC CHECKSUMS:
|
||||
CapacitorPreferences: 65107ed7437d96ee72583df5763985e3c0ff2bc2
|
||||
CapacitorPushNotifications: 7c0659b349b149ee3936a682da31a9d269de9582
|
||||
CapacitorStatusBar: e04d05e121d5a5979c29eb4249186a4e4a84cacb
|
||||
CapgoCapacitorNfc: 3306447e38359522e6c1ea233aa6e84a54632534
|
||||
CapgoCapacitorSocialLogin: a320106e1d032da88576cf8b31a336e971f5acad
|
||||
FBAEMKit: e34530df538b8eb8aeb53c35867715ba6c63ef0c
|
||||
FBSDKCoreKit: d3f479a69127acebb1c6aad91c1a33907bcf6c2f
|
||||
@@ -250,6 +256,6 @@ SPEC CHECKSUMS:
|
||||
SQLCipher: eb79c64049cb002b4e9fcb30edb7979bf4706dfc
|
||||
ZIPFoundation: dfd3d681c4053ff7e2f7350bc4e53b5dba3f5351
|
||||
|
||||
PODFILE CHECKSUM: bb6dcef70c8edc058fe3ba311f08a223fd7dbc66
|
||||
PODFILE CHECKSUM: 17f02615fe2b1c9437b2cd884eec64bc1229846c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
23
package-lock.json
generated
23
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "donetick",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.2",
|
||||
"dependencies": {
|
||||
"@capacitor-community/sqlite": "^7.0.0",
|
||||
"@capacitor/android": "^7.0.0",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capgo/capacitor-nfc": "^7.2.0",
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -45,7 +46,6 @@
|
||||
"esm": "^3.2.25",
|
||||
"event-source-polyfill": "^1.0.31",
|
||||
"fuse.js": "^7.0.0",
|
||||
"i": "^0.3.7",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
@@ -1947,6 +1947,15 @@
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capgo/capacitor-nfc": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@capgo/capacitor-nfc/-/capacitor-nfc-7.2.0.tgz",
|
||||
"integrity": "sha512-lYMlogAlUG3j1HmieOdFan4SNOnTbTvZ7BMYQmyhnfU9810aip9oPh6CYVXO/2ecZXW03ATTmSKtr4Ci8DpW2Q==",
|
||||
"license": "MPL-2.0",
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@capgo/capacitor-social-login": {
|
||||
"version": "7.11.2",
|
||||
"license": "MIT",
|
||||
@@ -8748,14 +8757,6 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/i": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/i/-/i-0.3.7.tgz",
|
||||
"integrity": "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==",
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.8.14",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "donetick",
|
||||
"private": true,
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
@@ -33,7 +33,8 @@
|
||||
"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",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@capacitor/preferences": "^7.0.0",
|
||||
"@capacitor/push-notifications": "^7.0.0",
|
||||
"@capacitor/status-bar": "^7.0.0",
|
||||
"@capgo/capacitor-nfc": "^7.2.0",
|
||||
"@capgo/capacitor-social-login": "^7.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -15,6 +15,7 @@ import './styles/safe-area.css'
|
||||
import SSEProvider from './contexts/SSEContext'
|
||||
import { useNotification } from './service/NotificationProvider'
|
||||
|
||||
import { useSyncOnReconnect } from './hooks/useSyncOnReconnect'
|
||||
import NetworkBanner from './views/components/NetworkBanner'
|
||||
|
||||
const add = className => {
|
||||
@@ -30,10 +31,12 @@ const intervalMS = 5 * 60 * 1000 // 5 minutes
|
||||
|
||||
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],
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,69 +1,81 @@
|
||||
import { Network } from '@capacitor/network'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
import { syncManager } from '../utils/SyncManager.jsx' // Ensure you import syncManager if needed for syncing
|
||||
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
|
||||
}
|
||||
})
|
||||
const syncQueue = () => {
|
||||
localStore
|
||||
.syncQueuedRequests()
|
||||
.then(hasMessages => {
|
||||
console.log(
|
||||
'Queued requests synced successfully. Queue has messaage is: ',
|
||||
hasMessages,
|
||||
)
|
||||
if (hasMessages) {
|
||||
this.notifyBackendSync()
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error syncing queued requests:', error)
|
||||
})
|
||||
}
|
||||
this.registerNetworkListener(async isOnline => {
|
||||
if (isOnline && this.isNetworkOn) {
|
||||
// TODO: Delete when Sync manager Implemented
|
||||
syncQueue()
|
||||
console.log('NetworkManager: Network is back online. with SYNCMANAGER')
|
||||
|
||||
await syncManager.syncTasks()
|
||||
console.log('Finished syncing queued requests.')
|
||||
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()
|
||||
}
|
||||
})
|
||||
syncQueue()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
if (this.isOnline === false) {
|
||||
this.isOnline = true
|
||||
this.notifyConnectionStatus()
|
||||
}
|
||||
this.setServerReachable()
|
||||
}
|
||||
|
||||
notifyConnectionStatus() {
|
||||
@@ -81,8 +93,12 @@ class NetworkManager {
|
||||
registerNetworkListener(callback) {
|
||||
this.connectionStatusListeners.push(callback)
|
||||
}
|
||||
unregisterNetworkListener(callback) {
|
||||
this.connectionStatusListeners = this.connectionStatusListeners.filter(
|
||||
cb => cb !== callback,
|
||||
)
|
||||
}
|
||||
registerBackendSyncListener(callback) {
|
||||
// if callback is not in the list already, add it
|
||||
if (!this.queueSyncListeners.includes(callback)) {
|
||||
this.queueSyncListeners.push(callback)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
91
src/hooks/useFileUpload.js
Normal file
91
src/hooks/useFileUpload.js
Normal file
@@ -0,0 +1,91 @@
|
||||
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 } = {}) => {
|
||||
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', entityId)
|
||||
|
||||
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, showError, userProfile],
|
||||
)
|
||||
|
||||
return { uploadFile, isPlus: isPlusAccount(userProfile) }
|
||||
}
|
||||
25
src/hooks/usePendingCommands.js
Normal file
25
src/hooks/usePendingCommands.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
|
||||
// Hook to get pending commands for a specific chore (for showing pending badges/undo)
|
||||
export const usePendingCommands = choreId => {
|
||||
return useQuery({
|
||||
queryKey: ['pendingCommands', choreId],
|
||||
queryFn: () => commandQueue.getPendingForEntity(String(choreId)),
|
||||
refetchInterval: 2000, // Poll since commands change outside React
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Hook to get all pending command count (for sync indicator)
|
||||
export const usePendingCommandCount = () => {
|
||||
return useQuery({
|
||||
queryKey: ['pendingCommands', 'all'],
|
||||
queryFn: async () => {
|
||||
const cmds = await commandQueue.getPending()
|
||||
return cmds.length
|
||||
},
|
||||
refetchInterval: 3000,
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
129
src/hooks/useSyncOnReconnect.js
Normal file
129
src/hooks/useSyncOnReconnect.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import { App as capacitorApp } from '@capacitor/app'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { commandQueue } from '../utils/CommandQueue'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
|
||||
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() {
|
||||
const queryClient = useQueryClient()
|
||||
const initialized = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
let pendingPollInterval
|
||||
let cacheRefreshInterval
|
||||
let serverProbeInterval
|
||||
let resumeListener
|
||||
let networkListener
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
runSync()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnline = () => runSync()
|
||||
|
||||
const init = async () => {
|
||||
if (initialized.current) return
|
||||
initialized.current = true
|
||||
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
await offlineDB.init()
|
||||
}
|
||||
|
||||
// 1. Device network change (works on native + real network drops)
|
||||
networkListener = async isOnline => {
|
||||
if (isOnline) {
|
||||
await runSync()
|
||||
}
|
||||
}
|
||||
networkManager.registerNetworkListener(networkListener)
|
||||
|
||||
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// 3. Browser online event (fires when device network is restored)
|
||||
window.addEventListener('online', handleOnline)
|
||||
|
||||
// 3.5 Native app resume (fires when returning to the foreground)
|
||||
if (Capacitor.isNativePlatform()) {
|
||||
resumeListener = await capacitorApp.addListener(
|
||||
'appStateChange',
|
||||
({ isActive }) => {
|
||||
if (isActive) {
|
||||
console.log(
|
||||
'App resumed, checking connectivity and syncing if online...',
|
||||
)
|
||||
runSync()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Retry pending commands every 30s (catches backend restart)
|
||||
pendingPollInterval = setInterval(async () => {
|
||||
const pending = await commandQueue.getPending()
|
||||
if (pending.length > 0) {
|
||||
runSync()
|
||||
}
|
||||
}, PENDING_POLL_MS)
|
||||
|
||||
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
if (pendingPollInterval) {
|
||||
clearInterval(pendingPollInterval)
|
||||
}
|
||||
|
||||
if (cacheRefreshInterval) {
|
||||
clearInterval(cacheRefreshInterval)
|
||||
}
|
||||
|
||||
if (serverProbeInterval) {
|
||||
clearInterval(serverProbeInterval)
|
||||
}
|
||||
|
||||
if (networkListener) {
|
||||
networkManager.unregisterNetworkListener(networkListener)
|
||||
}
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
window.removeEventListener('online', handleOnline)
|
||||
resumeListener?.remove()
|
||||
}
|
||||
}, [queryClient])
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle'
|
||||
import { commandQueue, CommandType } from '../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
ArchiveChore,
|
||||
@@ -20,51 +20,91 @@ import {
|
||||
UnArchiveChore,
|
||||
UpdateChoreHistory,
|
||||
} from '../utils/Fetcher'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
|
||||
export const useChores = includeArchive => {
|
||||
const mergePendingCreates = async chores => {
|
||||
const pending = await commandQueue.getPending()
|
||||
const pendingCreates = pending.filter(
|
||||
cmd => cmd.commandType === CommandType.CREATE_CHORE,
|
||||
)
|
||||
const deletedIds = new Set(
|
||||
pending
|
||||
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
|
||||
.map(cmd => String(cmd.entityId)),
|
||||
)
|
||||
|
||||
if (pendingCreates.length === 0) return chores
|
||||
|
||||
const existingIds = new Set((chores || []).map(chore => String(chore.id)))
|
||||
const createdFromQueue = pendingCreates
|
||||
.filter(
|
||||
cmd =>
|
||||
!existingIds.has(String(cmd.entityId)) &&
|
||||
!deletedIds.has(String(cmd.entityId)),
|
||||
)
|
||||
.map(cmd => {
|
||||
const payload = cmd.payload || {}
|
||||
return {
|
||||
...payload,
|
||||
id: cmd.entityId,
|
||||
nextDueDate: payload.nextDueDate || payload.dueDate || null,
|
||||
_pendingCreate: true,
|
||||
}
|
||||
})
|
||||
|
||||
return [...(chores || []), ...createdFromQueue]
|
||||
}
|
||||
|
||||
const isNetworkError = error =>
|
||||
(error instanceof TypeError && error.message === 'Failed to fetch') ||
|
||||
error?.name === 'AbortError'
|
||||
|
||||
const buildOfflineChore = task => ({
|
||||
...task,
|
||||
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
|
||||
nextDueDate: task.nextDueDate || task.dueDate || null,
|
||||
_pendingCreate: true,
|
||||
})
|
||||
|
||||
export const useChores = (includeArchive = false) => {
|
||||
return useQuery({
|
||||
queryKey: ['chores', includeArchive],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
const onlineChores = await GetChoresNew(includeArchive)
|
||||
|
||||
// Only handle offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChores
|
||||
if (isOfflineFeatureEnabled()) {
|
||||
// Sync from server first (no-op if already syncing or offline)
|
||||
if (networkManager.isOnline) {
|
||||
await syncEngine.sync()
|
||||
}
|
||||
const cursor = await offlineDB.getSyncCursor()
|
||||
if (cursor > 0) {
|
||||
const cached = await offlineDB.getChores(includeArchive)
|
||||
const merged = await mergePendingCreates(cached || [])
|
||||
return { res: merged }
|
||||
}
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
// go throught each and if there is two chores with same id in offline and online, prefer the offline one:
|
||||
var finalChores = []
|
||||
if (onlineChores && onlineChores.res) {
|
||||
finalChores = onlineChores.res.filter(
|
||||
onlineChore =>
|
||||
!offlineTasks.some(offlineTask => {
|
||||
// Match by id or tempId
|
||||
return (
|
||||
String(onlineChore.id) === String(offlineTask.id) ||
|
||||
(offlineTask.tempId &&
|
||||
String(onlineChore.id) === String(offlineTask.tempId))
|
||||
)
|
||||
}),
|
||||
// Offline feature disabled — fetch from API.
|
||||
try {
|
||||
const data = await GetChoresNew(includeArchive)
|
||||
if (data?.res) {
|
||||
syncEngine.cacheChores(data.res)
|
||||
}
|
||||
const merged = await mergePendingCreates(data?.res || [])
|
||||
return { ...data, res: merged }
|
||||
} catch {
|
||||
// API failed — fall back to whatever is in the cache
|
||||
const cached = await offlineDB.getChores(includeArchive)
|
||||
const merged = await mergePendingCreates(cached || [])
|
||||
if (merged && merged.length > 0) {
|
||||
return { res: merged }
|
||||
}
|
||||
throw new Error(
|
||||
'Unable to communicate with server and no data available',
|
||||
)
|
||||
}
|
||||
// Combine online chores with offline tasks
|
||||
if (offlineTasks.length > 0) {
|
||||
// Merge the offline tasks with the online chores
|
||||
finalChores = [
|
||||
...finalChores,
|
||||
...offlineTasks.map(task => ({
|
||||
...task,
|
||||
id: task.id || task.tempId, // Ensure we have an id for consistency
|
||||
})),
|
||||
]
|
||||
}
|
||||
|
||||
return { res: finalChores }
|
||||
|
||||
// return { res: [...onlineChores.res, ...offlineTasks] }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -73,21 +113,28 @@ export const useDeleteChores = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async choreIds => {
|
||||
// If offline mode is enabled and we're offline, handle deletion locally
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
const updatedOfflineTasks = offlineTasks.filter(
|
||||
task =>
|
||||
!choreIds.includes(task.id) && !choreIds.includes(task.tempId),
|
||||
if (!networkManager.isOnline) {
|
||||
await offlineDB.deleteChores(choreIds)
|
||||
await Promise.all(
|
||||
choreIds.map(async id => {
|
||||
await commandQueue.enqueue(CommandType.DELETE_CHORE, id, { id })
|
||||
}),
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
// Force the chores query to refetch
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
|
||||
const removeDeletedChores = oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
|
||||
const deletedIds = new Set(choreIds.map(id => String(id)))
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(chore => !deletedIds.has(String(chore.id))),
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueryData(['chores', false], removeDeletedChores)
|
||||
queryClient.setQueryData(['chores', true], removeDeletedChores)
|
||||
return
|
||||
}
|
||||
|
||||
// If online, proceed with server-side deletion
|
||||
await Promise.all(
|
||||
choreIds.map(async id => {
|
||||
const resp = await DeleteChore(id)
|
||||
@@ -99,75 +146,63 @@ export const useDeleteChores = () => {
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
export const useCreateChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const queueOfflineCreate = async newTask => {
|
||||
const offlineChore = buildOfflineChore(newTask)
|
||||
await commandQueue.enqueue(
|
||||
CommandType.CREATE_CHORE,
|
||||
offlineChore.id,
|
||||
newTask,
|
||||
)
|
||||
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) {
|
||||
return { res: [offlineChore] }
|
||||
}
|
||||
|
||||
const alreadyExists = oldData.res.some(
|
||||
chore => String(chore.id) === String(offlineChore.id),
|
||||
)
|
||||
if (alreadyExists) return oldData
|
||||
|
||||
return { ...oldData, res: [...oldData.res, offlineChore] }
|
||||
})
|
||||
|
||||
return offlineChore
|
||||
}
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async newTask => {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
if (!networkManager.isOnline) {
|
||||
return queueOfflineCreate(newTask)
|
||||
}
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
throw new Error('Failed to get created chore data')
|
||||
|
||||
try {
|
||||
const resp = await CreateChore(newTask)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to create chore')
|
||||
}
|
||||
const createdChore = await resp.json()
|
||||
if (!createdChore) {
|
||||
throw new Error('Failed to get created chore data')
|
||||
}
|
||||
return { ...newTask, id: createdChore.res }
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineCreate(newTask)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
// Successfully created the chore on the server, return the created chore
|
||||
// update the local chores cache with the new chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
if (!oldData) return { res: [createdChore.res] }
|
||||
return { res: [...oldData.res, createdChore.res] }
|
||||
})
|
||||
return { res: createdChore }
|
||||
},
|
||||
|
||||
// onMutate: async newTask => {
|
||||
// if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// const offlineTasks =
|
||||
// (await localStore.getFromCache('offlineTasks')) || []
|
||||
// const updateOfflineTasks = [
|
||||
// ...offlineTasks,
|
||||
// { ...newTask, id: tempId, tempId }, // Use the tempId for offline tracking
|
||||
// ]
|
||||
// await localStore.saveToCache('offlineTasks', updateOfflineTasks) // Save to local storage
|
||||
// // force useChores to refetch:
|
||||
// queryClient.invalidateQueries(['chores'])
|
||||
// // Force the chores query to refetch
|
||||
// queryClient.refetchQueries(['chores'])
|
||||
// // Update the chores query cache immediately
|
||||
// // queryClient.setQueryData(['chores'], oldData => {
|
||||
// // console.log('ATTEMPT TO SAVE OFFLINE TASKS:', updateOfflineTasks)
|
||||
|
||||
// // if (!oldData)
|
||||
// // return {
|
||||
// // res: [{ ...newTask, id: tempId, tempId }],
|
||||
// // } // If no data, return offline tasks
|
||||
// // return {
|
||||
// // res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// // }
|
||||
// // })
|
||||
// return { tempId }
|
||||
// }
|
||||
// const tempId = crypto.randomUUID() // Generate temp ID
|
||||
// // Update the chores query cache immediately
|
||||
// queryClient.setQueryData(['chores'], oldData => {
|
||||
// if (!oldData)
|
||||
// return {
|
||||
// res: [{ ...newTask, id: tempId, tempId }],
|
||||
// } // If no data, return offline tasks
|
||||
// return {
|
||||
// res: [...oldData.res, { ...newTask, id: tempId, tempId }],
|
||||
// }
|
||||
// })
|
||||
// return { tempId: null }
|
||||
// },
|
||||
onSuccess: () => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -177,44 +212,31 @@ export const useUpdateChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async updatedChore => {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
updatedChore['updatedAt'] = new Date().toISOString()
|
||||
if (!updatedChore['nextDueDate']) {
|
||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
||||
}
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
|
||||
for (const task of offlineTasks) {
|
||||
// Find the task with the same id or tempId and update it
|
||||
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
|
||||
// Update the task in local storage
|
||||
const updatedTask = { ...task, ...updatedChore }
|
||||
const updatedOfflineTasks = offlineTasks.map(t =>
|
||||
t.id === task.id ? updatedTask : t,
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(updatedTask)
|
||||
})
|
||||
const queueOfflineUpdate = async () => {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE,
|
||||
updatedChore.id,
|
||||
updatedChore,
|
||||
)
|
||||
const pendingChore = { ...updatedChore, _pendingUpdate: true }
|
||||
// Persist to offline DB so cache fallback reads the updated data
|
||||
await offlineDB.saveChores([pendingChore])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [pendingChore] }
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
chore.id === updatedChore.id ? pendingChore : chore,
|
||||
),
|
||||
}
|
||||
}
|
||||
const newTaskId = crypto.randomUUID()
|
||||
const updatedChoreWithNewId = {
|
||||
...updatedChore,
|
||||
tempId: newTaskId,
|
||||
}
|
||||
|
||||
await localStore.saveToCache('offlineTasks', [
|
||||
...offlineTasks,
|
||||
updatedChoreWithNewId,
|
||||
])
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve with the updated task
|
||||
resolve(updatedChoreWithNewId)
|
||||
})
|
||||
} else {
|
||||
// Call the API to update the chore
|
||||
queryClient.setQueryData(['chore', updatedChore.id], oldData => {
|
||||
if (!oldData) return { res: pendingChore }
|
||||
return { ...oldData, res: pendingChore }
|
||||
})
|
||||
return pendingChore
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
@@ -223,9 +245,7 @@ export const useUpdateChore = () => {
|
||||
if (!updatedChoreRes) {
|
||||
throw new Error('Failed to get updated chore data')
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
// update the local chores cache with the updated chore:
|
||||
queryClient.setQueryData(['chores'], oldData => {
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData) return { res: [updatedChore] }
|
||||
return {
|
||||
res: oldData.res.map(chore =>
|
||||
@@ -233,20 +253,18 @@ export const useUpdateChore = () => {
|
||||
),
|
||||
}
|
||||
})
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
return updatedChoreRes?.res || updatedChore
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
return queueOfflineUpdate()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
// Invalidate history for the specific chore
|
||||
queryClient.invalidateQueries(['choreHistory', variables.id])
|
||||
},
|
||||
onMutate: async updatedChore => {
|
||||
if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
// Handle offline case here if needed
|
||||
return
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -257,8 +275,20 @@ export const useChoresHistory = (initialLimit, includeMembers) => {
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['choresHistory', limit],
|
||||
queryFn: async () => {
|
||||
const resp = await GetChoresHistory(limit, includeMembers)
|
||||
return resp?.res || []
|
||||
try {
|
||||
const resp = await GetChoresHistory(limit, includeMembers)
|
||||
const entries = resp?.res || []
|
||||
// Cache for offline use — fire-and-forget so a cache failure never
|
||||
// degrades the online experience
|
||||
if (entries.length > 0) {
|
||||
offlineDB
|
||||
.saveHistory(entries)
|
||||
.catch(err => console.error('Failed to cache chores history:', err))
|
||||
}
|
||||
return entries
|
||||
} catch {
|
||||
return offlineDB.getHistoryByDays(limit)
|
||||
}
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
@@ -275,30 +305,20 @@ export const useChoreDetails = choreId => {
|
||||
queryKey: ['choreDetails', choreId],
|
||||
refetchOnWindowFocus: true,
|
||||
queryFn: async () => {
|
||||
var onlineChore = null
|
||||
|
||||
try {
|
||||
const response = await GetChoreDetailById(choreId)
|
||||
|
||||
if (response && response.ok) {
|
||||
onlineChore = await response.json()
|
||||
return await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
throw new Error('Failed to fetch chore detail')
|
||||
} catch {
|
||||
// Fall back to cached chore (without timer details)
|
||||
const cached = await offlineDB.getChore(choreId)
|
||||
if (cached) {
|
||||
return { res: cached }
|
||||
}
|
||||
throw new Error('Chore detail not available offline')
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
// Match by tempId or id if it was created offline
|
||||
return task.id === choreId || (task.tempId && task.tempId === choreId)
|
||||
})
|
||||
|
||||
return { res: offline ? { ...offline } : onlineChore.res }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -312,32 +332,21 @@ export const useChore = choreId => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch chore details')
|
||||
}
|
||||
var onlineChore = null
|
||||
|
||||
try {
|
||||
const response = await GetChoreByID(choreId)
|
||||
|
||||
if (response && response.ok) {
|
||||
onlineChore = await response.json()
|
||||
return await response.json()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching chore detail:', error)
|
||||
throw new Error('Failed to fetch chore')
|
||||
} catch {
|
||||
// API failed — try offline cache
|
||||
const cached = await offlineDB.getChore(choreId)
|
||||
if (cached) {
|
||||
return { res: cached }
|
||||
}
|
||||
throw new Error('Chore not available offline')
|
||||
}
|
||||
|
||||
// Only check offline tasks if experimental offline mode is enabled
|
||||
if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) {
|
||||
return onlineChore
|
||||
}
|
||||
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
const offline = offlineTasks.find(task => {
|
||||
return (
|
||||
String(task.id) === choreId ||
|
||||
(task.tempId && task.tempId === choreId)
|
||||
)
|
||||
})
|
||||
|
||||
return { res: offline ? { ...offline } : onlineChore.res }
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
@@ -374,11 +383,30 @@ export const useChoreHistory = choreId => {
|
||||
if (!choreId) {
|
||||
throw new Error('Chore ID is required to fetch history')
|
||||
}
|
||||
const response = await GetChoreHistory(choreId)
|
||||
if (response && response.ok) {
|
||||
return await response.json()
|
||||
let json
|
||||
try {
|
||||
const response = await GetChoreHistory(choreId)
|
||||
if (response && response.ok) {
|
||||
json = await response.json()
|
||||
} else {
|
||||
throw new Error('Failed to fetch chore history')
|
||||
}
|
||||
} catch {
|
||||
const cached = await offlineDB.getHistoryByChore(choreId)
|
||||
return { res: cached }
|
||||
}
|
||||
throw new Error('Failed to fetch chore history')
|
||||
// Cache for offline use — fire-and-forget so a cache failure never
|
||||
// degrades the online view. Inject choreId since the single-chore
|
||||
// endpoint may omit it from each entry.
|
||||
const entries = (json?.res || []).map(e =>
|
||||
e.choreId != null ? e : { ...e, choreId: Number(choreId) },
|
||||
)
|
||||
if (entries.length > 0) {
|
||||
offlineDB
|
||||
.saveHistory(entries)
|
||||
.catch(err => console.error('Failed to cache chore history:', err))
|
||||
}
|
||||
return json
|
||||
},
|
||||
enabled: !!choreId,
|
||||
staleTime: 0, // Always consider data stale
|
||||
@@ -392,10 +420,62 @@ export const useUpdateChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId, historyData }) =>
|
||||
UpdateChoreHistory(choreId, historyId, historyData),
|
||||
mutationFn: async ({ choreId, historyId, historyData }) => {
|
||||
const applyOptimisticUpdate = async () => {
|
||||
queryClient.setQueryData(['choreHistory', choreId], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(entry =>
|
||||
entry.id === historyId
|
||||
? { ...entry, ...historyData, _pendingUpdate: true }
|
||||
: entry,
|
||||
),
|
||||
}
|
||||
})
|
||||
await offlineDB.updateHistoryEntry(choreId, historyId, {
|
||||
...historyData,
|
||||
_pendingUpdate: true,
|
||||
})
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId, historyData },
|
||||
)
|
||||
return applyOptimisticUpdate()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await UpdateChoreHistory(
|
||||
choreId,
|
||||
historyId,
|
||||
historyData,
|
||||
)
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to update chore history')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.UPDATE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId, historyData },
|
||||
)
|
||||
return applyOptimisticUpdate()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
if (!data?.queued) {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -404,10 +484,57 @@ export const useDeleteChoreHistory = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, historyId }) =>
|
||||
DeleteChoreHistory(choreId, historyId),
|
||||
mutationFn: async ({ choreId, historyId }) => {
|
||||
const applyOptimisticDelete = async () => {
|
||||
queryClient.setQueryData(['choreHistory', choreId], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(entry =>
|
||||
entry.id === historyId
|
||||
? { ...entry, _pendingDelete: true }
|
||||
: entry,
|
||||
),
|
||||
}
|
||||
})
|
||||
await offlineDB.updateHistoryEntry(choreId, historyId, {
|
||||
_pendingDelete: true,
|
||||
})
|
||||
return { queued: true }
|
||||
}
|
||||
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId },
|
||||
)
|
||||
return applyOptimisticDelete()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await DeleteChoreHistory(choreId, historyId)
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to delete chore history')
|
||||
}
|
||||
return response
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE_HISTORY,
|
||||
`${choreId}:${historyId}`,
|
||||
{ choreId, historyId },
|
||||
)
|
||||
return applyOptimisticDelete()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onSuccess: (data, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
if (!data?.queued) {
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
}
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -416,12 +543,80 @@ export const useMarkChoreComplete = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, body, completedDate, performer }) =>
|
||||
MarkChoreComplete(choreId, body, completedDate, performer),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
mutationFn: async ({ choreId, body, completedDate, performer }) => {
|
||||
if (!networkManager.isOnline) {
|
||||
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,
|
||||
})
|
||||
// Optimistically update the cache to show pending state
|
||||
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' } }
|
||||
}
|
||||
|
||||
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'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -430,11 +625,48 @@ export const useSkipChore = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: SkipChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
mutationFn: async choreId => {
|
||||
if (!networkManager.isOnline) {
|
||||
await commandQueue.enqueue(CommandType.SKIP_CHORE, choreId, {
|
||||
id: choreId,
|
||||
})
|
||||
// Optimistically update the cache to show pending state
|
||||
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' } }
|
||||
}
|
||||
|
||||
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'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
queryClient.invalidateQueries(['pendingCommands'])
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -444,7 +676,7 @@ export const useApproveChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ApproveChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
@@ -457,7 +689,7 @@ export const useRejectChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: RejectChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
queryClient.invalidateQueries(['choreDetails', choreId])
|
||||
|
||||
@@ -1,73 +1,26 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { CompleteSubTask, SaveChore } from '../utils/Fetcher'
|
||||
import { localStore } from '../utils/LocalStore'
|
||||
|
||||
export const useUpdate = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
updatedChore['updatedAt'] = new Date().toISOString()
|
||||
if (!updatedChore['nextDueDate']) {
|
||||
updatedChore['nextDueDate'] = updatedChore['dueDate']
|
||||
}
|
||||
const offlineTasks =
|
||||
(await localStore.getFromCache('offlineTasks')) || []
|
||||
|
||||
for (const task of offlineTasks) {
|
||||
// Find the task with the same id or tempId and update it
|
||||
if (task.id === updatedChore.id || task.tempId === updatedChore.id) {
|
||||
// Update the task in local storage
|
||||
const updatedTask = { ...task, ...updatedChore }
|
||||
const updatedOfflineTasks = offlineTasks.map(t =>
|
||||
t.id === task.id ? updatedTask : t,
|
||||
)
|
||||
await localStore.saveToCache('offlineTasks', updatedOfflineTasks)
|
||||
return new Promise((resolve, reject) => {
|
||||
resolve(updatedTask)
|
||||
})
|
||||
}
|
||||
}
|
||||
const newTaskId = crypto.randomUUID()
|
||||
const updatedChoreWithNewId = {
|
||||
...updatedChore,
|
||||
tempId: newTaskId,
|
||||
}
|
||||
|
||||
await localStore.saveToCache('offlineTasks', [
|
||||
...offlineTasks,
|
||||
updatedChoreWithNewId,
|
||||
])
|
||||
return new Promise((resolve, reject) => {
|
||||
// Resolve with the updated task
|
||||
resolve(updatedChoreWithNewId)
|
||||
})
|
||||
} else {
|
||||
// Call the API to update the chore
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
}
|
||||
const updatedChoreRes = await resp.json()
|
||||
if (!updatedChoreRes) {
|
||||
throw new Error('Failed to get updated chore data')
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
const resp = await SaveChore(updatedChore)
|
||||
if (!resp || !resp.ok) {
|
||||
throw new Error('Failed to save chore')
|
||||
}
|
||||
const updatedChoreRes = await resp.json()
|
||||
if (!updatedChoreRes) {
|
||||
throw new Error('Failed to get updated chore data')
|
||||
}
|
||||
// Successfully updated the chore on the server, return the updated chore
|
||||
return updatedChoreRes?.res || updatedChoreRes
|
||||
},
|
||||
onSuccess: (data, variables) => {
|
||||
// Invalidate the chores query to refresh the data
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
},
|
||||
onMutate: async updatedChore => {
|
||||
if (!networkManager.isOnline) {
|
||||
// Handle offline case here if needed
|
||||
return
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export const useStartChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: StartChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -44,7 +44,7 @@ export const usePauseChore = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: PauseChore,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -58,7 +58,7 @@ export const useUpdateTimeSession = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId, sessionData }) =>
|
||||
UpdateTimeSession(choreId, sessionId, sessionData),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -72,7 +72,7 @@ export const useDeleteTimeSession = () => {
|
||||
return useMutation({
|
||||
mutationFn: ({ choreId, sessionId }) =>
|
||||
DeleteTimeSession(choreId, sessionId),
|
||||
onSuccess: (data, { choreId }) => {
|
||||
onSuccess: (_, { choreId }) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -85,7 +85,7 @@ export const useResetChoreTimer = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ResetChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
@@ -98,7 +98,7 @@ export const useClearChoreTimer = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ClearChoreTimer,
|
||||
onSuccess: (data, choreId) => {
|
||||
onSuccess: (_, choreId) => {
|
||||
queryClient.invalidateQueries(['choreTimer', choreId])
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
queryClient.invalidateQueries(['choreHistory', choreId])
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
GetDeviceTokens,
|
||||
GetUserProfile,
|
||||
} from '../utils/Fetcher'
|
||||
import { offlineDB } from '../utils/OfflineDB'
|
||||
|
||||
// Helper to check if we have a valid token
|
||||
const isTokenValid = () => {
|
||||
@@ -30,7 +31,20 @@ export const useCircleMembers = () => {
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['allCircleMembers'],
|
||||
queryFn: GetAllCircleMembers,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const result = await GetAllCircleMembers()
|
||||
// Cache for offline use
|
||||
if (result?.res) {
|
||||
offlineDB.saveKV('circle_members', result.res).catch(() => {})
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('circle_members')
|
||||
if (cached) return { res: cached }
|
||||
return { res: [] }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handleRefetch = () => {
|
||||
@@ -42,19 +56,31 @@ export const useCircleMembers = () => {
|
||||
|
||||
export const useUserProfile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
const { data, error, isLoading } = useQuery({
|
||||
queryKey: ['userProfile'],
|
||||
queryKey: ['userProfile', token],
|
||||
queryFn: async () => {
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return result.res || null
|
||||
try {
|
||||
const resp = await GetUserProfile()
|
||||
const result = await resp.json()
|
||||
// if we got 403 then user probably deleted their account and token is still valid. navigate to login
|
||||
if (result?.res) {
|
||||
await offlineDB.saveKV('user_profile', result.res)
|
||||
}
|
||||
return result.res || null
|
||||
} catch {
|
||||
// API unreachable — only serve cached profile for authenticated sessions
|
||||
return await offlineDB.getKV('user_profile')
|
||||
}
|
||||
},
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
gcTime: 30 * 60 * 1000, // 30 minutes in milliseconds
|
||||
enabled: isTokenValid(), // Only run query when we have a valid token
|
||||
staleTime: 30 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
enabled: !!token,
|
||||
})
|
||||
return {
|
||||
data,
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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,
|
||||
@@ -146,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) {
|
||||
@@ -222,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
|
||||
}
|
||||
|
||||
246
src/utils/CommandQueue.js
Normal file
246
src/utils/CommandQueue.js
Normal file
@@ -0,0 +1,246 @@
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
// Domain command types
|
||||
export const CommandType = {
|
||||
CREATE_CHORE: 'create_chore',
|
||||
UPDATE_CHORE: 'update_chore',
|
||||
UPDATE_CHORE_HISTORY: 'update_chore_history',
|
||||
COMPLETE_CHORE: 'complete_chore',
|
||||
SKIP_CHORE: 'skip_chore',
|
||||
START_CHORE: 'start_chore',
|
||||
PAUSE_CHORE: 'pause_chore',
|
||||
DELETE_CHORE: 'delete_chore',
|
||||
DELETE_CHORE_HISTORY: 'delete_chore_history',
|
||||
RESCHEDULE_CHORE: 'reschedule_chore',
|
||||
ARCHIVE_CHORE: 'archive_chore',
|
||||
UNARCHIVE_CHORE: 'unarchive_chore',
|
||||
}
|
||||
|
||||
class CommandQueue {
|
||||
_sanitizeCreatePayload(payload = {}) {
|
||||
const sanitized = { ...payload }
|
||||
delete sanitized.id
|
||||
delete sanitized._pendingCreate
|
||||
delete sanitized._pendingUpdate
|
||||
return sanitized
|
||||
}
|
||||
|
||||
_clearPendingFlags(chore = {}) {
|
||||
const next = { ...chore }
|
||||
delete next._pending
|
||||
delete next._pendingUpdate
|
||||
return next
|
||||
}
|
||||
|
||||
async _rollbackCancelledCommand(command) {
|
||||
if (!command) return
|
||||
|
||||
if (
|
||||
command.commandType !== CommandType.ARCHIVE_CHORE &&
|
||||
command.commandType !== CommandType.UNARCHIVE_CHORE
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const cachedChore = await offlineDB.getChore(command.entityId)
|
||||
if (!cachedChore) return
|
||||
|
||||
const restoredChore = this._clearPendingFlags({
|
||||
...cachedChore,
|
||||
isActive: command.commandType === CommandType.ARCHIVE_CHORE,
|
||||
})
|
||||
|
||||
await offlineDB.saveChores([restoredChore])
|
||||
}
|
||||
|
||||
// Enqueue a domain command
|
||||
async enqueue(type, entityId, payload) {
|
||||
if (!isOfflineFeatureEnabled()) {
|
||||
throw new Error('Offline support is disabled on this device')
|
||||
}
|
||||
|
||||
const command = {
|
||||
commandType: type,
|
||||
entityId: String(entityId),
|
||||
payload: JSON.stringify(payload),
|
||||
createdAt: Date.now(),
|
||||
status: 'pending',
|
||||
error: null,
|
||||
}
|
||||
return offlineDB.enqueueCommand(command)
|
||||
}
|
||||
|
||||
// Get all pending commands in order
|
||||
async getPending() {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const commands = await offlineDB.getCommands()
|
||||
return commands
|
||||
.filter(c => c.status === 'pending' || c.status === 'syncing')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Get all failed commands
|
||||
async getFailed() {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const commands = await offlineDB.getCommands()
|
||||
return commands
|
||||
.filter(c => c.status === 'failed')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Get pending commands for a specific entity (for undo/UI)
|
||||
async getPendingForEntity(entityId) {
|
||||
if (!isOfflineFeatureEnabled()) return []
|
||||
const allCommands = await offlineDB.getCommands()
|
||||
const key = String(entityId)
|
||||
const commands = allCommands
|
||||
.filter(
|
||||
c =>
|
||||
c.entityId === key ||
|
||||
(typeof c.entityId === 'string' && c.entityId.startsWith(`${key}:`)),
|
||||
)
|
||||
.sort((a, b) => a.createdAt - b.createdAt)
|
||||
return commands
|
||||
.filter(c => c.status === 'pending' || c.status === 'syncing')
|
||||
.map(c => ({ ...c, payload: JSON.parse(c.payload) }))
|
||||
}
|
||||
|
||||
// Cancel/undo a pending command
|
||||
async cancel(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
|
||||
const allCommands = await offlineDB.getCommands()
|
||||
const command = allCommands.find(c => String(c.id) === String(commandId))
|
||||
|
||||
await this._rollbackCancelledCommand(command)
|
||||
return offlineDB.removeCommand(commandId)
|
||||
}
|
||||
|
||||
// Mark as syncing
|
||||
async markSyncing(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'syncing', null)
|
||||
}
|
||||
|
||||
// Mark as failed (only for unrecoverable errors like conflicts)
|
||||
async markFailed(commandId, error) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'failed', error)
|
||||
}
|
||||
|
||||
// Reset back to pending (for transient network/server errors so it retries)
|
||||
async resetPending(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.updateCommandStatus(commandId, 'pending', null)
|
||||
}
|
||||
|
||||
// Reset any in-flight commands so they remain retryable after aborted syncs
|
||||
async resetSyncing() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const commands = await offlineDB.getCommands()
|
||||
const syncingCommands = commands.filter(c => c.status === 'syncing')
|
||||
|
||||
await Promise.all(
|
||||
syncingCommands.map(cmd =>
|
||||
offlineDB.updateCommandStatus(cmd.id, 'pending', null),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// Remove after successful sync
|
||||
async markDone(commandId) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
return offlineDB.removeCommand(commandId)
|
||||
}
|
||||
|
||||
// Compact: merge consecutive updates to same entity
|
||||
async compact() {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
const pending = await this.getPending()
|
||||
const seen = new Map() // entityId -> last command
|
||||
const toRemove = []
|
||||
|
||||
for (const cmd of pending) {
|
||||
const prev = seen.get(cmd.entityId)
|
||||
|
||||
if (prev?.commandType === CommandType.CREATE_CHORE) {
|
||||
if (cmd.commandType === CommandType.UPDATE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
...cmd.payload,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.RESCHEDULE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
dueDate: cmd.payload?.dueDate ?? prev.payload?.dueDate,
|
||||
nextDueDate: cmd.payload?.dueDate ?? prev.payload?.nextDueDate,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.ARCHIVE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
isActive: false,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.UNARCHIVE_CHORE) {
|
||||
const mergedPayload = this._sanitizeCreatePayload({
|
||||
...prev.payload,
|
||||
isActive: true,
|
||||
})
|
||||
await offlineDB.updateCommand(prev.id, {
|
||||
payload: JSON.stringify(mergedPayload),
|
||||
})
|
||||
toRemove.push(cmd.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.DELETE_CHORE) {
|
||||
toRemove.push(prev.id, cmd.id)
|
||||
seen.delete(cmd.entityId)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.UPDATE_CHORE) {
|
||||
if (prev && prev.commandType === CommandType.UPDATE_CHORE) {
|
||||
// Merge: keep latest payload, remove older
|
||||
toRemove.push(prev.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd.commandType === CommandType.DELETE_CHORE) {
|
||||
if (prev?.commandType === CommandType.UPDATE_CHORE) {
|
||||
toRemove.push(prev.id)
|
||||
}
|
||||
}
|
||||
|
||||
seen.set(cmd.entityId, cmd)
|
||||
}
|
||||
|
||||
for (const id of [...new Set(toRemove)]) {
|
||||
await offlineDB.removeCommand(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const commandQueue = new CommandQueue()
|
||||
@@ -1,70 +1,3 @@
|
||||
export const FEATURES = {
|
||||
OFFLINE_MODE: 'experimental_feature_offline_mode',
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current state of a feature flag from localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} defaultValue - Default value if feature is not set (default: false)
|
||||
* @returns {boolean} - Whether the feature is enabled
|
||||
*/
|
||||
export const isFeatureEnabled = (featureKey, defaultValue = false) => {
|
||||
try {
|
||||
const value = localStorage.getItem(featureKey)
|
||||
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
|
||||
if (value === null || value === undefined) return defaultValue
|
||||
|
||||
return Boolean(value)
|
||||
} catch (error) {
|
||||
console.warn(`FeatureToggle: Error reading feature "${featureKey}":`, error)
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of a feature flag in localStorage
|
||||
* @param {string} featureKey - The feature key from FEATURES constant
|
||||
* @param {boolean} enabled - Whether to enable the feature
|
||||
*/
|
||||
export const setFeatureEnabled = (featureKey, enabled) => {
|
||||
try {
|
||||
localStorage.setItem(featureKey, enabled.toString())
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`FeatureToggle: Error setting feature "${featureKey}":`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const toggleFeature = featureKey => {
|
||||
const currentState = isFeatureEnabled(featureKey)
|
||||
const newState = !currentState
|
||||
setFeatureEnabled(featureKey, newState)
|
||||
return newState
|
||||
}
|
||||
|
||||
export const getAllFeatureStates = () => {
|
||||
const states = {}
|
||||
Object.entries(FEATURES).forEach(([name, key]) => {
|
||||
states[name] = isFeatureEnabled(key)
|
||||
})
|
||||
return states
|
||||
}
|
||||
|
||||
export const clearAllFeatures = () => {
|
||||
try {
|
||||
Object.values(FEATURES).forEach(featureKey => {
|
||||
localStorage.removeItem(featureKey)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('FeatureToggle: Error clearing features:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current instance is the official donetick.com service
|
||||
* @returns {Promise<boolean>} - Whether this is the official donetick.com instance
|
||||
@@ -102,7 +35,11 @@ export const isOfficialDonetickInstanceSync = () => {
|
||||
// Dynamic import to avoid circular dependencies
|
||||
return import('./ApiClient')
|
||||
.then(({ apiClient }) => {
|
||||
const currentApiUrl = apiClient.baseURL
|
||||
const currentApiUrl =
|
||||
apiClient.baseURL || apiClient.customServerURL || ''
|
||||
if (!currentApiUrl || typeof currentApiUrl !== 'string') {
|
||||
return false
|
||||
}
|
||||
// Check if the API URL contains donetick.com
|
||||
return currentApiUrl.toLowerCase().includes('donetick.com')
|
||||
})
|
||||
@@ -123,12 +60,6 @@ export const isOfficialDonetickInstanceSync = () => {
|
||||
|
||||
// Export default object for easier imports
|
||||
export default {
|
||||
FEATURES,
|
||||
isFeatureEnabled,
|
||||
setFeatureEnabled,
|
||||
toggleFeature,
|
||||
getAllFeatureStates,
|
||||
clearAllFeatures,
|
||||
isOfficialDonetickInstance,
|
||||
isOfficialDonetickInstanceSync,
|
||||
}
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import { CapacitorSQLite } from '@capacitor-community/sqlite'
|
||||
|
||||
const CACHE_TABLE = 'offline_cache'
|
||||
const QUEUE_TABLE = 'offline_request_queue'
|
||||
const OFFLINE_TASK = 'offlineTasks' // For storing offline tasks
|
||||
|
||||
class LocalStore {
|
||||
constructor() {
|
||||
this.db = null
|
||||
// this.useLocalStorage = !Capacitor.isNativePlatform()
|
||||
this.useLocalStorage = true // default to localStorage for now.
|
||||
}
|
||||
|
||||
async initDatabase() {
|
||||
if (this.useLocalStorage) return null
|
||||
if (this.db) return this.db
|
||||
|
||||
const db = await CapacitorSQLite.createConnection({
|
||||
database: 'offline_data',
|
||||
version: 1,
|
||||
})
|
||||
await db.open()
|
||||
|
||||
// Create tables if they don't exist
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS ${CACHE_TABLE} (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT,
|
||||
timestamp INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ${QUEUE_TABLE} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url TEXT,
|
||||
requestBody TEXT
|
||||
);
|
||||
`)
|
||||
|
||||
this.db = db
|
||||
return db
|
||||
}
|
||||
|
||||
async saveToCache(key, data) {
|
||||
const timestamp = Date.now() // Current timestamp in milliseconds
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
localStorage.setItem(key, JSON.stringify({ value: data, timestamp }))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
INSERT OR REPLACE INTO ${CACHE_TABLE} (key, value, timestamp)
|
||||
VALUES (?, ?, ?);
|
||||
`,
|
||||
[key, JSON.stringify(data), timestamp],
|
||||
)
|
||||
}
|
||||
|
||||
// async saveTemporaryTask(task) {
|
||||
// const baseURL = apiManager.getApiURL()
|
||||
// const fullURL = `${baseURL}/chores/${task.tempId}`
|
||||
// const options = {
|
||||
// method: 'GET',
|
||||
// headers: HEADERS(),
|
||||
// url: fullURL,
|
||||
// }
|
||||
// const respond = { res: task }
|
||||
// const requestId = murmurhash.v3(JSON.stringify({ fullURL, options }))
|
||||
|
||||
// if (this.useLocalStorage) {
|
||||
// this.saveToCache(requestId, respond)
|
||||
// return
|
||||
// }
|
||||
// const db = await this.initDatabase()
|
||||
|
||||
// await db.run(
|
||||
// `
|
||||
// INSERT INTO ${CACHE_TABLE} (url, requestBody)
|
||||
// VALUES (?, ?);
|
||||
// `,
|
||||
// [
|
||||
// requestId,
|
||||
// JSON.stringify({
|
||||
// url: fullURL,
|
||||
// options: { method: 'GET', headers: HEADERS() },
|
||||
// }),
|
||||
// ],
|
||||
// )
|
||||
// console.log('Saved temporary task to queue:', task)
|
||||
// return
|
||||
// }
|
||||
|
||||
async getFromCache(key, ttl = 0) {
|
||||
const now = Date.now()
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
const cachedItem = localStorage.getItem(key)
|
||||
if (!cachedItem) return null
|
||||
|
||||
const { value, timestamp } = JSON.parse(cachedItem)
|
||||
if (ttl > 0 && now - timestamp > ttl) {
|
||||
localStorage.removeItem(key) // Remove expired item
|
||||
return null
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
const result = await db.query(
|
||||
`
|
||||
SELECT value, timestamp FROM ${CACHE_TABLE} WHERE key = ?;
|
||||
`,
|
||||
[key],
|
||||
)
|
||||
|
||||
if (result.values.length === 0) return null
|
||||
|
||||
const { value, timestamp } = result.values[0]
|
||||
if (ttl > 0 && now - timestamp > ttl) {
|
||||
// Remove expired item
|
||||
await db.run(`DELETE FROM ${CACHE_TABLE} WHERE key = ?;`, [key])
|
||||
return null
|
||||
}
|
||||
|
||||
return JSON.parse(value)
|
||||
}
|
||||
|
||||
async cleanExpiredCache(ttl) {
|
||||
const now = Date.now()
|
||||
|
||||
if (this.useLocalStorage) {
|
||||
const keys = Object.keys(localStorage)
|
||||
for (const key of keys) {
|
||||
const cachedItem = localStorage.getItem(key)
|
||||
if (!cachedItem) continue
|
||||
|
||||
const { timestamp } = JSON.parse(cachedItem)
|
||||
if (now - timestamp > ttl) {
|
||||
localStorage.removeItem(key) // Remove expired item
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
DELETE FROM ${CACHE_TABLE} WHERE ? - timestamp > ?;
|
||||
`,
|
||||
[now, ttl],
|
||||
)
|
||||
}
|
||||
|
||||
async queueRequest(requestId, requestPayload) {
|
||||
if (this.useLocalStorage) {
|
||||
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
|
||||
console.log('requestPayload', requestPayload)
|
||||
|
||||
if (typeof requestPayload?.options?.body['id'] === 'string') {
|
||||
requestPayload['id'] = null
|
||||
}
|
||||
queue.push({ requestId, requestBody: requestPayload })
|
||||
localStorage.setItem(QUEUE_TABLE, JSON.stringify(queue))
|
||||
return
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
await db.run(
|
||||
`
|
||||
INSERT INTO ${QUEUE_TABLE} (url, requestBody)
|
||||
VALUES (?, ?);
|
||||
`,
|
||||
[requestId, JSON.stringify(requestPayload)],
|
||||
)
|
||||
}
|
||||
|
||||
async syncQueuedRequests() {
|
||||
console.log('Syncing queued requests...')
|
||||
|
||||
var queueSize = 0
|
||||
if (this.useLocalStorage) {
|
||||
const queue = JSON.parse(localStorage.getItem(QUEUE_TABLE)) || []
|
||||
console.log('LocalStore: queue: ', queue)
|
||||
queueSize = queue.length
|
||||
for (const request of queue) {
|
||||
try {
|
||||
await fetch(request.requestBody.url, request.requestBody.options)
|
||||
console.log('LocalStore: Synced request:', request)
|
||||
} catch (error) {
|
||||
console.error('LocalStore: Failed to sync request:', request, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the queue after syncing
|
||||
localStorage.removeItem(QUEUE_TABLE)
|
||||
localStorage.removeItem(OFFLINE_TASK)
|
||||
return queueSize > 0
|
||||
}
|
||||
|
||||
const db = await this.initDatabase()
|
||||
const result = await db.query(`SELECT * FROM ${QUEUE_TABLE};`)
|
||||
queueSize = result.values.length
|
||||
for (const request of result.values) {
|
||||
try {
|
||||
await fetch(
|
||||
request.requestBody.url,
|
||||
JSON.parse(request.requestBody.options),
|
||||
)
|
||||
console.log('Synced request:', request)
|
||||
} catch (error) {
|
||||
console.error('Failed to sync request:', request, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the queue after syncing
|
||||
await db.run(`DELETE FROM ${QUEUE_TABLE};`)
|
||||
return queueSize > 0
|
||||
}
|
||||
}
|
||||
|
||||
export const localStore = new LocalStore()
|
||||
1090
src/utils/OfflineDB.js
Normal file
1090
src/utils/OfflineDB.js
Normal file
File diff suppressed because it is too large
Load Diff
58
src/utils/OfflineFeatureToggle.js
Normal file
58
src/utils/OfflineFeatureToggle.js
Normal file
@@ -0,0 +1,58 @@
|
||||
const OFFLINE_FEATURE_KEY = 'offline_feature_enabled'
|
||||
const OFFLINE_FEATURE_EVENT = 'donetick:offline-feature-changed'
|
||||
|
||||
const parseBoolean = value => {
|
||||
if (value === null || typeof value === 'undefined') return true
|
||||
try {
|
||||
return JSON.parse(value) !== false
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const isOfflineFeatureEnabled = () => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return true
|
||||
return parseBoolean(window.localStorage.getItem(OFFLINE_FEATURE_KEY))
|
||||
}
|
||||
|
||||
export const setOfflineFeatureEnabled = enabled => {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return
|
||||
window.localStorage.setItem(OFFLINE_FEATURE_KEY, JSON.stringify(!!enabled))
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(OFFLINE_FEATURE_EVENT, {
|
||||
detail: { enabled: !!enabled },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const subscribeToOfflineFeature = callback => {
|
||||
if (typeof window === 'undefined') return () => {}
|
||||
|
||||
const handleToggle = event => {
|
||||
if (event?.type === 'storage') {
|
||||
if (event.key !== OFFLINE_FEATURE_KEY) return
|
||||
callback(parseBoolean(event.newValue))
|
||||
return
|
||||
}
|
||||
|
||||
callback(!!event?.detail?.enabled)
|
||||
}
|
||||
|
||||
window.addEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
|
||||
window.addEventListener('storage', handleToggle)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(OFFLINE_FEATURE_EVENT, handleToggle)
|
||||
window.removeEventListener('storage', handleToggle)
|
||||
}
|
||||
}
|
||||
|
||||
export const clearBrowserCacheStorage = async () => {
|
||||
if (typeof window === 'undefined' || !('caches' in window)) return
|
||||
try {
|
||||
const cacheKeys = await window.caches.keys()
|
||||
await Promise.all(cacheKeys.map(key => window.caches.delete(key)))
|
||||
} catch {
|
||||
// Ignore cache clear failures and continue with offline cleanup
|
||||
}
|
||||
}
|
||||
248
src/utils/SyncEngine.js
Normal file
248
src/utils/SyncEngine.js
Normal file
@@ -0,0 +1,248 @@
|
||||
import { networkManager } from '../hooks/NetworkManager'
|
||||
import { apiClient } from './ApiClient'
|
||||
import { commandQueue, CommandType } from './CommandQueue'
|
||||
import {
|
||||
ArchiveChore,
|
||||
CreateChore,
|
||||
DeleteChore,
|
||||
DeleteChoreHistory,
|
||||
MarkChoreComplete,
|
||||
PauseChore,
|
||||
SaveChore,
|
||||
SkipChore,
|
||||
StartChore,
|
||||
UnArchiveChore,
|
||||
UpdateChoreHistory,
|
||||
UpdateDueDate,
|
||||
} from './Fetcher'
|
||||
import { offlineDB } from './OfflineDB'
|
||||
import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
|
||||
|
||||
class SyncEngine {
|
||||
constructor() {
|
||||
this.isSyncing = false
|
||||
this.listeners = []
|
||||
}
|
||||
|
||||
// Register listener for sync state changes
|
||||
onSyncStateChange(callback) {
|
||||
this.listeners.push(callback)
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter(l => l !== callback)
|
||||
}
|
||||
}
|
||||
|
||||
_notify(state) {
|
||||
this.listeners.forEach(cb => cb(state))
|
||||
}
|
||||
|
||||
// Main sync entry point — returns true if sync succeeded, false otherwise
|
||||
async sync() {
|
||||
if (!isOfflineFeatureEnabled()) return false
|
||||
if (this.isSyncing) return false
|
||||
this.isSyncing = true
|
||||
this._notify({ syncing: true, error: null })
|
||||
|
||||
try {
|
||||
await commandQueue.resetSyncing()
|
||||
|
||||
// Step 1: Compact the queue (merge consecutive updates)
|
||||
await commandQueue.compact()
|
||||
|
||||
// Step 2: Replay pending commands
|
||||
await this._replayCommands()
|
||||
|
||||
// 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) {
|
||||
await commandQueue.resetSyncing()
|
||||
console.error('Sync failed:', err)
|
||||
this._notify({ syncing: false, error: err.message })
|
||||
return false
|
||||
} finally {
|
||||
this.isSyncing = false
|
||||
}
|
||||
}
|
||||
|
||||
async _replayCommands() {
|
||||
const commands = await commandQueue.getPending()
|
||||
|
||||
for (const cmd of commands) {
|
||||
if (!networkManager.isOnline) break
|
||||
|
||||
await commandQueue.markSyncing(cmd.id)
|
||||
|
||||
try {
|
||||
await this._executeCommand(cmd)
|
||||
await commandQueue.markDone(cmd.id)
|
||||
} catch (err) {
|
||||
const status = err.status || err.statusCode
|
||||
if (status === 409) {
|
||||
// Conflict - mark for user attention but continue with other commands
|
||||
await commandQueue.markFailed(
|
||||
cmd.id,
|
||||
'Conflict: modified by another user',
|
||||
)
|
||||
} else if (status === 404) {
|
||||
// Entity no longer exists - discard command
|
||||
await commandQueue.markDone(cmd.id)
|
||||
} else {
|
||||
// Transient network/server error - reset to pending so it retries
|
||||
await commandQueue.resetPending(cmd.id)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _executeCommand(cmd) {
|
||||
let response
|
||||
|
||||
switch (cmd.commandType) {
|
||||
case CommandType.CREATE_CHORE:
|
||||
response = await CreateChore(cmd.payload)
|
||||
break
|
||||
|
||||
case CommandType.UPDATE_CHORE:
|
||||
response = await SaveChore(cmd.payload)
|
||||
break
|
||||
|
||||
case CommandType.COMPLETE_CHORE: {
|
||||
const { id, body, completedDate, performer } = cmd.payload
|
||||
response = await MarkChoreComplete(
|
||||
id,
|
||||
body || {},
|
||||
completedDate || null,
|
||||
performer || null,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.SKIP_CHORE:
|
||||
response = await SkipChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.START_CHORE:
|
||||
response = await StartChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.PAUSE_CHORE:
|
||||
response = await PauseChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.DELETE_CHORE:
|
||||
response = await DeleteChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.UPDATE_CHORE_HISTORY: {
|
||||
const { choreId, historyId, historyData } = cmd.payload
|
||||
response = await UpdateChoreHistory(choreId, historyId, historyData)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.DELETE_CHORE_HISTORY: {
|
||||
const { choreId, historyId } = cmd.payload
|
||||
response = await DeleteChoreHistory(choreId, historyId)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.RESCHEDULE_CHORE: {
|
||||
const { id, dueDate } = cmd.payload
|
||||
response = await UpdateDueDate(id, dueDate)
|
||||
break
|
||||
}
|
||||
|
||||
case CommandType.ARCHIVE_CHORE:
|
||||
response = await ArchiveChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
case CommandType.UNARCHIVE_CHORE:
|
||||
response = await UnArchiveChore(cmd.payload.id || cmd.entityId)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn('Unknown command type:', cmd.commandType)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the response indicates an error and throw so the caller can handle it
|
||||
if (response && typeof response.ok !== 'undefined' && !response.ok) {
|
||||
const err = new Error(`API error: ${response.status}`)
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async _deltaSync() {
|
||||
const cursor = (await offlineDB.getSyncCursor()) || -1
|
||||
|
||||
let hasMore = true
|
||||
let currentCursor = cursor
|
||||
|
||||
while (hasMore && networkManager.deviceOnline) {
|
||||
// Use apiClient.get which handles auth and returns a fetch Response
|
||||
const response = await apiClient.get(
|
||||
`/sync/changes?since=${currentCursor}`,
|
||||
)
|
||||
|
||||
if (!response || !response.ok) {
|
||||
const error = new Error(
|
||||
response
|
||||
? `Delta sync failed: ${response.status}`
|
||||
: 'Delta sync failed: no response from server',
|
||||
)
|
||||
error.status = response?.status
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Upsert changed chores first
|
||||
const changedChores = data.changes?.chores ?? []
|
||||
if (changedChores.length > 0) {
|
||||
await offlineDB.saveChores(changedChores)
|
||||
}
|
||||
|
||||
// Upsert changed history entries (also clears any pending entries for the same chore IDs)
|
||||
const changedHistory = data.changes?.choreHistories ?? []
|
||||
if (changedHistory.length > 0) {
|
||||
await offlineDB.saveHistory(changedHistory)
|
||||
}
|
||||
|
||||
// Hard-delete removed IDs after inserts (safe if the same ID somehow appears in both)
|
||||
const deletedIds = data.deletions?.chores ?? []
|
||||
if (deletedIds.length > 0) {
|
||||
await offlineDB.deleteChores(deletedIds)
|
||||
}
|
||||
|
||||
const deletedHistoryIds = data.deletions?.choreHistories ?? []
|
||||
if (deletedHistoryIds.length > 0) {
|
||||
await offlineDB.deleteHistory(deletedHistoryIds)
|
||||
}
|
||||
|
||||
// Always advance the cursor, even when there are no changes
|
||||
if (data.cursor) {
|
||||
currentCursor = data.cursor
|
||||
}
|
||||
|
||||
hasMore = !!data.hasMore
|
||||
}
|
||||
|
||||
await offlineDB.setSyncCursor(currentCursor)
|
||||
await offlineDB.setLastSyncTime(Date.now())
|
||||
}
|
||||
|
||||
// Cache current chores (call after a successful online fetch)
|
||||
async cacheChores(chores) {
|
||||
if (!isOfflineFeatureEnabled()) return
|
||||
if (!chores || chores.length === 0) return
|
||||
await offlineDB.saveChores(chores)
|
||||
}
|
||||
}
|
||||
|
||||
export const syncEngine = new SyncEngine()
|
||||
@@ -1,33 +0,0 @@
|
||||
import { CreateChore, SaveChore } from './Fetcher'
|
||||
import { localStore } from './LocalStore'
|
||||
|
||||
class SyncManager {
|
||||
async syncTasks() {
|
||||
console.log('SYNCMANAGER: Starting sync process for offline tasks.')
|
||||
const offlineTasks = (await localStore.getFromCache('offlineTasks')) || []
|
||||
for (const task of offlineTasks) {
|
||||
// if task.needSync then it's need to be created:
|
||||
var resp
|
||||
if (task.needSync) {
|
||||
resp = await CreateChore(task)
|
||||
} else {
|
||||
resp = await SaveChore(task)
|
||||
}
|
||||
if (!resp.ok) {
|
||||
console.log(
|
||||
`SYNCMANAGER: Failed to sync task with id: ${task.id}. Error: ${resp.statusText}`,
|
||||
)
|
||||
} else {
|
||||
console.log(
|
||||
`SYNCMANAGER: Successfully synced task with id: ${task.id}.`,
|
||||
)
|
||||
console.log(`SYNCMANAGER: Response:`, resp)
|
||||
}
|
||||
}
|
||||
// Clear the offline tasks cache after syncing
|
||||
await localStore.saveToCache('offlineTasks', [])
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export const syncManager = new SyncManager()
|
||||
@@ -163,6 +163,7 @@ export const clearAllTokens = async () => {
|
||||
// Clear localStorage
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN)
|
||||
localStorage.removeItem(TOKEN_KEYS.ACCESS_TOKEN_EXPIRY)
|
||||
localStorage.removeItem(TOKEN_KEYS.REFRESH_TOKEN_EXPIRY)
|
||||
// Clean up legacy keys
|
||||
localStorage.removeItem('ca_token')
|
||||
localStorage.removeItem('ca_expiration')
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -83,7 +83,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)
|
||||
@@ -158,6 +159,7 @@ const ChoreEdit = () => {
|
||||
|
||||
const Navigate = useNavigate()
|
||||
|
||||
const assignees = anyone ? performers : assignableTo
|
||||
const HandleValidateChore = () => {
|
||||
const errors = {}
|
||||
|
||||
@@ -330,12 +332,14 @@ const ChoreEdit = () => {
|
||||
if (searchParams.get('clone') === 'true') {
|
||||
newChoreId = null
|
||||
}
|
||||
const assignees = anyone ? [] : assignableTo
|
||||
const chore = {
|
||||
id: Number(newChoreId),
|
||||
name: name,
|
||||
description: description,
|
||||
assignees: assignees,
|
||||
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
nextDueDate: dueDate ? new Date(dueDate).toISOString() : null,
|
||||
frequencyType: frequencyType,
|
||||
frequency: Number(frequency),
|
||||
frequencyMetadata: frequencyMetadata,
|
||||
@@ -365,11 +369,22 @@ const ChoreEdit = () => {
|
||||
}
|
||||
|
||||
SaveFunction(chore)
|
||||
.then(() => {
|
||||
showSuccess({
|
||||
title: 'Chore Saved',
|
||||
message: 'Your task has been saved successfully!',
|
||||
})
|
||||
.then(result => {
|
||||
if (
|
||||
result?._pendingUpdate ||
|
||||
result?._pendingCreate ||
|
||||
result?.res?._pendingCreate
|
||||
) {
|
||||
showSuccess({
|
||||
title: 'Saved Offline',
|
||||
message: 'Your changes will sync when you are back online.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'Chore Saved',
|
||||
message: 'Your task has been saved successfully!',
|
||||
})
|
||||
}
|
||||
Navigate('/chores')
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -407,15 +422,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(() => {
|
||||
@@ -465,7 +494,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')
|
||||
|
||||
@@ -579,13 +609,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) {
|
||||
@@ -602,7 +634,7 @@ const ChoreEdit = () => {
|
||||
if (attemptToSave) {
|
||||
HandleValidateChore()
|
||||
}
|
||||
}, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
}, [assignableTo, name, frequencyMetadata, attemptToSave, dueDate])
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmModelConfig({
|
||||
@@ -929,9 +961,9 @@ const ChoreEdit = () => {
|
||||
|
||||
<ListItem key={'anyone'}>
|
||||
<Checkbox
|
||||
checked={assignees.length === 0}
|
||||
checked={anyone}
|
||||
onClick={() => {
|
||||
setAssignees([])
|
||||
setAnyone(!anyone)
|
||||
setIsPrivate(false)
|
||||
}}
|
||||
overlay
|
||||
@@ -944,19 +976,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
|
||||
@@ -986,9 +1024,13 @@ const ChoreEdit = () => {
|
||||
},
|
||||
}}
|
||||
onClick={() => {
|
||||
localStorage.setItem(
|
||||
'defaultAnyoneSetting',
|
||||
JSON.stringify(anyone),
|
||||
)
|
||||
localStorage.setItem(
|
||||
'defaultAssigneeSetting',
|
||||
JSON.stringify(assignees),
|
||||
JSON.stringify(assignableTo),
|
||||
)
|
||||
setShowSaveAssigneeDefault(false)
|
||||
}}
|
||||
@@ -1014,17 +1056,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>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,11 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'
|
||||
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useChoreDetails } from '../../queries/ChoreQueries.jsx'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import {
|
||||
useChoreDetails,
|
||||
useChoreHistory,
|
||||
} from '../../queries/ChoreQueries.jsx'
|
||||
import {
|
||||
useChoreTimer,
|
||||
useDeleteTimeSession,
|
||||
@@ -54,8 +58,13 @@ import {
|
||||
} from '../../queries/TimeQueries'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import {
|
||||
ChoreHistoryStatus,
|
||||
ChoreStatus,
|
||||
notInCompletionWindow,
|
||||
} from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
GetChoreDetailById,
|
||||
@@ -66,16 +75,34 @@ import {
|
||||
UndoChoreAction,
|
||||
UpdateChorePriority,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
|
||||
import LoadingComponent from '../components/Loading.jsx'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
import RichTextEditor from '../components/RichTextEditor.jsx'
|
||||
import SubTasks from '../components/SubTask.jsx'
|
||||
import TimePassedCard from './TimePassedCard.jsx'
|
||||
import TimerSplitButton from './TimerSplitButton.jsx'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
const decodeHtmlEntities = value => {
|
||||
if (typeof value !== 'string') return ''
|
||||
|
||||
return value
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('&', '&')
|
||||
}
|
||||
|
||||
const hasHtmlTags = value => /<\/?[a-z][\s\S]*>/i.test(value)
|
||||
|
||||
const ChoreView = () => {
|
||||
const { t } = useTranslation('chores')
|
||||
const { fmt } = useLocalization()
|
||||
@@ -104,6 +131,17 @@ const ChoreView = () => {
|
||||
|
||||
const { data: choreData, isLoading: isChoreLoading } =
|
||||
useChoreDetails(choreId)
|
||||
const { data: choreHistoryData } = useChoreHistory(choreId)
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
const historyCompletionCount = choreHistory.filter(historyEntry => {
|
||||
const status = Number(historyEntry?.status)
|
||||
return status === ChoreHistoryStatus.COMPLETED
|
||||
}).length
|
||||
const completionCount = choreHistoryData
|
||||
? historyCompletionCount
|
||||
: chore.totalCompletedCount || 0
|
||||
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
@@ -120,114 +158,99 @@ 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (chore && performers?.length > 0) {
|
||||
generateInfoCards(chore)
|
||||
const cards = [
|
||||
{
|
||||
size: 6,
|
||||
icon: <PeopleAlt />,
|
||||
title: t('choreView.assignment'),
|
||||
text: `${t('choreView.assigned')}: ${
|
||||
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
|
||||
t('choreView.na')
|
||||
}`,
|
||||
subtext: ` ${t('choreView.last')}: ${
|
||||
chore.lastCompletedDate
|
||||
? performers.find(p => p.userId === chore.lastCompletedBy)
|
||||
?.displayName
|
||||
: 'N/A'
|
||||
}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <CalendarMonth />,
|
||||
title: t('choreView.schedule'),
|
||||
text: `${t('choreView.due')}: ${
|
||||
chore.nextDueDate
|
||||
? moment(chore.nextDueDate).fromNow()
|
||||
: t('choreView.na')
|
||||
}`,
|
||||
subtext: `${t('choreView.last')}: ${
|
||||
chore.lastCompletedDate
|
||||
? moment(chore.lastCompletedDate).fromNow()
|
||||
: t('choreView.na')
|
||||
}`,
|
||||
|
||||
subtext2:
|
||||
chore.deadlineOffset > 0 && chore.nextDueDate
|
||||
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Checklist />,
|
||||
title: t('choreView.statistics'),
|
||||
text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Person />,
|
||||
title: t('choreView.details'),
|
||||
subtext: `${t('choreView.createdBy')}: ${
|
||||
performers.find(p => p.userId === chore.createdBy)?.displayName ||
|
||||
t('choreView.na')
|
||||
}`,
|
||||
},
|
||||
]
|
||||
setInfoCards(cards)
|
||||
}
|
||||
}, [chore, performers])
|
||||
}, [chore, performers, completionCount, t])
|
||||
const handleUpdatePriority = priority => {
|
||||
UpdateChorePriority(choreId, priority.value).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(() => {
|
||||
setChorePriority(priority)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
const generateInfoCards = chore => {
|
||||
const cards = [
|
||||
{
|
||||
size: 6,
|
||||
icon: <PeopleAlt />,
|
||||
title: t('choreView.assignment'),
|
||||
text: `${t('choreView.assigned')}: ${
|
||||
performers.find(p => p.userId === chore.assignedTo)?.displayName ||
|
||||
t('choreView.na')
|
||||
}`,
|
||||
subtext: ` ${t('choreView.last')}: ${
|
||||
chore.lastCompletedDate
|
||||
? performers.find(p => p.userId === chore.lastCompletedBy)
|
||||
?.displayName
|
||||
: 'N/A'
|
||||
}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <CalendarMonth />,
|
||||
title: t('choreView.schedule'),
|
||||
text: `${t('choreView.due')}: ${
|
||||
chore.nextDueDate ? moment(chore.nextDueDate).fromNow() : t('choreView.na')
|
||||
}`,
|
||||
subtext: `${t('choreView.last')}: ${
|
||||
chore.lastCompletedDate
|
||||
? moment(chore.lastCompletedDate).fromNow()
|
||||
: t('choreView.na')
|
||||
}`,
|
||||
|
||||
subtext2:
|
||||
chore.deadlineOffset > 0 && chore.nextDueDate
|
||||
? `Deadline: ${moment(chore.nextDueDate).add(chore.deadlineOffset, 'seconds').fromNow()}`
|
||||
: null,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Checklist />,
|
||||
title: t('choreView.statistics'),
|
||||
text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
|
||||
},
|
||||
{
|
||||
size: 6,
|
||||
icon: <Person />,
|
||||
title: t('choreView.details'),
|
||||
subtext: `${t('choreView.createdBy')}: ${
|
||||
performers.find(p => p.userId === chore.createdBy)?.displayName ||
|
||||
t('choreView.na')
|
||||
}`,
|
||||
},
|
||||
]
|
||||
setInfoCards(cards)
|
||||
}
|
||||
const handleTaskCompletion = () => {
|
||||
MarkChoreComplete(
|
||||
choreId,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId, note }
|
||||
: { note },
|
||||
completedDate,
|
||||
null,
|
||||
)
|
||||
.then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
setNote(null)
|
||||
setChore(data.res)
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
// Invalidate chores cache to refetch data
|
||||
const handleTaskCompletion = async () => {
|
||||
try {
|
||||
const resp = await MarkChoreComplete(
|
||||
choreId,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId, note }
|
||||
: { note },
|
||||
completedDate,
|
||||
null,
|
||||
)
|
||||
if (resp.ok) {
|
||||
const data = await resp.json()
|
||||
setNote(null)
|
||||
setChore(data.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
.then(() => {
|
||||
// refetch the chore details
|
||||
GetChoreDetailById(choreId).then(resp => {
|
||||
if (resp.ok) {
|
||||
return resp.json().then(data => {
|
||||
setChore(data.res)
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(() => {
|
||||
// Show undo notification
|
||||
const detailResp = await GetChoreDetailById(choreId)
|
||||
if (detailResp.ok) {
|
||||
const detailData = await detailResp.json()
|
||||
setChore(detailData.res)
|
||||
}
|
||||
showSuccess({
|
||||
title: t('choreView.taskCompleted'),
|
||||
message: t('choreView.taskCompletedMessage'),
|
||||
@@ -235,7 +258,6 @@ const ChoreView = () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(choreId)
|
||||
if (undoResponse.ok) {
|
||||
// Refetch chore details after undo
|
||||
const detailResponse = await GetChoreDetailById(choreId)
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json()
|
||||
@@ -257,51 +279,108 @@ const ChoreView = () => {
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
const handleSkippingTask = () => {
|
||||
SkipChore(choreId).then(response => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = data.res
|
||||
setChore(newChore)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
|
||||
// Show undo notification
|
||||
showSuccess({
|
||||
message: t('choreView.skipTask'),
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(choreId)
|
||||
if (undoResponse.ok) {
|
||||
// Refetch chore details after undo
|
||||
const detailResponse = await GetChoreDetailById(choreId)
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json()
|
||||
setChore(detailData.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
showUndo({
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: t('choreView.taskSkipUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.COMPLETE_CHORE,
|
||||
choreId,
|
||||
{
|
||||
id: choreId,
|
||||
body: impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId, note }
|
||||
: { note },
|
||||
completedDate: completedDate || null,
|
||||
performer: null,
|
||||
},
|
||||
)
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: Number(choreId),
|
||||
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
|
||||
performedAt: completedDate || new Date().toISOString(),
|
||||
dueDate: chore.nextDueDate || null,
|
||||
notes: note || null,
|
||||
status: 1,
|
||||
points: chore.points || 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to complete task',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
const handleSkippingTask = async () => {
|
||||
try {
|
||||
const response = await SkipChore(choreId)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setChore(data.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
message: t('choreView.skipTask'),
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(choreId)
|
||||
if (undoResponse.ok) {
|
||||
const detailResponse = await GetChoreDetailById(choreId)
|
||||
if (detailResponse.ok) {
|
||||
const detailData = await detailResponse.json()
|
||||
setChore(detailData.res)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
showUndo({
|
||||
title: t('choreView.undoSuccessful'),
|
||||
message: t('choreView.taskSkipUndone'),
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to undo')
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: t('choreView.undoFailedMessage'),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.SKIP_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to skip task',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
const handleChoreStart = () => {
|
||||
const startedChore = { ...chore, status: ChoreStatus.ACTIVE }
|
||||
startChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
@@ -310,10 +389,37 @@ const ChoreView = () => {
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
setChore(startedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
setChore({ ...chore, status: previousStatus })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to start task',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleChorePause = () => {
|
||||
const pausedChore = { ...chore, status: ChoreStatus.PAUSED }
|
||||
pauseChore.mutate(choreId, {
|
||||
onSuccess: data => {
|
||||
const newChore = {
|
||||
@@ -322,6 +428,32 @@ const ChoreView = () => {
|
||||
}
|
||||
setChore(newChore)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
setChore(pausedChore)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
setChore({ ...chore, status: previousStatus })
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: t('choreView.undoFailed'),
|
||||
message: error?.message || 'Unable to pause task',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -383,7 +515,6 @@ const ChoreView = () => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
@@ -395,23 +526,50 @@ const ChoreView = () => {
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore(data.res)
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleUnarchiveChore = () => {
|
||||
UnArchiveChore(choreId).then(response => {
|
||||
const handleUnarchiveChore = async () => {
|
||||
try {
|
||||
const response = await UnArchiveChore(choreId)
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
setChore({ ...chore, isActive: true })
|
||||
// Invalidate chores cache to refetch data
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
await offlineDB.saveChores([{ ...chore, isActive: true }])
|
||||
setChore({ ...chore, isActive: true })
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
} catch (error) {
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.UNARCHIVE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: true, _pending: 'unarchive' },
|
||||
])
|
||||
setChore({ ...chore, isActive: true })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — restore will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await offlineDB.saveChores([{ ...chore, isActive: false }])
|
||||
setChore({ ...chore, isActive: false })
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore task',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the current user can approve/reject (admin, manager, or task owner)
|
||||
@@ -458,16 +616,19 @@ const ChoreView = () => {
|
||||
mb: 1,
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='h3'
|
||||
// textAlign={'center'}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 1,
|
||||
mt: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
<Typography level='h3'>{chore.name}</Typography>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
</Box>
|
||||
{chore.isActive === false && (
|
||||
<Chip
|
||||
startDecorator={<Archive />}
|
||||
@@ -747,7 +908,30 @@ const ChoreView = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor value={chore.description} isEditable={false} />
|
||||
{(() => {
|
||||
const raw = chore.description || ''
|
||||
const shouldRenderHtml = hasHtmlTags(raw)
|
||||
|
||||
return shouldRenderHtml ? (
|
||||
<Box
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{decodeHtmlEntities(raw)}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Sheet>
|
||||
</>
|
||||
@@ -792,7 +976,30 @@ const ChoreView = () => {
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<RichTextEditor value={chore.notes} isEditable={false} />
|
||||
{(() => {
|
||||
const raw = chore.notes || ''
|
||||
const shouldRenderHtml = hasHtmlTags(raw)
|
||||
|
||||
return shouldRenderHtml ? (
|
||||
<Box
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: raw }}
|
||||
/>
|
||||
) : (
|
||||
<Typography
|
||||
level='body-md'
|
||||
sx={{
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{decodeHtmlEntities(raw)}
|
||||
</Typography>
|
||||
)
|
||||
})()}
|
||||
</Box>
|
||||
</Sheet>
|
||||
</>
|
||||
|
||||
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
|
||||
return `Every ${dayNames} at ${timeStr}`
|
||||
}
|
||||
|
||||
const RepeatOnSections = ({
|
||||
export const RepeatOnSections = ({
|
||||
frequencyType,
|
||||
frequency,
|
||||
onFrequencyUpdate,
|
||||
|
||||
@@ -111,6 +111,18 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
|
||||
text: 'Rejected',
|
||||
icon: <ThumbDown />,
|
||||
}
|
||||
} else if (activity.status === 5) {
|
||||
return {
|
||||
color: 'danger',
|
||||
text: 'Missed',
|
||||
icon: <EventNote />,
|
||||
}
|
||||
} else if (activity.status === 6) {
|
||||
return {
|
||||
color: 'neutral',
|
||||
text: 'Rescheduled',
|
||||
icon: <Refresh />,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for completed status
|
||||
|
||||
@@ -13,16 +13,17 @@ import {
|
||||
ViewModule,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
Stack,
|
||||
Typography,
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
Stack,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
@@ -33,8 +34,10 @@ 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'
|
||||
import ChoreCard from './ChoreCard'
|
||||
@@ -42,11 +45,54 @@ import ChoreListView from './ChoreListView.jsx'
|
||||
import CompactChoreCard from './CompactChoreCard'
|
||||
import MultiSelectHelp from './MultiSelectHelp'
|
||||
|
||||
const sortByUpdatedAtDesc = chores =>
|
||||
(chores || []).sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt || 0)
|
||||
const dateB = new Date(b.updatedAt || 0)
|
||||
return dateB - dateA
|
||||
})
|
||||
|
||||
const applyPendingArchivedState = async chores => {
|
||||
const pending = await commandQueue.getPending()
|
||||
|
||||
const pendingArchiveIds = new Set(
|
||||
pending
|
||||
.filter(cmd => cmd.commandType === CommandType.ARCHIVE_CHORE)
|
||||
.map(cmd => String(cmd.entityId)),
|
||||
)
|
||||
const pendingUnarchiveIds = new Set(
|
||||
pending
|
||||
.filter(cmd => cmd.commandType === CommandType.UNARCHIVE_CHORE)
|
||||
.map(cmd => String(cmd.entityId)),
|
||||
)
|
||||
const pendingDeleteIds = new Set(
|
||||
pending
|
||||
.filter(cmd => cmd.commandType === CommandType.DELETE_CHORE)
|
||||
.map(cmd => String(cmd.entityId)),
|
||||
)
|
||||
|
||||
return (chores || [])
|
||||
.filter(chore => !pendingDeleteIds.has(String(chore.id)))
|
||||
.filter(chore => {
|
||||
const id = String(chore.id)
|
||||
if (pendingUnarchiveIds.has(id)) return false
|
||||
return chore.isActive === false || pendingArchiveIds.has(id)
|
||||
})
|
||||
.map(chore => {
|
||||
const id = String(chore.id)
|
||||
if (pendingArchiveIds.has(id)) {
|
||||
return { ...chore, isActive: false, _pending: 'archive' }
|
||||
}
|
||||
return chore
|
||||
})
|
||||
}
|
||||
|
||||
const ArchivedTasks = () => {
|
||||
const { data: userProfile, isLoading: isUserProfileLoading } =
|
||||
useUserProfile()
|
||||
const { showSuccess, showError } = useNotification()
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
const queryClient = useQueryClient()
|
||||
const unArchiveChore = useUnArchiveChore()
|
||||
const [archivedChores, setArchivedChores] = useState([])
|
||||
const [filteredChores, setFilteredChores] = useState([])
|
||||
@@ -163,19 +209,28 @@ const ArchivedTasks = () => {
|
||||
try {
|
||||
const response = await GetArchivedChores()
|
||||
const data = await response.json()
|
||||
// Sort by updatedAt (most recent first)
|
||||
const sortedChores = data.res.sort((a, b) => {
|
||||
const dateA = new Date(a.updatedAt || 0)
|
||||
const dateB = new Date(b.updatedAt || 0)
|
||||
return dateB - dateA
|
||||
})
|
||||
if (data?.res?.length) {
|
||||
await offlineDB.saveChores(data.res)
|
||||
}
|
||||
const archivedWithPending = await applyPendingArchivedState(
|
||||
data?.res || [],
|
||||
)
|
||||
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
|
||||
setArchivedChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to load archived tasks',
|
||||
message: 'Please try again later.',
|
||||
})
|
||||
try {
|
||||
const cached = await offlineDB.getChores(true)
|
||||
const archivedWithPending = await applyPendingArchivedState(cached)
|
||||
const sortedChores = sortByUpdatedAtDesc(archivedWithPending)
|
||||
setArchivedChores(sortedChores)
|
||||
setFilteredChores(sortedChores)
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Failed to load archived tasks',
|
||||
message: 'Please try again later.',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -411,6 +466,10 @@ const ArchivedTasks = () => {
|
||||
const restoredTasks = []
|
||||
const failedTasks = []
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
const queuedTasks = []
|
||||
|
||||
for (const chore of selectedData) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -419,9 +478,18 @@ const ArchivedTasks = () => {
|
||||
restoredTasks.push(chore)
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
await commandQueue.enqueue(CommandType.UNARCHIVE_CHORE, chore.id, { id: chore.id })
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: true, _pending: 'unarchive' },
|
||||
])
|
||||
queuedTasks.push(chore)
|
||||
resolve()
|
||||
} else {
|
||||
failedTasks.push(chore)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -430,22 +498,22 @@ const ArchivedTasks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (restoredTasks.length > 0) {
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`,
|
||||
})
|
||||
|
||||
// Remove restored tasks from archived list
|
||||
const restoredIds = new Set(restoredTasks.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const newFilteredChores = filteredChores.filter(
|
||||
c => !restoredIds.has(c.id),
|
||||
)
|
||||
const allRestored = [...restoredTasks, ...queuedTasks]
|
||||
if (allRestored.length > 0) {
|
||||
const offlineNote = queuedTasks.length > 0 ? " (queued — will sync when back online)" : ''
|
||||
// Remove from archived view optimistically for both online and queued
|
||||
const restoredIds = new Set(allRestored.map(c => c.id))
|
||||
const newArchivedChores = archivedChores.filter(c => !restoredIds.has(c.id))
|
||||
const newFilteredChores = filteredChores.filter(c => !restoredIds.has(c.id))
|
||||
setArchivedChores(newArchivedChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
if (queuedTasks.length > 0) {
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
}
|
||||
showSuccess({
|
||||
title: '📤 Tasks Restored',
|
||||
message: `Restored ${allRestored.length} task${allRestored.length > 1 ? 's' : ''}${offlineNote}.`,
|
||||
})
|
||||
}
|
||||
|
||||
if (failedTasks.length > 0) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Pause,
|
||||
PlayArrow,
|
||||
Repeat,
|
||||
Schedule,
|
||||
ThumbUp,
|
||||
TimesOneMobiledata,
|
||||
Toll,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from '@mui/joy'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import { useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import {
|
||||
getDueDateChipColor,
|
||||
@@ -32,6 +34,7 @@ import { notInCompletionWindow } from '../../utils/Chores.jsx'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
import Priorities from '../../utils/Priorities'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
const ChoreCard = ({
|
||||
chore,
|
||||
performers,
|
||||
@@ -47,6 +50,7 @@ const ChoreCard = ({
|
||||
}) => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
@@ -86,7 +90,11 @@ const ChoreCard = ({
|
||||
return name
|
||||
}
|
||||
return (
|
||||
<Box key={chore.id + '-box'} minWidth={'100%'}>
|
||||
<Box
|
||||
key={chore.id + '-box'}
|
||||
minWidth={'100%'}
|
||||
sx={{ position: 'relative' }}
|
||||
>
|
||||
<Chip
|
||||
variant='soft'
|
||||
sx={{
|
||||
@@ -122,6 +130,9 @@ const ChoreCard = ({
|
||||
</div>
|
||||
</Chip>
|
||||
|
||||
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
|
||||
<PendingBadge commands={pendingCmds} />
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
@@ -342,9 +353,31 @@ const ChoreCard = ({
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{chore.status === 3 && (
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='sm'
|
||||
sx={{
|
||||
mb: 1,
|
||||
px: 0.75,
|
||||
py: 0.5,
|
||||
minHeight: 56,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Schedule sx={{ fontSize: 16 }} />
|
||||
<Typography level='body-xs'>Pending</Typography>
|
||||
</Chip>
|
||||
)}
|
||||
{showActions && (
|
||||
<Box
|
||||
display='flex'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Box, Checkbox, Chip, IconButton, Typography } from '@mui/joy'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { usePendingCommands } from '../../hooks/usePendingCommands'
|
||||
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
|
||||
import {
|
||||
getDueDateChipColor,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
getTextColorFromBackgroundColor,
|
||||
} from '../../utils/Colors.jsx'
|
||||
import ChoreActionMenu from '../components/ChoreActionMenu'
|
||||
import PendingBadge from '../components/PendingBadge'
|
||||
|
||||
const CompactChoreCard = ({
|
||||
chore,
|
||||
@@ -44,6 +46,7 @@ const CompactChoreCard = ({
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const { timeFormat } = useLocalization()
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const { data: pendingCmds } = usePendingCommands(chore.id)
|
||||
|
||||
const { impersonatedUser } = useImpersonateUser()
|
||||
|
||||
@@ -383,7 +386,9 @@ const CompactChoreCard = ({
|
||||
>
|
||||
{chore.name}
|
||||
</Typography>
|
||||
|
||||
{(chore._pending || (pendingCmds && pendingCmds.length > 0)) && (
|
||||
<PendingBadge commands={pendingCmds} size='xs' sx={{ mr: -0.5 }} />
|
||||
)}
|
||||
{/* Due Date - Inline with name */}
|
||||
<Chip
|
||||
variant='soft'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Chip, Menu, MenuItem, Typography } from '@mui/joy'
|
||||
import IconButton from '@mui/joy/IconButton'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
|
||||
|
||||
const IconButtonWithMenu = ({
|
||||
@@ -17,6 +17,7 @@ const IconButtonWithMenu = ({
|
||||
}) => {
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const menuRef = useRef(null)
|
||||
const menuOptions = Array.isArray(options) ? options : []
|
||||
|
||||
const handleMenuOpen = event => {
|
||||
setAnchorEl(event.currentTarget)
|
||||
@@ -85,7 +86,7 @@ const IconButtonWithMenu = ({
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
{options?.map(item => (
|
||||
{menuOptions.map(item => (
|
||||
<MenuItem
|
||||
key={`${k}-${item?.id}`}
|
||||
onClick={() => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PriorityHigh,
|
||||
Style,
|
||||
} from '@mui/icons-material'
|
||||
import Logo from '../../Logo'
|
||||
import {
|
||||
Accordion,
|
||||
AccordionDetails,
|
||||
@@ -352,18 +353,6 @@ const MyChores = () => {
|
||||
// Don't set choreSections here - let the dedicated effect handle it
|
||||
// This prevents caching issues when switching between projects
|
||||
|
||||
if (localStorage.getItem('openChoreSections') === null) {
|
||||
setSelectedChoreSectionWithCache(selectedChoreSection)
|
||||
const openSections = processedSections.reduce(
|
||||
(acc, _section, index) => {
|
||||
acc[index] = true
|
||||
return acc
|
||||
},
|
||||
{},
|
||||
)
|
||||
setOpenChoreSections(openSections)
|
||||
}
|
||||
|
||||
if (await canScheduleNotification()) {
|
||||
console.log('Scheduling chore notifications...')
|
||||
scheduleChoreNotification(
|
||||
@@ -383,10 +372,8 @@ const MyChores = () => {
|
||||
choresData?.res,
|
||||
membersData?.res,
|
||||
processedChores, // Added to ensure local state syncs when query data updates
|
||||
processedSections,
|
||||
userProfile,
|
||||
impersonatedUser?.userId,
|
||||
selectedChoreSection,
|
||||
])
|
||||
|
||||
// Auto-update sections when processedSections changes
|
||||
@@ -1100,20 +1087,19 @@ const MyChores = () => {
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{searchTerm?.length > 0 &&
|
||||
viewMode !== 'calendar' && (
|
||||
<ChoreListView
|
||||
chores={getFilteredChores}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
/>
|
||||
)}
|
||||
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
|
||||
<ChoreListView
|
||||
chores={getFilteredChores}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'calendar' && (
|
||||
<>
|
||||
{/* Summary Chips when no date selected */}
|
||||
@@ -1296,87 +1282,86 @@ const MyChores = () => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{searchTerm.length === 0 &&
|
||||
viewMode !== 'calendar' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
return (
|
||||
<Accordion
|
||||
key={section.name + index}
|
||||
sx={{
|
||||
my: 0,
|
||||
px: 0,
|
||||
}}
|
||||
expanded={Boolean(openChoreSections[index])}
|
||||
>
|
||||
<Divider orientation='horizontal'>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
if (openChoreSections[index]) {
|
||||
const newOpenChoreSections = {
|
||||
...openChoreSections,
|
||||
}
|
||||
delete newOpenChoreSections[index]
|
||||
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
||||
} else {
|
||||
setOpenChoreSectionsWithCache({
|
||||
...openChoreSections,
|
||||
[index]: true,
|
||||
})
|
||||
{searchTerm.length === 0 && viewMode !== 'calendar' && (
|
||||
<AccordionGroup transition='0.2s ease' disableDivider>
|
||||
{choreSections.map((section, index) => {
|
||||
if (section.content.length === 0) return null
|
||||
return (
|
||||
<Accordion
|
||||
key={section.name + index}
|
||||
sx={{
|
||||
my: 0,
|
||||
px: 0,
|
||||
}}
|
||||
expanded={Boolean(openChoreSections[index])}
|
||||
>
|
||||
<Divider orientation='horizontal'>
|
||||
<Chip
|
||||
variant='soft'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
if (openChoreSections[index]) {
|
||||
const newOpenChoreSections = {
|
||||
...openChoreSections,
|
||||
}
|
||||
}}
|
||||
endDecorator={
|
||||
openChoreSections[index] ? (
|
||||
<ExpandCircleDown
|
||||
color='primary'
|
||||
sx={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
) : (
|
||||
<ExpandCircleDown color='primary' />
|
||||
)
|
||||
delete newOpenChoreSections[index]
|
||||
setOpenChoreSectionsWithCache(newOpenChoreSections)
|
||||
} else {
|
||||
setOpenChoreSectionsWithCache({
|
||||
...openChoreSections,
|
||||
[index]: true,
|
||||
})
|
||||
}
|
||||
startDecorator={
|
||||
<>
|
||||
<Chip color='primary' size='sm' variant='soft'>
|
||||
{section?.content?.length}
|
||||
</Chip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{section.name}
|
||||
</Chip>
|
||||
</Divider>
|
||||
<AccordionDetails
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
['& > *']: {
|
||||
// px: 0.5,
|
||||
px: 0.5,
|
||||
// pr: 0,
|
||||
},
|
||||
}}
|
||||
endDecorator={
|
||||
openChoreSections[index] ? (
|
||||
<ExpandCircleDown
|
||||
color='primary'
|
||||
sx={{ transform: 'rotate(180deg)' }}
|
||||
/>
|
||||
) : (
|
||||
<ExpandCircleDown color='primary' />
|
||||
)
|
||||
}
|
||||
startDecorator={
|
||||
<>
|
||||
<Chip color='primary' size='sm' variant='soft'>
|
||||
{section?.content?.length}
|
||||
</Chip>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</AccordionGroup>
|
||||
)}
|
||||
{section.name}
|
||||
</Chip>
|
||||
</Divider>
|
||||
<AccordionDetails
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
['& > *']: {
|
||||
// px: 0.5,
|
||||
px: 0.5,
|
||||
// pr: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ChoreListView
|
||||
chores={section.content}
|
||||
viewMode={viewMode}
|
||||
membersData={membersData}
|
||||
userLabels={userLabels}
|
||||
handleLabelFiltering={handleLabelFiltering}
|
||||
handleChoreAction={handleChoreAction}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedChores={selectedChores}
|
||||
toggleChoreSelection={toggleChoreSelection}
|
||||
/>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
)
|
||||
})}
|
||||
</AccordionGroup>
|
||||
)}
|
||||
<Box
|
||||
sx={{
|
||||
// center the button
|
||||
@@ -1424,6 +1409,7 @@ const MyChores = () => {
|
||||
/>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
data-testid='open-add-task-modal'
|
||||
color='primary'
|
||||
variant='soft'
|
||||
sx={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { useArchiveChore } from '../../../queries/ChoreQueries'
|
||||
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
||||
import {
|
||||
ApproveChore,
|
||||
DeleteChore,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
useArchiveChore,
|
||||
useUnArchiveChore,
|
||||
} from '../../../queries/ChoreQueries'
|
||||
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
|
||||
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
|
||||
import {
|
||||
ApproveChore,
|
||||
DeleteChore,
|
||||
MarkChoreComplete,
|
||||
NudgeChore,
|
||||
RejectChore,
|
||||
SkipChore,
|
||||
UndoChoreAction,
|
||||
UpdateChoreAssignee,
|
||||
UpdateDueDate,
|
||||
} from '../../../utils/Fetcher'
|
||||
import { offlineDB } from '../../../utils/OfflineDB'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
export const useChoreActions = ({
|
||||
chores,
|
||||
@@ -35,11 +43,12 @@ export const useChoreActions = ({
|
||||
}) => {
|
||||
const queryClient = useQueryClient()
|
||||
const archiveChore = useArchiveChore()
|
||||
const unarchiveChore = useUnArchiveChore()
|
||||
const startChore = useStartChore()
|
||||
const pauseChore = usePauseChore()
|
||||
|
||||
const updateChoreInState = useCallback(
|
||||
(updatedChore, event) => {
|
||||
(updatedChore, event, { skipInvalidation = false } = {}) => {
|
||||
let newChores = chores.map(c =>
|
||||
c.id === updatedChore.id ? updatedChore : c,
|
||||
)
|
||||
@@ -61,7 +70,9 @@ export const useChoreActions = ({
|
||||
setChores(newChores)
|
||||
setFilteredChores(newFilteredChores)
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
if (!skipInvalidation) {
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
}
|
||||
|
||||
const undoableActions = {
|
||||
completed: 'Task completed',
|
||||
@@ -77,7 +88,7 @@ export const useChoreActions = ({
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(updatedChore.id)
|
||||
if (undoResponse.ok) {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
const undoMessages = {
|
||||
completed: 'Task completion has been undone.',
|
||||
approved: 'Task approval has been undone.',
|
||||
@@ -121,7 +132,8 @@ export const useChoreActions = ({
|
||||
archive: {
|
||||
type: 'success',
|
||||
title: 'Task Archived',
|
||||
message: 'The task has been archived and hidden from the active list.',
|
||||
message:
|
||||
'The task has been archived and hidden from the active list.',
|
||||
},
|
||||
started: {
|
||||
type: 'success',
|
||||
@@ -147,47 +159,56 @@ export const useChoreActions = ({
|
||||
notifyFn({ title: notification.title, message: notification.message })
|
||||
}
|
||||
},
|
||||
[chores, filteredChores, setChores, setFilteredChores, queryClient, showSuccess, showError, showWarning, showUndo, refetchChores],
|
||||
[
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
queryClient,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showUndo,
|
||||
],
|
||||
)
|
||||
|
||||
const handleChoreAction = useCallback(
|
||||
async (action, chore, extraData = {}) => {
|
||||
switch (action) {
|
||||
case 'complete':
|
||||
// 1. Instantly hide the chore from the UI and Cache
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
|
||||
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
|
||||
if (!oldData || !oldData.res) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(c => c.id !== chore.id),
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await MarkChoreComplete(
|
||||
chore.id,
|
||||
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
|
||||
impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
if (response.ok) {
|
||||
// 2. Show the success notification with Undo
|
||||
// Online: hide the chore and show undo
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
|
||||
if (!oldData || !oldData.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.filter(c => c.id !== chore.id),
|
||||
}
|
||||
})
|
||||
showSuccess({
|
||||
message: 'Task completed',
|
||||
undoAction: async () => {
|
||||
try {
|
||||
const undoResponse = await UndoChoreAction(chore.id)
|
||||
if (undoResponse.ok) {
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: 'Task completion has been undone.',
|
||||
})
|
||||
} else throw new Error('Failed to undo')
|
||||
} catch (error) {
|
||||
} catch {
|
||||
showError({
|
||||
title: 'Undo Failed',
|
||||
message: 'Unable to undo the action. Please try again.',
|
||||
@@ -195,60 +216,162 @@ export const useChoreActions = ({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Fetch the fresh active list from the server silently
|
||||
// (This brings in the next occurrence if recurring, without showing the completed one)
|
||||
queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
} else {
|
||||
refetchChores() // Network failed, revert to truth
|
||||
refetchChores()
|
||||
}
|
||||
} catch (error) {
|
||||
refetchChores() // Network failed, revert to truth
|
||||
if (error?.queued) {
|
||||
showError({
|
||||
title: 'Update Failed',
|
||||
message: 'Request will be reattempt when you are online',
|
||||
if (isNetworkError(error)) {
|
||||
// Offline — queue and show pending badge on the chore (don't hide it)
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.COMPLETE_CHORE,
|
||||
chore.id,
|
||||
{
|
||||
id: chore.id,
|
||||
body: impersonatedUser
|
||||
? { completedBy: impersonatedUser.userId }
|
||||
: null,
|
||||
completedDate: null,
|
||||
performer: null,
|
||||
},
|
||||
)
|
||||
await offlineDB.savePendingHistory({
|
||||
id: -Date.now(),
|
||||
choreId: chore.id,
|
||||
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
|
||||
performedAt: new Date().toISOString(),
|
||||
dueDate: chore.nextDueDate || null,
|
||||
notes: null,
|
||||
status: 1,
|
||||
points: chore.points || 0,
|
||||
pending: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
title: 'Task completion pending',
|
||||
message:
|
||||
"You're offline — completion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to update',
|
||||
message: error,
|
||||
title: 'Failed to complete',
|
||||
message: error?.message || 'Unable to complete chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'start':
|
||||
startChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'started')
|
||||
},
|
||||
onError: error => {
|
||||
case 'start': {
|
||||
const startedChore = { ...chore, status: 1 }
|
||||
try {
|
||||
await startChore.mutateAsync(chore.id)
|
||||
queryClient.cancelQueries(['chores'])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(c =>
|
||||
c.id === chore.id ? startedChore : c,
|
||||
),
|
||||
}
|
||||
})
|
||||
updateChoreInState(startedChore, 'started', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
updateChoreInState(startedChore, 'started', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
updateChoreInState(
|
||||
{ ...chore, status: previousStatus },
|
||||
previousStatus === 2 ? 'paused' : 'started',
|
||||
{ skipInvalidation: true },
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to start',
|
||||
message: error.message || 'Unable to start chore',
|
||||
message: error?.message || 'Unable to start chore',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
pauseChore.mutate(chore.id, {
|
||||
onSuccess: async res => {
|
||||
const data = await res.json()
|
||||
const newChore = { ...chore, status: data.res.status }
|
||||
updateChoreInState(newChore, 'paused')
|
||||
},
|
||||
onError: error => {
|
||||
case 'pause': {
|
||||
const pausedChore = { ...chore, status: 2 }
|
||||
try {
|
||||
await pauseChore.mutateAsync(chore.id)
|
||||
queryClient.cancelQueries(['chores'])
|
||||
queryClient.setQueryData(['chores', false], oldData => {
|
||||
if (!oldData?.res) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
res: oldData.res.map(c =>
|
||||
c.id === chore.id ? pausedChore : c,
|
||||
),
|
||||
}
|
||||
})
|
||||
updateChoreInState(pausedChore, 'paused', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const previousStatus = chore.status
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
updateChoreInState(pausedChore, 'paused', {
|
||||
skipInvalidation: true,
|
||||
})
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
updateChoreInState(
|
||||
{ ...chore, status: previousStatus },
|
||||
previousStatus === 2 ? 'paused' : 'started',
|
||||
{ skipInvalidation: true },
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to pause',
|
||||
message: error.message || 'Unable to pause chore',
|
||||
message: error?.message || 'Unable to pause chore',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'approve':
|
||||
try {
|
||||
@@ -297,18 +420,45 @@ 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.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error,
|
||||
})
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.DELETE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — deletion will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
setChores(prev => [...prev, chore])
|
||||
setFilteredChores(prev => [...prev, chore])
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to delete',
|
||||
message: error?.message || 'Unable to delete chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
setConfirmModelConfig({})
|
||||
@@ -321,34 +471,137 @@ export const useChoreActions = ({
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState(data, 'archive')
|
||||
updateChoreInState(chore, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: error => {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.ARCHIVE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: false, _pending: 'archive' },
|
||||
])
|
||||
setChores(prev => prev.filter(c => c.id !== chore.id))
|
||||
setFilteredChores(prev =>
|
||||
prev.filter(c => c.id !== chore.id),
|
||||
)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — archive will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: true },
|
||||
])
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
setChores(prev => [...prev, chore])
|
||||
setFilteredChores(prev => [...prev, chore])
|
||||
},
|
||||
})
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to archive',
|
||||
message: error.message || 'Unable to archive chore',
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {}
|
||||
break
|
||||
|
||||
case 'unarchive':
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
unarchiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState({ ...chore, isActive: true }, 'unarchive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.UNARCHIVE_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: true, _pending: 'unarchive' },
|
||||
])
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — restore will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await offlineDB.saveChores([
|
||||
{ ...chore, isActive: false },
|
||||
])
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
resolve()
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to restore',
|
||||
message: error.message || 'Unable to restore chore',
|
||||
})
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {}
|
||||
break
|
||||
|
||||
case 'skip':
|
||||
try {
|
||||
const response = await SkipChore(chore.id)
|
||||
if (response.ok) {
|
||||
// Online: update in place (chore gets new due date)
|
||||
const data = await response.json()
|
||||
updateChoreInState(data.res, 'skipped')
|
||||
} else {
|
||||
refetchChores()
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
message: error,
|
||||
})
|
||||
if (isNetworkError(error)) {
|
||||
// Offline — queue and show pending badge on the chore
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.SKIP_CHORE,
|
||||
chore.id,
|
||||
{ id: chore.id },
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — skip will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to skip',
|
||||
message: error?.message || 'Unable to skip chore',
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
@@ -363,13 +616,48 @@ export const useChoreActions = ({
|
||||
updateChoreInState(chore, eventType)
|
||||
}
|
||||
} catch (error) {
|
||||
showError({
|
||||
title:
|
||||
extraData.date === null
|
||||
? 'Failed to remove due date'
|
||||
: 'Failed to reschedule',
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
if (isNetworkError(error)) {
|
||||
const oldDueDate = chore.nextDueDate
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.RESCHEDULE_CHORE,
|
||||
chore.id,
|
||||
{
|
||||
id: chore.id,
|
||||
dueDate: extraData.date,
|
||||
},
|
||||
)
|
||||
const eventType =
|
||||
extraData.date === null ? 'due-date-removed' : 'rescheduled'
|
||||
updateChoreInState(
|
||||
{ ...chore, nextDueDate: extraData.date },
|
||||
eventType,
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message:
|
||||
"You're offline — reschedule will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['pendingCommands'],
|
||||
})
|
||||
const undoEventType =
|
||||
oldDueDate === null ? 'due-date-removed' : 'rescheduled'
|
||||
updateChoreInState(
|
||||
{ ...chore, nextDueDate: oldDueDate },
|
||||
undoEventType,
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title:
|
||||
extraData.date === null
|
||||
? 'Failed to remove due date'
|
||||
: 'Failed to reschedule',
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
openModal(action, chore, extraData)
|
||||
@@ -400,26 +688,67 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig,
|
||||
openModal,
|
||||
archiveChore,
|
||||
unarchiveChore,
|
||||
startChore,
|
||||
pauseChore,
|
||||
],
|
||||
)
|
||||
|
||||
const handleChangeDueDate = useCallback(
|
||||
newDate => {
|
||||
async newDate => {
|
||||
if (!modalChore) return
|
||||
UpdateDueDate(modalChore.id, newDate).then(response => {
|
||||
closeModal()
|
||||
try {
|
||||
const response = await UpdateDueDate(modalChore.id, newDate)
|
||||
if (response.ok) {
|
||||
response.json().then(data => {
|
||||
const newChore = modalChore
|
||||
newChore.nextDueDate = newDate
|
||||
updateChoreInState(newChore, 'rescheduled')
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: newDate },
|
||||
'rescheduled',
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNetworkError(error)) {
|
||||
const oldDueDate = modalChore.nextDueDate
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.RESCHEDULE_CHORE,
|
||||
modalChore.id,
|
||||
{
|
||||
id: modalChore.id,
|
||||
dueDate: newDate,
|
||||
},
|
||||
)
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: newDate },
|
||||
'rescheduled',
|
||||
)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
showSuccess({
|
||||
message: "You're offline — reschedule will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
updateChoreInState(
|
||||
{ ...modalChore, nextDueDate: oldDueDate },
|
||||
'rescheduled',
|
||||
)
|
||||
},
|
||||
})
|
||||
} else {
|
||||
showError({
|
||||
title: 'Failed to reschedule',
|
||||
message: error.message || 'Unable to update due date',
|
||||
})
|
||||
}
|
||||
})
|
||||
closeModal()
|
||||
}
|
||||
},
|
||||
[modalChore, updateChoreInState, closeModal],
|
||||
[
|
||||
modalChore,
|
||||
updateChoreInState,
|
||||
closeModal,
|
||||
showSuccess,
|
||||
showError,
|
||||
queryClient,
|
||||
],
|
||||
)
|
||||
|
||||
const handleCompleteWithPastDate = useCallback(
|
||||
@@ -568,7 +897,15 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
impersonatedUser,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -603,8 +940,7 @@ export const useChoreActions = ({
|
||||
},
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
if (archivedTasks.length > 0) {
|
||||
showSuccess({
|
||||
@@ -630,7 +966,17 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
archiveChore,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkDelete = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -690,7 +1036,18 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
chores,
|
||||
filteredChores,
|
||||
setChores,
|
||||
setFilteredChores,
|
||||
showSuccess,
|
||||
showError,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
const handleBulkSkip = useCallback(async () => {
|
||||
const selectedData = getSelectedChoresData(chores)
|
||||
@@ -726,7 +1083,7 @@ export const useChoreActions = ({
|
||||
for (const chore of skippedTasks) {
|
||||
await UndoChoreAction(chore.id)
|
||||
}
|
||||
refetchChores()
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showUndo({
|
||||
title: 'Undo Successful',
|
||||
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
|
||||
@@ -760,7 +1117,15 @@ export const useChoreActions = ({
|
||||
setConfirmModelConfig({})
|
||||
},
|
||||
})
|
||||
}, [getSelectedChoresData, showSuccess, showError, showUndo, refetchChores, clearSelection, setConfirmModelConfig])
|
||||
}, [
|
||||
getSelectedChoresData,
|
||||
showSuccess,
|
||||
showError,
|
||||
showUndo,
|
||||
refetchChores,
|
||||
clearSelection,
|
||||
setConfirmModelConfig,
|
||||
])
|
||||
|
||||
return {
|
||||
handleChoreAction,
|
||||
|
||||
@@ -36,6 +36,7 @@ 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,
|
||||
useDeleteChoreHistory,
|
||||
@@ -68,9 +69,27 @@ const ChoreHistory = () => {
|
||||
const { data: circleMembersData } = useCircleMembers()
|
||||
const updateChoreHistory = useUpdateChoreHistory()
|
||||
const deleteChoreHistory = useDeleteChoreHistory()
|
||||
const { data: pendingCmds } = usePendingCommands(choreId)
|
||||
|
||||
const choreHistory = choreHistoryData?.res || []
|
||||
const performers = circleMembersData?.res || []
|
||||
const pendingByHistoryId = useMemo(() => {
|
||||
if (!pendingCmds?.length) return {}
|
||||
return pendingCmds.reduce((acc, cmd) => {
|
||||
if (
|
||||
cmd.commandType !== 'update_chore_history' &&
|
||||
cmd.commandType !== 'delete_chore_history'
|
||||
) {
|
||||
return acc
|
||||
}
|
||||
const historyId =
|
||||
cmd?.payload?.historyId ?? Number(String(cmd.entityId).split(':')[1])
|
||||
if (!historyId) return acc
|
||||
if (!acc[historyId]) acc[historyId] = []
|
||||
acc[historyId].push(cmd)
|
||||
return acc
|
||||
}, {})
|
||||
}, [pendingCmds])
|
||||
|
||||
const filterDefs = useMemo(
|
||||
() => [
|
||||
@@ -487,6 +506,7 @@ const ChoreHistory = () => {
|
||||
},
|
||||
})
|
||||
}}
|
||||
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
|
||||
onViewNote={notes => {
|
||||
setNoteViewerConfig({
|
||||
isOpen: true,
|
||||
@@ -529,13 +549,21 @@ const ChoreHistory = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: data => {
|
||||
setIsEditModalOpen(false)
|
||||
setEditHistory(null)
|
||||
showSuccess({
|
||||
title: 'History Updated',
|
||||
message: `The history record has been updated successfully.`,
|
||||
})
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Update Queued',
|
||||
message:
|
||||
'You are offline. The history update will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Updated',
|
||||
message: `The history record has been updated successfully.`,
|
||||
})
|
||||
}
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Failed to update chore history:', error)
|
||||
@@ -551,13 +579,21 @@ const ChoreHistory = () => {
|
||||
historyId: editHistory.id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
onSuccess: data => {
|
||||
setIsEditModalOpen(false)
|
||||
setEditHistory(null)
|
||||
showSuccess({
|
||||
title: 'History Deleted',
|
||||
message: `The history record has been deleted successfully.`,
|
||||
})
|
||||
if (data?.queued) {
|
||||
showSuccess({
|
||||
title: 'History Delete Queued',
|
||||
message:
|
||||
'You are offline. The history delete will sync when connection is restored.',
|
||||
})
|
||||
} else {
|
||||
showSuccess({
|
||||
title: 'History Deleted',
|
||||
message: `The history record has been deleted successfully.`,
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -13,7 +13,16 @@ 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
|
||||
}
|
||||
|
||||
const formatTime = seconds => {
|
||||
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
|
||||
@@ -48,6 +57,7 @@ const HistoryCard = ({
|
||||
performers,
|
||||
historyEntry,
|
||||
index,
|
||||
pendingCommands,
|
||||
onToggleActions,
|
||||
onViewNote,
|
||||
onViewDetails,
|
||||
@@ -186,6 +196,18 @@ const HistoryCard = ({
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{pendingCommands?.length > 0 && (
|
||||
<PendingBadge
|
||||
commands={pendingCommands}
|
||||
size='s'
|
||||
sx={{
|
||||
mr: 0.5,
|
||||
position: 'absolute',
|
||||
right: -3,
|
||||
top: -3,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { CreateLabel, GetLabels } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
|
||||
export const useLabels = () => {
|
||||
return useQuery({
|
||||
queryKey: ['labels'],
|
||||
queryFn: GetLabels,
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const data = await GetLabels()
|
||||
const labels = Array.isArray(data?.res)
|
||||
? data.res
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: []
|
||||
|
||||
if (labels.length > 0) {
|
||||
offlineDB.saveKV('labels', labels)
|
||||
}
|
||||
return labels
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('labels')
|
||||
if (Array.isArray(cached)) return cached
|
||||
return []
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
|
||||
return (
|
||||
<ResponsiveModal
|
||||
open={config?.isOpen}
|
||||
onClose={config?.onClose}
|
||||
onClose={() => handleAction(false)}
|
||||
size='sm'
|
||||
unmountDelay={250}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
CreateProject,
|
||||
DeleteProject,
|
||||
GetProjects,
|
||||
UpdateProject,
|
||||
} from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
|
||||
// Query hook for fetching all projects
|
||||
export const useProjects = () => {
|
||||
@@ -10,22 +16,15 @@ export const useProjects = () => {
|
||||
const response = await GetProjects()
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
return data.res || data
|
||||
const projects = data.res || data
|
||||
offlineDB.saveKV('projects', projects)
|
||||
return projects
|
||||
}
|
||||
throw new Error('Failed to fetch projects')
|
||||
} catch (error) {
|
||||
console.error('Error fetching projects:', error)
|
||||
// Return default project if API fails
|
||||
return [
|
||||
{
|
||||
id: 'default',
|
||||
name: 'Default Project',
|
||||
description: 'Your default project workspace',
|
||||
color: '#1976d2',
|
||||
created_by: 'system',
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
]
|
||||
} catch {
|
||||
const cached = await offlineDB.getKV('projects')
|
||||
if (cached) return cached
|
||||
return []
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
@@ -39,7 +38,7 @@ export const useCreateProject = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (projectData) => {
|
||||
mutationFn: async projectData => {
|
||||
try {
|
||||
const response = await CreateProject(projectData)
|
||||
if (response.ok) {
|
||||
@@ -59,7 +58,7 @@ export const useCreateProject = () => {
|
||||
return localProject
|
||||
}
|
||||
},
|
||||
onSuccess: (newProject) => {
|
||||
onSuccess: newProject => {
|
||||
// Update the projects cache
|
||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||
const updatedProjects = [...oldProjects, newProject]
|
||||
@@ -69,7 +68,7 @@ export const useCreateProject = () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Create project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
@@ -98,18 +97,18 @@ export const useUpdateProject = () => {
|
||||
}
|
||||
}
|
||||
},
|
||||
onSuccess: (updatedProject) => {
|
||||
onSuccess: updatedProject => {
|
||||
// Update the projects cache
|
||||
queryClient.setQueryData(['projects'], (oldProjects = []) => {
|
||||
return oldProjects.map(project =>
|
||||
project.id === updatedProject.id ? updatedProject : project
|
||||
project.id === updatedProject.id ? updatedProject : project,
|
||||
)
|
||||
})
|
||||
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Update project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
@@ -120,7 +119,7 @@ export const useDeleteProject = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (projectId) => {
|
||||
mutationFn: async projectId => {
|
||||
try {
|
||||
// Prevent deletion of default project
|
||||
if (projectId === 'default') {
|
||||
@@ -147,14 +146,14 @@ export const useDeleteProject = () => {
|
||||
// Invalidate and refetch
|
||||
queryClient.invalidateQueries(['projects'])
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: error => {
|
||||
console.error('Delete project mutation failed:', error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Hook to get a specific project by ID
|
||||
export const useProject = (projectId) => {
|
||||
export const useProject = projectId => {
|
||||
return useQuery({
|
||||
queryKey: ['projects', projectId],
|
||||
queryFn: async () => {
|
||||
@@ -185,4 +184,4 @@ export const useProject = (projectId) => {
|
||||
staleTime: 5 * 60 * 1000,
|
||||
cacheTime: 10 * 60 * 1000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Chip,
|
||||
FormControl,
|
||||
@@ -9,22 +8,43 @@ import {
|
||||
Input,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import RealTimeSettings from '../../components/RealTimeSettings'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { GetUserCircle, PutWebhookURL } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import {
|
||||
clearBrowserCacheStorage,
|
||||
isOfflineFeatureEnabled,
|
||||
setOfflineFeatureEnabled,
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
import SettingsLayout from './SettingsLayout'
|
||||
|
||||
const AdvancedSettings = () => {
|
||||
const { data: userProfile } = useUserProfile()
|
||||
const queryClient = useQueryClient()
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const [userCircles, setUserCircles] = useState([])
|
||||
const [webhookURL, setWebhookURL] = useState(null)
|
||||
const [webhookError, setWebhookError] = useState(null)
|
||||
const [isAdmin, setIsAdmin] = useState(false)
|
||||
const [offlineEnabled, setOfflineEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
const [offlineLoading, setOfflineLoading] = useState(false)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToOfflineFeature(setOfflineEnabled)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
GetUserCircle().then(resp => {
|
||||
@@ -42,21 +62,120 @@ const AdvancedSettings = () => {
|
||||
}
|
||||
}, [userCircles])
|
||||
|
||||
if (!userProfile) {
|
||||
return (
|
||||
<SettingsLayout title="Advanced Settings">
|
||||
<div>Loading...</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
const disableOfflineSupport = async () => {
|
||||
setOfflineLoading(true)
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
await clearBrowserCacheStorage()
|
||||
setOfflineFeatureEnabled(false)
|
||||
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
|
||||
queryClient.removeQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline mode turned off and local data was cleared',
|
||||
})
|
||||
} catch {
|
||||
setOfflineFeatureEnabled(false)
|
||||
queryClient.removeQueries({ queryKey: ['pendingCommands'] })
|
||||
queryClient.removeQueries({ queryKey: ['chores'] })
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Offline mode was turned off, but some local data may still be stored',
|
||||
})
|
||||
} finally {
|
||||
setOfflineLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showDisableOfflineConfirmation = () => {
|
||||
setConfirmModalConfig({
|
||||
isOpen: true,
|
||||
title: 'Turn Off Offline Mode',
|
||||
message:
|
||||
'Turning off offline mode will remove unsynced offline changes and saved offline data on this device/browser. Do you want to continue?',
|
||||
confirmText: 'Turn Off & Clear Data',
|
||||
cancelText: 'Cancel',
|
||||
color: 'danger',
|
||||
onClose: isConfirmed => {
|
||||
setConfirmModalConfig({})
|
||||
if (isConfirmed) {
|
||||
disableOfflineSupport()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleOfflineToggle = async event => {
|
||||
const nextEnabled = !!event.target.checked
|
||||
|
||||
if (nextEnabled) {
|
||||
setOfflineFeatureEnabled(true)
|
||||
await syncEngine.sync()
|
||||
queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Offline mode turned on for this device/browser',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showDisableOfflineConfirmation()
|
||||
}
|
||||
|
||||
// if (!userProfile) {
|
||||
// return (
|
||||
// <SettingsLayout title="Advanced Settings">
|
||||
// <div>Loading...</div>
|
||||
// </SettingsLayout>
|
||||
// )
|
||||
// }
|
||||
|
||||
return (
|
||||
<SettingsLayout title="Advanced Settings">
|
||||
<SettingsLayout title='Advanced Settings'>
|
||||
<div className='grid gap-4'>
|
||||
<Typography level='body-md'>
|
||||
Configure advanced features like webhooks and real-time updates for enhanced productivity.
|
||||
Configure advanced features like webhooks and real-time updates for
|
||||
enhanced productivity.
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 2 }}>
|
||||
<Typography level='title-lg'>Offline Support</Typography>
|
||||
<Chip
|
||||
variant='outlined'
|
||||
size='sm'
|
||||
sx={{
|
||||
height: '20px',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'warning.main',
|
||||
borderColor: 'warning.main',
|
||||
}}
|
||||
>
|
||||
Early Access
|
||||
</Chip>
|
||||
</Box>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Keep using Donetick when you're offline on this device/browser. Your
|
||||
changes are saved locally and synced when you're back online.
|
||||
</Typography>
|
||||
<FormControl sx={{ mt: 1 }}>
|
||||
<Checkbox
|
||||
checked={offlineEnabled}
|
||||
onChange={handleOfflineToggle}
|
||||
variant='soft'
|
||||
label='Enable Offline Support'
|
||||
disabled={offlineLoading}
|
||||
overlay
|
||||
/>
|
||||
<FormHelperText>
|
||||
Turning this off removes unsynced offline changes and saved offline
|
||||
data from this device/browser.
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
{/* Webhook Settings - Only show for admins */}
|
||||
{isAdmin && (
|
||||
<>
|
||||
@@ -152,12 +271,17 @@ const AdvancedSettings = () => {
|
||||
Real-time Updates
|
||||
</Typography>
|
||||
<Typography level='body-md' mt={-1}>
|
||||
Configure how you receive live updates when tasks and activities change in your circle.
|
||||
Configure how you receive live updates when tasks and activities
|
||||
change in your circle.
|
||||
</Typography>
|
||||
<RealTimeSettings />
|
||||
|
||||
{confirmModalConfig?.isOpen && (
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
)}
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdvancedSettings
|
||||
export default AdvancedSettings
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { Refresh, Token } from '@mui/icons-material'
|
||||
import { Box, Button, Card, Chip, Divider, Typography } from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LocalNotifications } from '@capacitor/local-notifications'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import useConfirmationModal from '../../hooks/useConfirmationModal'
|
||||
import { useSSEContext } from '../../hooks/useSSEContext'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import { RefreshToken } from '../../utils/Fetcher'
|
||||
import { offlineDB } from '../../utils/OfflineDB'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
import { getRefreshTokenExpiry, isNative } from '../../utils/TokenStorage'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
|
||||
const DeveloperSettings = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const { confirmModalConfig, showConfirmation } = useConfirmationModal()
|
||||
const {
|
||||
isConnected,
|
||||
isConnecting,
|
||||
@@ -31,9 +40,48 @@ const DeveloperSettings = () => {
|
||||
const [isRefreshingDirect, setIsRefreshingDirect] = useState(false)
|
||||
const [scheduledNotifications, setScheduledNotifications] = useState([])
|
||||
const [isLoadingNotifications, setIsLoadingNotifications] = useState(false)
|
||||
const [isResettingSync, setIsResettingSync] = useState(false)
|
||||
const [syncDiagnostics, setSyncDiagnostics] = useState({
|
||||
cursor: null,
|
||||
lastSync: null,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
syncing: false,
|
||||
syncError: null,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
})
|
||||
|
||||
const { showNotification } = useNotification()
|
||||
|
||||
const refreshSyncDiagnostics = useCallback(async () => {
|
||||
try {
|
||||
const [cursor, lastSync, pendingCommands, failedCommands] =
|
||||
await Promise.all([
|
||||
offlineDB.getSyncCursor(),
|
||||
offlineDB.getLastSyncTime(),
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
cursor,
|
||||
lastSync,
|
||||
pendingCount: pendingCommands.length,
|
||||
failedCount: failedCommands.length,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to load sync diagnostics:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setIsNativePlatform(isNative())
|
||||
|
||||
@@ -54,12 +102,8 @@ const DeveloperSettings = () => {
|
||||
const pending = await LocalNotifications.getPending()
|
||||
// Sort by schedule time (earliest first)
|
||||
const sorted = pending.notifications.sort((a, b) => {
|
||||
const timeA = a.schedule?.at
|
||||
? new Date(a.schedule.at).getTime()
|
||||
: 0
|
||||
const timeB = b.schedule?.at
|
||||
? new Date(b.schedule.at).getTime()
|
||||
: 0
|
||||
const timeA = a.schedule?.at ? new Date(a.schedule.at).getTime() : 0
|
||||
const timeB = b.schedule?.at ? new Date(b.schedule.at).getTime() : 0
|
||||
return timeA - timeB
|
||||
})
|
||||
setScheduledNotifications(sorted)
|
||||
@@ -73,7 +117,41 @@ const DeveloperSettings = () => {
|
||||
|
||||
loadTokenData()
|
||||
loadScheduledNotifications()
|
||||
}, [])
|
||||
refreshSyncDiagnostics()
|
||||
}, [refreshSyncDiagnostics])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribeSync = syncEngine.onSyncStateChange(state => {
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
syncing:
|
||||
typeof state.syncing === 'boolean' ? state.syncing : prev.syncing,
|
||||
syncError: Object.prototype.hasOwnProperty.call(state, 'error')
|
||||
? state.error
|
||||
: prev.syncError,
|
||||
lastSync: state.lastSync ?? prev.lastSync,
|
||||
}))
|
||||
})
|
||||
|
||||
networkManager.registerNetworkListener(() => {
|
||||
setSyncDiagnostics(prev => ({
|
||||
...prev,
|
||||
isOnline: networkManager.isOnline,
|
||||
isNetworkOn: networkManager.isNetworkOn,
|
||||
offlineSince: networkManager.offlineSince,
|
||||
lastChecked: networkManager.lastChecked,
|
||||
}))
|
||||
})
|
||||
|
||||
const interval = setInterval(() => {
|
||||
refreshSyncDiagnostics()
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
unsubscribeSync()
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [refreshSyncDiagnostics])
|
||||
|
||||
useEffect(() => {
|
||||
const calculateTimeLeft = () => {
|
||||
@@ -239,6 +317,51 @@ const DeveloperSettings = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetDatabaseAndResync = async () => {
|
||||
showConfirmation(
|
||||
'This will clear local offline data and pending commands, then start a full sync from the beginning. Continue?',
|
||||
'Clear Local DB & Re-Sync',
|
||||
async () => {
|
||||
setIsResettingSync(true)
|
||||
try {
|
||||
await offlineDB.clearAll()
|
||||
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Local offline database cleared. Starting full sync...',
|
||||
})
|
||||
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) {
|
||||
await queryClient.invalidateQueries()
|
||||
showNotification({
|
||||
type: 'success',
|
||||
message: 'Full sync completed from the beginning',
|
||||
})
|
||||
} else {
|
||||
showNotification({
|
||||
type: 'warning',
|
||||
message:
|
||||
'Database cleared. Full sync did not run (likely offline or already syncing).',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset database and resync:', error)
|
||||
showNotification({
|
||||
type: 'error',
|
||||
message: `Reset/resync failed: ${error.message}`,
|
||||
})
|
||||
} finally {
|
||||
await refreshSyncDiagnostics()
|
||||
setIsResettingSync(false)
|
||||
}
|
||||
},
|
||||
'Clear & Re-Sync',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}
|
||||
|
||||
const getNotificationStatusColor = scheduleTime => {
|
||||
if (!scheduleTime) return 'neutral'
|
||||
|
||||
@@ -252,6 +375,11 @@ const DeveloperSettings = () => {
|
||||
return 'success' // More than 1 hour
|
||||
}
|
||||
|
||||
const formatDateTime = timestamp => {
|
||||
if (!timestamp) return 'N/A'
|
||||
return new Date(timestamp).toLocaleString()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-4 py-4' id='developer'>
|
||||
<Typography level='h3'>Developer Settings</Typography>
|
||||
@@ -377,6 +505,143 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<Typography level='title-lg'>Sync & Network Diagnostics</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='soft'
|
||||
startDecorator={<Refresh />}
|
||||
onClick={refreshSyncDiagnostics}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Network Status</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Connection:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.isOnline ? 'success' : 'danger'}
|
||||
>
|
||||
{syncDiagnostics.isOnline ? 'Online' : 'Offline'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Device Network:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={
|
||||
syncDiagnostics.isNetworkOn === false
|
||||
? 'danger'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'success'
|
||||
: 'neutral'
|
||||
}
|
||||
>
|
||||
{syncDiagnostics.isNetworkOn === false
|
||||
? 'Disconnected'
|
||||
: syncDiagnostics.isNetworkOn === true
|
||||
? 'Connected'
|
||||
: 'Unknown'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Offline Since: {formatDateTime(syncDiagnostics.offlineSince)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' color='neutral'>
|
||||
Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Sync Offset Information</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync Cursor:{' '}
|
||||
<Chip size='sm' variant='soft'>
|
||||
{syncDiagnostics.cursor ?? 'N/A'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Last Sync:{' '}
|
||||
<Chip size='sm' variant='soft' color='primary'>
|
||||
{formatDateTime(syncDiagnostics.lastSync)}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Sync State:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.syncing ? 'warning' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.syncing ? 'Syncing' : 'Idle'}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Pending Commands:{' '}
|
||||
<Chip size='sm' variant='soft' color='warning'>
|
||||
{syncDiagnostics.pendingCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
<Typography level='body-sm'>
|
||||
Failed Commands:{' '}
|
||||
<Chip
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color={syncDiagnostics.failedCount > 0 ? 'danger' : 'success'}
|
||||
>
|
||||
{syncDiagnostics.failedCount}
|
||||
</Chip>
|
||||
</Typography>
|
||||
{syncDiagnostics.syncError && (
|
||||
<Typography level='body-sm' color='danger'>
|
||||
Sync Error: {syncDiagnostics.syncError}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography level='title-sm'>Recovery Actions</Typography>
|
||||
<Typography level='body-xs' color='warning'>
|
||||
Clears local offline cache, sync cursor, and queued commands, then
|
||||
re-syncs from the beginning.
|
||||
</Typography>
|
||||
<Box>
|
||||
<Button
|
||||
size='sm'
|
||||
color='danger'
|
||||
variant='soft'
|
||||
onClick={handleResetDatabaseAndResync}
|
||||
loading={isResettingSync}
|
||||
disabled={isResettingSync || syncDiagnostics.syncing}
|
||||
>
|
||||
Clear DB & Full Re-Sync
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{isNativePlatform && (
|
||||
<Card variant='outlined'>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
@@ -436,9 +701,7 @@ const DeveloperSettings = () => {
|
||||
? new Date(scheduleTime)
|
||||
: null
|
||||
const now = new Date()
|
||||
const timeUntil = scheduledDate
|
||||
? scheduledDate - now
|
||||
: null
|
||||
const timeUntil = scheduledDate ? scheduledDate - now : null
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -726,6 +989,8 @@ const DeveloperSettings = () => {
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,17 +4,11 @@ import {
|
||||
Card,
|
||||
Chip,
|
||||
LinearProgress,
|
||||
Switch,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useUserProfile } from '../../queries/UserQueries'
|
||||
import {
|
||||
FEATURES,
|
||||
isFeatureEnabled,
|
||||
setFeatureEnabled,
|
||||
} from '../../utils/FeatureToggle'
|
||||
import { GetStorageUsage } from '../../utils/Fetcher'
|
||||
import { isPlusAccount } from '../../utils/Helpers'
|
||||
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
|
||||
@@ -26,9 +20,6 @@ const StorageSettings = () => {
|
||||
const [usage, setUsage] = useState({ used: 0, total: 0 })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [confirmModalConfig, setConfirmModalConfig] = useState({})
|
||||
const [offlineModeEnabled, setOfflineModeEnabledState] = useState(
|
||||
isFeatureEnabled(FEATURES.OFFLINE_MODE),
|
||||
)
|
||||
|
||||
const showConfirmation = (
|
||||
message,
|
||||
@@ -54,11 +45,6 @@ const StorageSettings = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleOfflineModeToggle = enabled => {
|
||||
setOfflineModeEnabledState(enabled)
|
||||
setFeatureEnabled(FEATURES.OFFLINE_MODE, enabled)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlusAccount(userProfile)) {
|
||||
GetStorageUsage().then(resp => {
|
||||
@@ -127,48 +113,14 @@ const StorageSettings = () => {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
Experimental Features
|
||||
<Chip variant='soft' color='warning' sx={{ ml: 1 }}>
|
||||
Coming Soon
|
||||
</Chip>
|
||||
</Typography>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<div className='flex-1'>
|
||||
<Typography level='body-md' sx={{ mb: 0.5 }}>
|
||||
Enable Offline Mode
|
||||
</Typography>
|
||||
<Typography level='body-sm' color='neutral'>
|
||||
Allows the app to work offline by caching data locally. This is
|
||||
experimental and may cause some slowness. If you experience
|
||||
performance issues, we recommend turning this off.
|
||||
</Typography>
|
||||
</div>
|
||||
<Switch
|
||||
checked={offlineModeEnabled}
|
||||
disabled={true}
|
||||
onChange={event => handleOfflineModeToggle(event.target.checked)}
|
||||
sx={{ ml: 2 }}
|
||||
/>
|
||||
</div>
|
||||
{offlineModeEnabled && (
|
||||
<Typography level='body-xs' color='warning' sx={{ mt: 1 }}>
|
||||
⚠️ Offline mode is enabled. If you experience slowness, disable
|
||||
this setting.
|
||||
</Typography>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className='p-4' sx={{ maxWidth: 500, mb: 2 }}>
|
||||
<Typography level='title-md' sx={{ mb: 1 }}>
|
||||
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
|
||||
Cache
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ mb: 1 }}>
|
||||
This is data stored locally in your browser for faster access and
|
||||
offline use. Clearing this will not affect your server data, but may
|
||||
log you out or remove offline tasks.
|
||||
This is data stored locally in your browser for faster access.
|
||||
Clearing this will not affect your server data, but may log you out.
|
||||
</Typography>
|
||||
<Button
|
||||
variant='soft'
|
||||
@@ -189,27 +141,6 @@ const StorageSettings = () => {
|
||||
>
|
||||
Clear All Local Storage and Cache
|
||||
</Button>
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='danger'
|
||||
onClick={() => {
|
||||
showConfirmation(
|
||||
'Are you sure you want to clear only the offline cache and tasks?',
|
||||
'Clear Offline Cache',
|
||||
() => {
|
||||
localStorage.removeItem('offline_cache')
|
||||
localStorage.removeItem('offline_request_queue')
|
||||
localStorage.removeItem('offlineTasks')
|
||||
},
|
||||
'Clear Cache',
|
||||
'Cancel',
|
||||
'danger',
|
||||
)
|
||||
}}
|
||||
sx={{ mt: 1 }}
|
||||
>
|
||||
Clear Offline Cache and Offline Tasks
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{Capacitor.isNativePlatform() && (
|
||||
|
||||
@@ -47,10 +47,14 @@ import {
|
||||
} from '../../queries/TimeQueries'
|
||||
import { useCircleMembers } from '../../queries/UserQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { commandQueue, CommandType } from '../../utils/CommandQueue'
|
||||
import { resolvePhotoURL } from '../../utils/Helpers'
|
||||
import { getSafeBottom } from '../../utils/SafeAreaUtils'
|
||||
import LoadingComponent from '../components/Loading'
|
||||
|
||||
const isNetworkError = err =>
|
||||
err instanceof TypeError && err.message === 'Failed to fetch'
|
||||
|
||||
const TimerDetails = () => {
|
||||
const { choreId } = useParams()
|
||||
const { fmt } = useLocalization()
|
||||
@@ -256,7 +260,23 @@ const TimerDetails = () => {
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
onError: () => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.START_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Start queued',
|
||||
message: "You're offline — start will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to start timer',
|
||||
message: 'Please try again.',
|
||||
@@ -278,7 +298,23 @@ const TimerDetails = () => {
|
||||
})
|
||||
refetchTimer()
|
||||
},
|
||||
onError: () => {
|
||||
onError: async error => {
|
||||
if (isNetworkError(error)) {
|
||||
const cmdId = await commandQueue.enqueue(
|
||||
CommandType.PAUSE_CHORE,
|
||||
choreId,
|
||||
{ id: choreId },
|
||||
)
|
||||
showSuccess({
|
||||
title: 'Pause queued',
|
||||
message: "You're offline — pause will sync when back online",
|
||||
undoAction: async () => {
|
||||
await commandQueue.cancel(cmdId)
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showError({
|
||||
title: 'Failed to pause timer',
|
||||
message: 'Please try again.',
|
||||
@@ -928,9 +964,7 @@ const TimerDetails = () => {
|
||||
'MMM DD',
|
||||
)
|
||||
const startTime = fmt.time(pause.start)
|
||||
const endTime = pause.end
|
||||
? fmt.time(pause.end)
|
||||
: null
|
||||
const endTime = pause.end ? fmt.time(pause.end) : null
|
||||
|
||||
const realTimeDuration = isOngoing
|
||||
? Math.max(
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Add, EditNotifications } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
FormHelperText,
|
||||
Input,
|
||||
Option,
|
||||
Select,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { FormControl } from '@mui/material'
|
||||
import { Add } from '@mui/icons-material'
|
||||
import { Box, Button, Typography } from '@mui/joy'
|
||||
import { useMediaQuery } from '@mui/material'
|
||||
import * as chrono from 'chrono-node'
|
||||
import moment from 'moment'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -30,8 +21,15 @@ import {
|
||||
import SmartTaskTitleInput from './SmartTaskTitleInput'
|
||||
|
||||
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { TASK_COLOR } from '../../utils/Colors'
|
||||
import AssigneePickerField from './AssigneePickerField'
|
||||
import AttachmentPickerField from './AttachmentPickerField'
|
||||
import DueDatePickerField from './DueDatePickerField'
|
||||
import LabelsPickerField from './LabelsPickerField'
|
||||
import LearnMoreButton from './LearnMore'
|
||||
import NotificationPickerField from './NotificationPickerField'
|
||||
import PriorityPickerField from './PriorityPickerField'
|
||||
import RepeatPickerField from './RepeatPickerField'
|
||||
import RichTextEditor from './RichTextEditor'
|
||||
import SubTasks from './SubTask'
|
||||
const getDefaultNotification = () => {
|
||||
@@ -46,18 +44,20 @@ const getDefaultNotification = () => {
|
||||
]
|
||||
|
||||
localStorage.setItem(
|
||||
'defaultNotification',
|
||||
'defaultNotificationTemplate',
|
||||
JSON.stringify(defaultNotification),
|
||||
)
|
||||
return defaultNotification
|
||||
}
|
||||
|
||||
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm'))
|
||||
const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text'
|
||||
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
|
||||
const { data: circleMembers, isLoading: isCircleMembersLoading } =
|
||||
useCircleMembers()
|
||||
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
|
||||
const { isLoading: isProjectsLoading } = useProjects()
|
||||
const createChoreMutation = useCreateChore()
|
||||
|
||||
const { data: userProfile } = useUserProfile()
|
||||
@@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [taskTitle, setTaskTitle] = useState('')
|
||||
const [renderedParts, setRenderedParts] = useState([])
|
||||
|
||||
const textareaRef = useRef(null)
|
||||
const mainInputRef = useRef(null)
|
||||
const richTextEditorRef = useRef(null)
|
||||
const latestRef = useRef({})
|
||||
const [priority, setPriority] = useState(0)
|
||||
const [dueDate, setDueDate] = useState(null)
|
||||
const [description, setDescription] = useState(null)
|
||||
@@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const [notificationMetadata, setNotificationMetadata] = useState({
|
||||
templates: getDefaultNotification(),
|
||||
})
|
||||
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
|
||||
const [subTasks, setSubTasks] = useState(null)
|
||||
const [points, setPoints] = useState(-1)
|
||||
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
|
||||
const [hasDescription, setHasDescription] = useState(false)
|
||||
const [hasSubTasks, setHasSubTasks] = useState(false)
|
||||
const [hasNotifications, setHasNotifications] = useState(false)
|
||||
const [hasDeadline, setHasDeadline] = useState(false)
|
||||
const [deadlineOffset, setDeadlineOffset] = useState(-1)
|
||||
const [dueDateOnly, setDueDateOnly] = useState(null)
|
||||
const [dueTime, setDueTime] = useState(null)
|
||||
const [useCustomTime, setUseCustomTime] = useState(false)
|
||||
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
|
||||
const [projectId, setProjectId] = useState(getInitialProject())
|
||||
const [attachments, setAttachments] = useState([])
|
||||
|
||||
// Priority colors
|
||||
const priorityColors = {
|
||||
0: TASK_COLOR.NO_PRIORITY,
|
||||
1: TASK_COLOR.PRIORITY_1,
|
||||
2: TASK_COLOR.PRIORITY_2,
|
||||
3: TASK_COLOR.PRIORITY_3,
|
||||
4: TASK_COLOR.PRIORITY_4,
|
||||
}
|
||||
|
||||
const priorityLabels = {
|
||||
0: '--',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
@@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}, [hasDescription])
|
||||
|
||||
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
|
||||
useEffect(() => {
|
||||
const handleKeyDown = event => {
|
||||
const {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
handleCloseModal,
|
||||
} = latestRef.current
|
||||
const isHoldingCmd = event.ctrlKey || event.metaKey
|
||||
if (isHoldingCmd) {
|
||||
// event.preventDefault()
|
||||
setShowKeyboardShortcuts(true)
|
||||
}
|
||||
if (
|
||||
@@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
|
||||
// add subtask:
|
||||
setHasSubTasks(true)
|
||||
setShowKeyboardShortcuts(false)
|
||||
// set focus on the first subtask input:
|
||||
}
|
||||
if (
|
||||
isHoldingCmd &&
|
||||
@@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
isModalOpen &&
|
||||
!dueDate
|
||||
) {
|
||||
// add due date:
|
||||
const tomorrow = moment().add(1, 'day')
|
||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
|
||||
@@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setDueTime(null)
|
||||
setShowKeyboardShortcuts(false)
|
||||
}
|
||||
// Enter key to create task
|
||||
if (
|
||||
event.key === 'Enter' &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
@@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
createChore()
|
||||
return
|
||||
}
|
||||
// Escape key to cancel/close modal
|
||||
if (event.key === 'Escape' && isModalOpen) {
|
||||
event.preventDefault()
|
||||
handleCloseModal()
|
||||
@@ -185,22 +199,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen && textareaRef.current) {
|
||||
textareaRef.current.focus()
|
||||
textareaRef.current.selectionStart = textareaRef.current.value?.length
|
||||
textareaRef.current.selectionEnd = textareaRef.current.value?.length
|
||||
}
|
||||
}, [isModalOpen])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoFocus > 0 && mainInputRef.current) {
|
||||
mainInputRef.current.focus()
|
||||
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
|
||||
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
|
||||
}
|
||||
}, [autoFocus])
|
||||
|
||||
const renderHighlightedSentence = useCallback(
|
||||
(
|
||||
sentence,
|
||||
@@ -262,20 +260,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
} else {
|
||||
// No overlap, add the current highlight
|
||||
resolvedHighlights.push(current)
|
||||
}
|
||||
}
|
||||
|
||||
for (const highlight of resolvedHighlights) {
|
||||
// Add the text before the highlight
|
||||
if (highlight.start > lastIndex) {
|
||||
const textBefore = sentence.substring(lastIndex, highlight.start)
|
||||
parts.push(textBefore)
|
||||
plainText += textBefore
|
||||
}
|
||||
|
||||
// Determine the class name based on the highlight type
|
||||
let className = ''
|
||||
switch (highlight.type) {
|
||||
case 'repeat':
|
||||
@@ -300,7 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
break
|
||||
}
|
||||
|
||||
// Add the highlighted span
|
||||
const highlightedText = sentence.substring(
|
||||
highlight.start,
|
||||
highlight.end,
|
||||
@@ -310,9 +304,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
key={highlight.start}
|
||||
className={className}
|
||||
style={{
|
||||
// text underline:
|
||||
textDecoration: 'underline',
|
||||
// textDecorationColor: 'red',
|
||||
textDecorationThickness: '2px',
|
||||
textDecorationStyle: 'dashed',
|
||||
}}
|
||||
@@ -321,11 +313,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
</span>,
|
||||
)
|
||||
|
||||
// Update the last index to the end of the current highlight
|
||||
lastIndex = highlight.end
|
||||
}
|
||||
|
||||
// Add any remaining text after the last highlight
|
||||
if (lastIndex < sentence.length) {
|
||||
const remainingText = sentence.substring(lastIndex)
|
||||
parts.push(remainingText)
|
||||
@@ -342,12 +332,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
const processText = useCallback(
|
||||
sentence => {
|
||||
// Parse everything from the original sentence to get correct highlight positions
|
||||
const priority = parsePriority(sentence)
|
||||
const pointsParsed = parsePoints(sentence)
|
||||
const labels = parseLabels(sentence, userLabels || [])
|
||||
|
||||
// Parse assignees using circle members
|
||||
const circleMembersList = circleMembers?.res || []
|
||||
const assigneesForParsing = circleMembersList.map(member => ({
|
||||
userId: member.userId,
|
||||
@@ -364,9 +352,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const dueDateParsed = parseDueDate(sentence, chrono)
|
||||
|
||||
// Set all the parsed values
|
||||
if (priority.result) setPriority(priority.result)
|
||||
if (priority.result) setPriority(parseInt(priority.result, 10))
|
||||
if (pointsParsed.result) setPoints(pointsParsed.result)
|
||||
if (labels.result) setLabelsV2(labels.result)
|
||||
if (labels.result) {
|
||||
// parseLabels returns array of label objects, extract their IDs
|
||||
const labelIds = labels.result
|
||||
.filter(label => label.id) // Only labels with IDs (existing labels)
|
||||
.map(label => label.id)
|
||||
setLabelsV2(labelIds)
|
||||
}
|
||||
|
||||
if (assigneesResult.isAnyone) {
|
||||
// @Anyone was used - set empty assignees (anyone can do the task)
|
||||
@@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
|
||||
if (repeat.result) {
|
||||
setFrequency(repeat.result)
|
||||
setFrequencyHumanReadable(repeat.name)
|
||||
}
|
||||
|
||||
const syncDueDateStates = parsedDate => {
|
||||
@@ -416,15 +409,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
dueDateHighlight = dueDateParsed.highlight[0]
|
||||
}
|
||||
|
||||
if (repeat.result) {
|
||||
// if repeat has result the cleaned sentence will remove the date related info which mean
|
||||
// we need to reparse the date again to get the correct due date:
|
||||
const dueDateParsedAgain = parseDueDate(sentence, chrono)
|
||||
if (dueDateParsedAgain.result) {
|
||||
syncDueDateStates(dueDateParsedAgain.result)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the cleaned sentence by sequentially applying all cleanups
|
||||
let cleanedSentence = sentence
|
||||
if (priority.result) cleanedSentence = priority.cleanedSentence
|
||||
@@ -509,6 +493,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
}
|
||||
}
|
||||
@@ -517,9 +503,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
const timeValue = e.target.value
|
||||
setDueTime(timeValue)
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
if (timeValue) {
|
||||
setUseCustomTime(true)
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
} else {
|
||||
setUseCustomTime(false)
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,13 +521,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setUseCustomTime(checked)
|
||||
if (checked) {
|
||||
const defaultTime = dueTime || '18:00'
|
||||
setDueTime(defaultTime)
|
||||
if (!dueTime) {
|
||||
setDueTime(defaultTime)
|
||||
}
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
setDueTime(null)
|
||||
if (dueDateOnly) {
|
||||
setDueDate(
|
||||
moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'),
|
||||
@@ -552,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setTaskTitle('')
|
||||
setDueDate(null)
|
||||
setFrequency(null)
|
||||
setFrequencyHumanReadable(null)
|
||||
setPriority(0)
|
||||
setPoints(-1)
|
||||
setIsAnyoneTask(false)
|
||||
@@ -563,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
setLabelsV2([])
|
||||
setAssignees([])
|
||||
setProjectId(getInitialProject())
|
||||
setHasDeadline(false)
|
||||
setDeadlineOffset(-1)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
@@ -614,6 +609,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
notificationMetadata: {},
|
||||
subTasks: subTasks?.length > 0 ? subTasks : null,
|
||||
projectId: projectId === 'default' ? null : projectId,
|
||||
attachments: attachments.length > 0 ? attachments : null,
|
||||
}
|
||||
|
||||
if (frequency) {
|
||||
@@ -626,39 +622,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}
|
||||
}
|
||||
if (!frequency && dueDate) {
|
||||
// use dueDate converted to UTC:
|
||||
chore.nextDueDate = new Date(dueDate).toUTCString()
|
||||
// Use RFC3339/ISO-8601 format expected by backend.
|
||||
chore.nextDueDate = new Date(dueDate).toISOString()
|
||||
chore.notificationMetadata = notificationMetadata
|
||||
}
|
||||
|
||||
createChoreMutation
|
||||
.mutateAsync(chore)
|
||||
.then(resp => {
|
||||
resp.json().then(data => {
|
||||
if (resp.status !== 200) {
|
||||
console.error('Error creating chore:', data)
|
||||
return
|
||||
} else {
|
||||
onChoreUpdate({
|
||||
...chore,
|
||||
id: data.res,
|
||||
nextDueDate: chore.dueDate,
|
||||
})
|
||||
|
||||
handleCloseModal(false)
|
||||
}
|
||||
handleCloseModal()
|
||||
setTaskText('')
|
||||
})
|
||||
.then(result => {
|
||||
const choreData = result
|
||||
if (choreData?._pendingCreate) {
|
||||
// Offline: task queued, add temp chore to UI immediately
|
||||
onChoreUpdate(choreData)
|
||||
} else {
|
||||
// Online: choreData is the created chore object returned by the mutation
|
||||
onChoreUpdate({
|
||||
...chore,
|
||||
...choreData,
|
||||
id: choreData?.id,
|
||||
nextDueDate: chore.dueDate,
|
||||
})
|
||||
}
|
||||
setTaskText('')
|
||||
})
|
||||
.catch(error => {
|
||||
if (error?.queued) {
|
||||
handleCloseModal(true)
|
||||
}
|
||||
console.error('Error creating chore:', error)
|
||||
})
|
||||
handleCloseModal(false)
|
||||
}
|
||||
if (userLabelsLoading || isCircleMembersLoading || isProjectsLoading) {
|
||||
|
||||
latestRef.current = {
|
||||
isModalOpen,
|
||||
hasDescription,
|
||||
dueDate,
|
||||
createChore,
|
||||
handleCloseModal,
|
||||
}
|
||||
|
||||
if (isCircleMembersLoading || isProjectsLoading) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
@@ -698,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
size='lg'
|
||||
variant='solid'
|
||||
color='primary'
|
||||
disabled={!taskTitle.trim()}
|
||||
onClick={createChore}
|
||||
>
|
||||
Create
|
||||
@@ -761,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
<SmartTaskTitleInput
|
||||
autoFocus
|
||||
value={taskText}
|
||||
placeholder='Type your full text here...'
|
||||
placeholder='Type your task...'
|
||||
onChange={text => {
|
||||
setTaskText(text)
|
||||
}}
|
||||
@@ -806,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* <Box>
|
||||
<Typography level='body-sm'>Title:</Typography>
|
||||
<Input
|
||||
value={taskTitle}
|
||||
onChange={e => setTaskTitle(e.target.value)}
|
||||
sx={{ width: '100%', fontSize: '16px' }}
|
||||
/>
|
||||
</Box> */}
|
||||
<Box
|
||||
sx={{
|
||||
paddingTop: 2,
|
||||
paddingBottom: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 1.5,
|
||||
|
||||
<Box>
|
||||
// scrollable horizontally but hide the scrollbar:
|
||||
overflowX: 'auto',
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
// if not mobile then go to next line if not enough space( show chip on next line):
|
||||
flexWrap: isMobile ? 'nowrap' : 'wrap',
|
||||
}}
|
||||
>
|
||||
<DueDatePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
dueDateOnly={dueDateOnly}
|
||||
dueTime={dueTime}
|
||||
useCustomTime={useCustomTime}
|
||||
onDueDateChange={handleDueDateChange}
|
||||
onDueTimeChange={handleDueTimeChange}
|
||||
onUseCustomTimeChange={handleUseCustomTimeChange}
|
||||
onClear={() => {
|
||||
setDueDate(null)
|
||||
setDueDateOnly(null)
|
||||
setDueTime(null)
|
||||
setUseCustomTime(false)
|
||||
}}
|
||||
/>
|
||||
<RepeatPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={frequency}
|
||||
onChange={setFrequency}
|
||||
onClear={() => setFrequency(null)}
|
||||
/>
|
||||
<PriorityPickerField
|
||||
value={priority}
|
||||
onChange={setPriority}
|
||||
onClear={() => setPriority(0)}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
priorityColors={priorityColors}
|
||||
priorityLabels={priorityLabels}
|
||||
/>
|
||||
<AssigneePickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
value={assignees?.[0]?.userId || null}
|
||||
onChange={userId => {
|
||||
if (!userId) {
|
||||
setAssignees([])
|
||||
} else {
|
||||
setAssignees([{ userId }])
|
||||
}
|
||||
}}
|
||||
onClear={() => setAssignees([])}
|
||||
currentUserId={userProfile?.id}
|
||||
members={circleMembers?.res || []}
|
||||
/>
|
||||
|
||||
<LabelsPickerField
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
values={labelsV2 || []}
|
||||
onChange={setLabelsV2}
|
||||
onClear={() => setLabelsV2([])}
|
||||
labels={userLabels || []}
|
||||
/>
|
||||
<AttachmentPickerField
|
||||
attachments={attachments}
|
||||
onChange={setAttachments}
|
||||
onClear={() => setAttachments([])}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
entityType='chore_attachment'
|
||||
/>
|
||||
<NotificationPickerField
|
||||
value={notificationMetadata}
|
||||
onChange={setNotificationMetadata}
|
||||
onClear={() => setNotificationMetadata({ templates: [] })}
|
||||
emptyDisplay={pickerEmptyDisplay}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
|
||||
{!hasDescription && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasDescription(true)
|
||||
// Focus will be handled by the useEffect hook
|
||||
@@ -836,8 +913,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
{!hasSubTasks && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='md'
|
||||
onClick={() => {
|
||||
setHasSubTasks(true)
|
||||
}}
|
||||
@@ -848,52 +926,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
Subtasks
|
||||
</Button>
|
||||
)}
|
||||
{!dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
const tomorrow = moment().add(1, 'day')
|
||||
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
|
||||
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
|
||||
setUseCustomTime(false)
|
||||
setDueTime(null)
|
||||
}}
|
||||
endDecorator={
|
||||
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='B' />
|
||||
}
|
||||
>
|
||||
Due Date
|
||||
</Button>
|
||||
)}
|
||||
{!hasNotifications && dueDate && (
|
||||
<Button
|
||||
startDecorator={<EditNotifications />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasNotifications(true)
|
||||
setFrequencyHumanReadable('Once')
|
||||
setFrequency(null)
|
||||
}}
|
||||
>
|
||||
Edit Notifications
|
||||
</Button>
|
||||
)}
|
||||
{/* {!hasDeadline && dueDate && (
|
||||
<Button
|
||||
startDecorator={<Add />}
|
||||
variant='plain'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setHasDeadline(true)
|
||||
setDeadlineOffset(86400)
|
||||
}}
|
||||
>
|
||||
Set Deadline
|
||||
</Button>
|
||||
)} */}
|
||||
</Box>
|
||||
|
||||
{hasDescription && (
|
||||
@@ -919,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{priority > 0 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Priority</Typography>
|
||||
<Select
|
||||
defaultValue={0}
|
||||
value={priority}
|
||||
onChange={(e, value) => setPriority(value)}
|
||||
>
|
||||
<Option value='0'>No Priority</Option>
|
||||
<Option value='1'>P1</Option>
|
||||
<Option value='2'>P2</Option>
|
||||
<Option value='3'>P3</Option>
|
||||
<Option value='4'>P4</Option>
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{dueDate && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Due Date</Typography>
|
||||
<Input
|
||||
type='date'
|
||||
value={dueDateOnly || ''}
|
||||
onChange={handleDueDateChange}
|
||||
/>
|
||||
<Checkbox
|
||||
size='sm'
|
||||
checked={useCustomTime}
|
||||
onChange={e => handleUseCustomTimeChange(e.target.checked)}
|
||||
label='Set a specific time'
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
<FormHelperText>
|
||||
{useCustomTime
|
||||
? 'Task will be due at the specified time'
|
||||
: 'Task will be due at the end of the day (11:59 PM)'}
|
||||
</FormHelperText>
|
||||
{useCustomTime && (
|
||||
<Input
|
||||
type='time'
|
||||
value={dueTime || '18:00'}
|
||||
onChange={handleDueTimeChange}
|
||||
sx={{ maxWidth: 200, mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
)}
|
||||
</Box>
|
||||
{/* {projects.length >= 1 && (
|
||||
<FormControl>
|
||||
<Typography level='body-sm'>Project</Typography>
|
||||
<Select
|
||||
value={projectId}
|
||||
onChange={(event, newValue) => setProjectId(newValue)}
|
||||
sx={{ minWidth: '15rem' }}
|
||||
>
|
||||
<Option key='default' value='default'>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: '#1976d2',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const IconComponent = getIconComponent('FolderOpen')
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor('#1976d2'),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</Avatar>
|
||||
Default Project
|
||||
</Box>
|
||||
</Option>
|
||||
{projects.map(project => (
|
||||
<Option key={project.id} value={project.id}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Avatar
|
||||
size='sm'
|
||||
sx={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
bgcolor: project.color || '#1976d2',
|
||||
}}
|
||||
>
|
||||
{project.icon ? (
|
||||
(() => {
|
||||
const IconComponent = getIconComponent(project.icon)
|
||||
return (
|
||||
<IconComponent
|
||||
sx={{
|
||||
fontSize: 14,
|
||||
color: getTextColorFromBackgroundColor(
|
||||
project.color || '#1976d2',
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Avatar>
|
||||
{project.name}
|
||||
</Box>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)} */}
|
||||
<Box
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'start',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{/* <FormControl>
|
||||
<Typography level='body-sm'>Assignees</Typography>
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
|
||||
{assignees.length > 0 ? (
|
||||
assignees.map((assignee, index) => (
|
||||
<Chip
|
||||
key={assignee.userId || index}
|
||||
variant='soft'
|
||||
size='lg'
|
||||
color='primary'
|
||||
>
|
||||
{assignee.displayName || assignee.username}
|
||||
</Chip>
|
||||
))
|
||||
) : (
|
||||
<Chip variant='soft' size='sm' color='neutral'>
|
||||
{userProfile.displayName}
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
</FormControl> */}
|
||||
{/* {hasDeadline && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'start',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Deadline</Typography>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
|
||||
>
|
||||
<DurationInput
|
||||
value={deadlineOffset}
|
||||
onChange={setDeadlineOffset}
|
||||
size='sm'
|
||||
minValue={0}
|
||||
/>
|
||||
<Typography level='body-sm'>after due date</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)} */}
|
||||
{hasNotifications && dueDate && (
|
||||
<Box
|
||||
sx={{
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>Notification Schedule</Typography>
|
||||
<Box sx={{ p: 0.5 }}>
|
||||
<NotificationTemplate
|
||||
onChange={metadata => {
|
||||
if (
|
||||
metadata.notifications !== notificationMetadata.templates
|
||||
) {
|
||||
const newNotificationMetadata = {
|
||||
...notificationMetadata,
|
||||
templates: metadata.notifications,
|
||||
}
|
||||
setNotificationMetadata(newNotificationMetadata)
|
||||
}
|
||||
}}
|
||||
value={notificationMetadata}
|
||||
showTimeline={false}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
)
|
||||
}
|
||||
|
||||
43
src/views/components/AssigneePickerField.jsx
Normal file
43
src/views/components/AssigneePickerField.jsx
Normal 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
|
||||
258
src/views/components/AttachmentPickerField.jsx
Normal file
258
src/views/components/AttachmentPickerField.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
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,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const buttonRef = useRef(null)
|
||||
const { uploadFile } = useFileUpload({ entityType, entityId })
|
||||
|
||||
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
|
||||
253
src/views/components/BaseOptionPicker.jsx
Normal file
253
src/views/components/BaseOptionPicker.jsx
Normal 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
|
||||
@@ -45,6 +45,20 @@ const ALL_MONTHS = Object.values(VALID_MONTHS).filter(
|
||||
(v, i, a) => a.indexOf(v) === i,
|
||||
)
|
||||
|
||||
// Helper function to validate word boundaries for date/time matches
|
||||
// Prevents matching partial words like 'wed' in 'wedding', 'fri' in 'friend', etc.
|
||||
const isValidWordBoundary = (text, matchIndex, matchLength) => {
|
||||
const charBefore = matchIndex > 0 ? text[matchIndex - 1] : ' '
|
||||
const charAfter =
|
||||
matchIndex + matchLength < text.length
|
||||
? text[matchIndex + matchLength]
|
||||
: ' '
|
||||
// Valid boundaries: spaces, punctuation, but NOT alphanumeric or word-forming characters
|
||||
return !(
|
||||
/[\p{L}\p{N}_]/u.test(charBefore) || /[\p{L}\p{N}_]/u.test(charAfter)
|
||||
)
|
||||
}
|
||||
|
||||
export const parsePriority = inputSentence => {
|
||||
let sentence = inputSentence.toLowerCase()
|
||||
const priorityMap = {
|
||||
@@ -292,7 +306,22 @@ export const parseRepeatV2 = inputSentence => {
|
||||
.toLowerCase()
|
||||
.split(/ and |,|\s/)
|
||||
.map(day => day.trim())
|
||||
.filter(day => VALID_DAYS[day])
|
||||
.filter(day => {
|
||||
// Validate that the day abbreviation is at proper word boundaries
|
||||
// This prevents matches like 'wed' in 'wedding', 'fri' in 'friend'
|
||||
if (!VALID_DAYS[day]) return false
|
||||
// For short abbreviations (3 chars or less), validate word boundaries
|
||||
if (day.length <= 3) {
|
||||
const dayIndex = sentence.toLowerCase().indexOf(day)
|
||||
if (dayIndex === -1) return false
|
||||
return isValidWordBoundary(
|
||||
sentence.toLowerCase(),
|
||||
dayIndex,
|
||||
day.length,
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(day => VALID_DAYS[day])
|
||||
if (!result.frequencyMetadata.days.length)
|
||||
return { result: null, name: null, cleanedSentence: inputSentence }
|
||||
@@ -318,7 +347,20 @@ export const parseRepeatV2 = inputSentence => {
|
||||
.toLowerCase()
|
||||
.split(/ and |,|\s/)
|
||||
.map(month => month.trim())
|
||||
.filter(month => VALID_MONTHS[month])
|
||||
.filter(month => {
|
||||
if (!VALID_MONTHS[month]) return false
|
||||
// For short abbreviations (3 chars or less), validate word boundaries
|
||||
if (month.length <= 3) {
|
||||
const monthIndex = sentence.toLowerCase().indexOf(month)
|
||||
if (monthIndex === -1) return false
|
||||
return isValidWordBoundary(
|
||||
sentence.toLowerCase(),
|
||||
monthIndex,
|
||||
month.length,
|
||||
)
|
||||
}
|
||||
return true
|
||||
})
|
||||
.map(month => VALID_MONTHS[month])
|
||||
result.frequencyMetadata.unit = 'days'
|
||||
return {
|
||||
@@ -664,7 +706,7 @@ export const parseDueDate = (inputSentence, chrono) => {
|
||||
forwardDate: true,
|
||||
})
|
||||
|
||||
if (!parsedDueDate[0] || parsedDueDate[0].index === -1) {
|
||||
if (!parsedDueDate.length) {
|
||||
return {
|
||||
result: null,
|
||||
highlight: [],
|
||||
@@ -672,7 +714,21 @@ export const parseDueDate = (inputSentence, chrono) => {
|
||||
}
|
||||
}
|
||||
|
||||
const dueDateMatch = parsedDueDate[0]
|
||||
// Select the first valid word-bounded date match
|
||||
// Prevents false positives like "wed" in "wedding" while still allowing later valid matches
|
||||
const dueDateMatch = parsedDueDate.find(
|
||||
match =>
|
||||
match.index !== -1 &&
|
||||
isValidWordBoundary(inputSentence, match.index, match.text.length),
|
||||
)
|
||||
|
||||
if (!dueDateMatch) {
|
||||
return {
|
||||
result: null,
|
||||
highlight: [],
|
||||
cleanedSentence: inputSentence,
|
||||
}
|
||||
}
|
||||
const dueDateText = dueDateMatch.text
|
||||
const dueDateStartIndex = dueDateMatch.index
|
||||
const dueDateEndIndex = dueDateStartIndex + dueDateText.length
|
||||
|
||||
628
src/views/components/DueDatePickerField.jsx
Normal file
628
src/views/components/DueDatePickerField.jsx
Normal file
@@ -0,0 +1,628 @@
|
||||
import {
|
||||
Bedtime,
|
||||
CalendarMonth,
|
||||
Close,
|
||||
EventNote,
|
||||
LightMode,
|
||||
NextWeek,
|
||||
NightsStay,
|
||||
Today,
|
||||
WbSunny,
|
||||
WbTwilight,
|
||||
Weekend,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Calendar from 'react-calendar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const DueDatePickerField = ({
|
||||
dueDateOnly,
|
||||
dueTime,
|
||||
useCustomTime,
|
||||
onDueDateChange,
|
||||
onDueTimeChange,
|
||||
onUseCustomTimeChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const { firstDayOfWeek } = useLocalization()
|
||||
|
||||
// Local buffered state — only committed on Apply
|
||||
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
|
||||
const [localDueTime, setLocalDueTime] = useState(dueTime)
|
||||
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
|
||||
|
||||
// Sync local state from props whenever the modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalDueDateOnly(dueDateOnly)
|
||||
setLocalDueTime(dueTime)
|
||||
setLocalUseCustomTime(useCustomTime)
|
||||
}
|
||||
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
const calendarType =
|
||||
firstDayOfWeek === 1
|
||||
? 'iso8601'
|
||||
: firstDayOfWeek === 6
|
||||
? 'islamic'
|
||||
: 'gregory'
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
const getQuickScheduleDate = option => {
|
||||
const now = new Date()
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
|
||||
switch (option) {
|
||||
case 'today':
|
||||
return today
|
||||
case 'tomorrow': {
|
||||
const tomorrow = new Date(today)
|
||||
tomorrow.setDate(today.getDate() + 1)
|
||||
return tomorrow
|
||||
}
|
||||
case 'weekend': {
|
||||
const weekend = new Date(today)
|
||||
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
|
||||
weekend.setDate(today.getDate() + daysUntilSaturday)
|
||||
return weekend
|
||||
}
|
||||
case 'next-week': {
|
||||
const nextWeek = new Date(today)
|
||||
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
|
||||
nextWeek.setDate(today.getDate() + daysUntilMonday)
|
||||
return nextWeek
|
||||
}
|
||||
case 'next-month': {
|
||||
const nextMonth = new Date(today)
|
||||
nextMonth.setMonth(today.getMonth() + 1)
|
||||
return nextMonth
|
||||
}
|
||||
default:
|
||||
return today
|
||||
}
|
||||
}
|
||||
|
||||
const handleQuickSchedule = option => {
|
||||
const date = getQuickScheduleDate(option)
|
||||
setLocalDueDateOnly(date.toISOString().split('T')[0])
|
||||
}
|
||||
|
||||
const handleQuickTime = timeStr => {
|
||||
// Tap the active chip again to deselect it
|
||||
if (localUseCustomTime && localDueTime === timeStr) {
|
||||
setLocalUseCustomTime(false)
|
||||
setLocalDueTime(null)
|
||||
return
|
||||
}
|
||||
if (!localDueDateOnly) {
|
||||
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
|
||||
}
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(timeStr)
|
||||
}
|
||||
|
||||
const handleCalendarChange = selected => {
|
||||
if (!selected || Array.isArray(selected)) return
|
||||
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const handleLocalTimeInputChange = e => {
|
||||
setLocalUseCustomTime(true)
|
||||
setLocalDueTime(e.target.value)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
|
||||
onUseCustomTimeChange?.(localUseCustomTime)
|
||||
if (localUseCustomTime && localDueTime) {
|
||||
onDueTimeChange?.({ target: { value: localDueTime } })
|
||||
} else {
|
||||
onDueTimeChange?.({ target: { value: '' } })
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const hasDueDate = Boolean(dueDateOnly)
|
||||
const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text'
|
||||
|
||||
const dueDateLabel = useMemo(() => {
|
||||
if (!dueDateOnly) {
|
||||
return 'Due'
|
||||
}
|
||||
|
||||
const formattedDate = moment(dueDateOnly).format('MMM D')
|
||||
if (useCustomTime && dueTime) {
|
||||
return `${formattedDate}, ${dueTime}`
|
||||
}
|
||||
|
||||
return formattedDate
|
||||
}, [dueDateOnly, dueTime, useCustomTime])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasDueDate ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<CalendarMonth sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 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',
|
||||
}}
|
||||
>
|
||||
{dueDateLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
{hasDueDate && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
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>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Due Date'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasDueDate && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
|
||||
{/* Date shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick date
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
key: 'today',
|
||||
label: 'Today',
|
||||
icon: <Today sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'tomorrow',
|
||||
label: 'Tomorrow',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'weekend',
|
||||
label: 'Weekend',
|
||||
icon: <Weekend sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-week',
|
||||
label: 'Next week',
|
||||
icon: <NextWeek sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
key: 'next-month',
|
||||
label: 'Next month',
|
||||
icon: <EventNote sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => {
|
||||
const dateStr = getQuickScheduleDate(opt.key)
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
return (
|
||||
<ListItem key={opt.key}>
|
||||
<Checkbox
|
||||
checked={localDueDateOnly === dateStr}
|
||||
onClick={() => handleQuickSchedule(opt.key)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
}}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
)
|
||||
})}
|
||||
</List>
|
||||
|
||||
{/* Time shortcuts */}
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.75,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Quick time
|
||||
</Typography>
|
||||
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
|
||||
{[
|
||||
{
|
||||
time: '09:00',
|
||||
label: 'Morning',
|
||||
icon: <LightMode sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '12:00',
|
||||
label: 'Noon',
|
||||
icon: <WbSunny sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '15:00',
|
||||
label: 'Afternoon',
|
||||
icon: <WbTwilight sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '18:00',
|
||||
label: 'Evening',
|
||||
icon: <NightsStay sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
time: '22:00',
|
||||
label: 'Night',
|
||||
icon: <Bedtime sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
].map(opt => (
|
||||
<ListItem key={opt.time}>
|
||||
<Checkbox
|
||||
checked={localUseCustomTime && localDueTime === opt.time}
|
||||
onClick={() => handleQuickTime(opt.time)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
borderRadius: 'md',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.outlinedBorder',
|
||||
bgcolor: 'background.surface',
|
||||
p: 1,
|
||||
// Fix the height so switching views (month/year/decade) doesn't
|
||||
// cause layout shift — month view with 6 rows is the tallest.
|
||||
minHeight: 300,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
'& .react-calendar': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__viewContainer': {
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
|
||||
{
|
||||
flex: 1,
|
||||
},
|
||||
// Navigation row
|
||||
'& .react-calendar__navigation': {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
mb: 1,
|
||||
},
|
||||
// All nav buttons — large tap targets
|
||||
'& .react-calendar__navigation button': {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
minHeight: '40px',
|
||||
minWidth: '40px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 8px',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
'&:disabled': {
|
||||
opacity: 0.35,
|
||||
cursor: 'default',
|
||||
},
|
||||
},
|
||||
// Label button (month/year text) takes remaining space
|
||||
'& .react-calendar__navigation__label': {
|
||||
flex: 1,
|
||||
fontSize: '0.9rem',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.01em',
|
||||
},
|
||||
// Prev/next arrow buttons — slightly larger icon feel
|
||||
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
|
||||
{
|
||||
fontSize: '1.75rem',
|
||||
},
|
||||
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
|
||||
{
|
||||
fontSize: '1.4rem',
|
||||
},
|
||||
// Weekday headers
|
||||
'& .react-calendar__month-view__weekdays__weekday': {
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
textAlign: 'center',
|
||||
padding: '4px 0',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays__weekday abbr': {
|
||||
textDecoration: 'none',
|
||||
},
|
||||
// All tiles — shared base
|
||||
'& .react-calendar__tile': {
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
color: 'var(--joy-palette-text-primary)',
|
||||
fontFamily: 'var(--joy-fontFamily-body)',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
transition: 'background 0.15s',
|
||||
'&:hover': {
|
||||
background: 'var(--joy-palette-neutral-softBg)',
|
||||
},
|
||||
},
|
||||
// Day tiles only — circular
|
||||
'& .react-calendar__month-view__days .react-calendar__tile': {
|
||||
aspectRatio: '1',
|
||||
borderRadius: '50%',
|
||||
},
|
||||
// Month tiles (year view) — pill shape, no huge circle
|
||||
'& .react-calendar__year-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Year tiles (decade view) — pill shape
|
||||
'& .react-calendar__decade-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
// Century tiles — pill shape
|
||||
'& .react-calendar__century-view .react-calendar__tile': {
|
||||
borderRadius: '8px',
|
||||
padding: '10px 4px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
'& .react-calendar__tile--now': {
|
||||
border:
|
||||
'1.5px solid var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidBg) !important',
|
||||
fontWeight: 700,
|
||||
background: 'none !important',
|
||||
},
|
||||
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
|
||||
{
|
||||
background: 'var(--joy-palette-primary-solidBg) !important',
|
||||
color: 'var(--joy-palette-primary-solidColor) !important',
|
||||
fontWeight: 700,
|
||||
},
|
||||
'& .react-calendar__month-view__days__day--neighboringMonth': {
|
||||
color: 'var(--joy-palette-text-tertiary)',
|
||||
},
|
||||
'& .react-calendar__month-view__days': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
'& .react-calendar__month-view__weekdays': {
|
||||
display: 'grid !important',
|
||||
gridTemplateColumns: 'repeat(7, 1fr) !important',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Calendar
|
||||
value={
|
||||
localDueDateOnly
|
||||
? new Date(`${localDueDateOnly}T00:00:00`)
|
||||
: null
|
||||
}
|
||||
calendarType={calendarType}
|
||||
onChange={handleCalendarChange}
|
||||
formatShortWeekday={(locale, date) =>
|
||||
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
|
||||
}
|
||||
formatMonth={(locale, date) =>
|
||||
[
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec',
|
||||
][date.getMonth()]
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
mt: 0.5,
|
||||
color: 'text.tertiary',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Custom time
|
||||
</Typography>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={localUseCustomTime ? localDueTime || '' : ''}
|
||||
disabled={!localDueDateOnly}
|
||||
onChange={handleLocalTimeInputChange}
|
||||
sx={{ maxWidth: 200, mb: 1 }}
|
||||
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={!localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(false)}
|
||||
>
|
||||
Anytime
|
||||
</Button>
|
||||
<Button
|
||||
size='sm'
|
||||
variant={localUseCustomTime ? 'soft' : 'plain'}
|
||||
color='neutral'
|
||||
disabled={!localDueDateOnly}
|
||||
onClick={() => setLocalUseCustomTime(true)}
|
||||
>
|
||||
Specific time
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DueDatePickerField
|
||||
48
src/views/components/LabelsPickerField.jsx
Normal file
48
src/views/components/LabelsPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Label } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const LabelsPickerField = ({
|
||||
values = [],
|
||||
onChange,
|
||||
onClear,
|
||||
labels = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = labels.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
}))
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
multiple
|
||||
values={values}
|
||||
onValuesChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Labels'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<Label
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
if (isEmpty) return 'Labels'
|
||||
if (selectedItems.length === 1) return selectedItems[0].name
|
||||
return `${selectedItems.length} labels`
|
||||
}}
|
||||
menuMinWidth={220}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default LabelsPickerField
|
||||
@@ -31,6 +31,7 @@ import { version } from '../../../package.json'
|
||||
import UserProfileAvatar from '../../components/UserProfileAvatar'
|
||||
import { useLocalization } from '../../contexts/LocalizationContext'
|
||||
import NavBarLink from './NavBarLink'
|
||||
import SyncStatusIndicator from './SyncStatusIndicator'
|
||||
|
||||
import { SafeArea } from 'capacitor-plugin-safe-area'
|
||||
import Z_INDEX from '../../constants/zIndex'
|
||||
@@ -199,6 +200,7 @@ const NavBar = () => {
|
||||
{getMenuIcon()}
|
||||
<Box className='flex-1' />
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SyncStatusIndicator />
|
||||
<UserProfileAvatar />
|
||||
{/* <ThemeToggleButton /> */}
|
||||
</Box>
|
||||
|
||||
@@ -6,17 +6,47 @@ import { networkManager } from '../../hooks/NetworkManager'
|
||||
|
||||
const NetworkBanner = () => {
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineReason, setOfflineReason] = useState(
|
||||
networkManager.offlineReason,
|
||||
)
|
||||
const [isBannerVisible, setIsBannerVisible] = useState(
|
||||
!networkManager.isOnline,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const handleNetworkChange = isOnline => {
|
||||
setIsOnline(isOnline)
|
||||
setOfflineReason(networkManager.offlineReason)
|
||||
|
||||
if (!isOnline) {
|
||||
setIsBannerVisible(true)
|
||||
}
|
||||
}
|
||||
|
||||
networkManager.registerNetworkListener(handleNetworkChange)
|
||||
return () => networkManager.unregisterNetworkListener(handleNetworkChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOnline || !isBannerVisible) {
|
||||
return
|
||||
}
|
||||
|
||||
const timerId = setTimeout(() => {
|
||||
setIsBannerVisible(false)
|
||||
}, 5000)
|
||||
|
||||
return () => clearTimeout(timerId)
|
||||
}, [isOnline, isBannerVisible])
|
||||
|
||||
const message =
|
||||
offlineReason === 'server'
|
||||
? 'Server unreachable. Changes will sync when connection is restored.'
|
||||
: 'No internet connection. Some features may not be available.'
|
||||
|
||||
return (
|
||||
<Box sx={{}}>
|
||||
{!isOnline && (
|
||||
{!isOnline && isBannerVisible && (
|
||||
<Alert
|
||||
variant='soft'
|
||||
color='warning'
|
||||
@@ -36,7 +66,7 @@ const NetworkBanner = () => {
|
||||
}}
|
||||
startDecorator={<WifiOff />}
|
||||
>
|
||||
You are currently offline. Some features may not be available.
|
||||
{message}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
160
src/views/components/NotificationPickerField.jsx
Normal file
160
src/views/components/NotificationPickerField.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Close, NotificationsNone } from '@mui/icons-material'
|
||||
import { Box, Button, IconButton, Typography } from '@mui/joy'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import NotificationTemplate from '../../components/NotificationTemplate'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const getDisplayLabel = templates => {
|
||||
if (!templates || templates.length === 0) return 'Remind'
|
||||
const count = templates.length
|
||||
if (count === 1) {
|
||||
const n = templates[0]
|
||||
const numericValue = Number(n.value)
|
||||
if (numericValue === 0) return 'On due date'
|
||||
const unitName =
|
||||
n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
|
||||
const absValue = Math.abs(numericValue)
|
||||
const plural = absValue !== 1 ? 's' : ''
|
||||
return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
|
||||
}
|
||||
return `${count} reminders`
|
||||
}
|
||||
|
||||
const NotificationPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const latestTemplatesRef = useRef(value?.templates || [])
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
latestTemplatesRef.current = value?.templates || []
|
||||
}
|
||||
}, [isOpen, value])
|
||||
|
||||
const templates = value?.templates || []
|
||||
const hasNotifications = templates.length > 0
|
||||
const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
|
||||
const displayLabel = getDisplayLabel(templates)
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({ ...value, templates: latestTemplatesRef.current })
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const footer = (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasNotifications && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove all
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasNotifications ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<NotificationsNone sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 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',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasNotifications && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
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>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Reminders'
|
||||
footer={footer}
|
||||
>
|
||||
<NotificationTemplate
|
||||
value={value}
|
||||
onChange={({ notifications }) => {
|
||||
latestTemplatesRef.current = notifications
|
||||
}}
|
||||
showTimeline
|
||||
/>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationPickerField
|
||||
206
src/views/components/PendingBadge.jsx
Normal file
206
src/views/components/PendingBadge.jsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { Close, CloudSync } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
|
||||
const LABELS = {
|
||||
complete_chore: 'Complete pending',
|
||||
skip_chore: 'Skip pending',
|
||||
update_chore: 'Update pending',
|
||||
create_chore: 'Create pending',
|
||||
delete_chore: 'Delete pending',
|
||||
update_chore_history: 'Edit history pending',
|
||||
delete_chore_history: 'Delete history pending',
|
||||
reschedule_chore: 'Reschedule pending',
|
||||
archive_chore: 'Archive pending',
|
||||
unarchive_chore: 'Restore pending',
|
||||
start_chore: 'Start pending',
|
||||
pause_chore: 'Pause pending',
|
||||
}
|
||||
|
||||
const formatCommandLabel = commandType => {
|
||||
return (
|
||||
LABELS[commandType] ||
|
||||
commandType
|
||||
?.replace(/_/g, ' ')
|
||||
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
|
||||
'Pending action'
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBadge({ commands, size = 'sm', sx = {} }) {
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
const queryClient = useQueryClient()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [cancelingIds, setCancelingIds] = useState({})
|
||||
const [isCancelingAll, setIsCancelingAll] = useState(false)
|
||||
|
||||
const pendingSyncLabel = `${commands?.length || 0} pending action${commands?.length === 1 ? '' : 's'} to sync`
|
||||
|
||||
if (!commands || commands.length === 0) return null
|
||||
|
||||
const stopEvent = e => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
const invalidatePending = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['chores'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['choreHistory'] })
|
||||
}
|
||||
|
||||
const handleUndo = async (e, cmdId) => {
|
||||
e.stopPropagation()
|
||||
setCancelingIds(prev => ({ ...prev, [cmdId]: true }))
|
||||
|
||||
try {
|
||||
await commandQueue.cancel(cmdId)
|
||||
await invalidatePending()
|
||||
} finally {
|
||||
setCancelingIds(prev => {
|
||||
const next = { ...prev }
|
||||
delete next[cmdId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelAll = async e => {
|
||||
e.stopPropagation()
|
||||
if (commands.length === 0) return
|
||||
|
||||
setIsCancelingAll(true)
|
||||
try {
|
||||
await Promise.all(commands.map(cmd => commandQueue.cancel(cmd.id)))
|
||||
await invalidatePending()
|
||||
setIsOpen(false)
|
||||
} finally {
|
||||
setIsCancelingAll(false)
|
||||
setCancelingIds({})
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpen = e => {
|
||||
e.stopPropagation()
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const handleClose = e => {
|
||||
if (e?.stopPropagation) {
|
||||
e.stopPropagation()
|
||||
}
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isXs = size === 'xs'
|
||||
|
||||
return (
|
||||
<Box data-no-chore-nav='true' sx={{ mt: isXs ? 0 : 0.5, ...sx }}>
|
||||
<IconButton
|
||||
variant='soft'
|
||||
color='warning'
|
||||
size='sm'
|
||||
onClick={handleOpen}
|
||||
onMouseDown={stopEvent}
|
||||
onPointerDown={stopEvent}
|
||||
aria-label={pendingSyncLabel}
|
||||
title={pendingSyncLabel}
|
||||
sx={{
|
||||
borderRadius: '50%',
|
||||
...(isXs && {
|
||||
width: 18,
|
||||
height: 18,
|
||||
minWidth: 18,
|
||||
minHeight: 18,
|
||||
p: 0.25,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* <Badge
|
||||
badgeContent={commands.length}
|
||||
size='sm'
|
||||
color='warning'
|
||||
sx={{
|
||||
'& .MuiBadge-badge': { fontSize: 10, minWidth: 16, height: 16 },
|
||||
}}
|
||||
> */}
|
||||
<CloudSync sx={{ fontSize: isXs ? 14 : 16 }} />
|
||||
{/* </Badge> */}
|
||||
</IconButton>
|
||||
|
||||
<ResponsiveModal open={isOpen} onClose={handleClose} size='sm'>
|
||||
<Typography level='title-lg' mb={0.5}>
|
||||
Pending actions
|
||||
</Typography>
|
||||
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 1.5 }}>
|
||||
{commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
|
||||
synced.
|
||||
</Typography>
|
||||
|
||||
<List sx={{ '--List-gap': '8px', p: 0, mb: 1 }}>
|
||||
{commands.map(cmd => (
|
||||
<ListItem
|
||||
key={cmd.id}
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
p: 1,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
borderRadius: 'md',
|
||||
}}
|
||||
>
|
||||
<ListItemContent>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 600 }}>
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
|
||||
{new Date(cmd.createdAt).toLocaleString()}
|
||||
</Typography>
|
||||
</ListItemContent>
|
||||
|
||||
<IconButton
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='sm'
|
||||
onClick={e => handleUndo(e, cmd.id)}
|
||||
disabled={Boolean(cancelingIds[cmd.id]) || isCancelingAll}
|
||||
>
|
||||
<Close sx={{ fontSize: 14 }} />
|
||||
</IconButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||
<Button variant='outlined' onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
color='danger'
|
||||
onClick={handleCancelAll}
|
||||
loading={isCancelingAll}
|
||||
disabled={commands.length === 0}
|
||||
>
|
||||
Cancel all
|
||||
</Button>
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default PendingBadge
|
||||
74
src/views/components/PriorityPickerField.jsx
Normal file
74
src/views/components/PriorityPickerField.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Flag } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const defaultPriorityColors = {
|
||||
0: '#9CA3AF',
|
||||
1: '#EF4444',
|
||||
2: '#F97316',
|
||||
3: '#FBBF24',
|
||||
4: '#3B82F6',
|
||||
}
|
||||
|
||||
const defaultPriorityLabels = {
|
||||
0: 'No Priority',
|
||||
1: 'P1',
|
||||
2: 'P2',
|
||||
3: 'P3',
|
||||
4: 'P4',
|
||||
}
|
||||
|
||||
const PriorityPickerField = ({
|
||||
value = 0,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
priorityColors = defaultPriorityColors,
|
||||
priorityLabels = defaultPriorityLabels,
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const options = [1, 2, 3, 4].map(priorityOption => ({
|
||||
id: priorityOption,
|
||||
label: priorityLabels[priorityOption],
|
||||
color: priorityColors[priorityOption],
|
||||
}))
|
||||
|
||||
// Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
size={size}
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.label}
|
||||
getItemColor={item => item.color}
|
||||
getTriggerText={({ selectedItems, isEmpty }) => {
|
||||
// For priority 0 (no priority), show empty string (icon only)
|
||||
if (value === 0 || isEmpty) return 'Priority'
|
||||
return selectedItems[0]?.label || ''
|
||||
}}
|
||||
renderTriggerIcon={({ selectedItems, isEmpty }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
|
||||
fontSize: '20px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
renderItemStart={({ item }) => (
|
||||
<Flag
|
||||
sx={{
|
||||
color: item.color,
|
||||
fontSize: '18px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
menuMinWidth={180}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default PriorityPickerField
|
||||
48
src/views/components/ProjectPickerField.jsx
Normal file
48
src/views/components/ProjectPickerField.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { FolderOpen } from '@mui/icons-material'
|
||||
import BaseOptionPicker from './BaseOptionPicker'
|
||||
|
||||
const ProjectPickerField = ({
|
||||
value = 'default',
|
||||
onChange,
|
||||
onClear,
|
||||
projects = [],
|
||||
emptyDisplay = 'icon-text',
|
||||
}) => {
|
||||
const options = [
|
||||
{ id: 'default', name: 'Default Project', color: '#9CA3AF' },
|
||||
...projects.map(project => ({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
color: project.color,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<BaseOptionPicker
|
||||
items={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
emptyDisplay={emptyDisplay}
|
||||
emptyLabel='Project'
|
||||
getItemValue={item => item.id}
|
||||
getItemLabel={item => item.name}
|
||||
getItemColor={item => item.color}
|
||||
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
|
||||
renderItemStart={({ item }) => (
|
||||
<FolderOpen
|
||||
sx={{
|
||||
fontSize: '18px',
|
||||
color: item.color || 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
getTriggerText={({ selectedItems, isEmpty }) =>
|
||||
isEmpty ? 'Project' : selectedItems[0].name
|
||||
}
|
||||
menuMinWidth={240}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProjectPickerField
|
||||
639
src/views/components/RepeatPickerField.jsx
Normal file
639
src/views/components/RepeatPickerField.jsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { Close, Repeat } from '@mui/icons-material'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
IconButton,
|
||||
Input,
|
||||
List,
|
||||
ListItem,
|
||||
Radio,
|
||||
RadioGroup,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import moment from 'moment'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
|
||||
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
|
||||
|
||||
const FREQUENCY_TYPES = [
|
||||
'daily',
|
||||
'weekly',
|
||||
'monthly',
|
||||
'yearly',
|
||||
'adaptive',
|
||||
'custom',
|
||||
]
|
||||
const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
|
||||
|
||||
const DAYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
|
||||
const MONTHS = [
|
||||
'january',
|
||||
'february',
|
||||
'march',
|
||||
'april',
|
||||
'may',
|
||||
'june',
|
||||
'july',
|
||||
'august',
|
||||
'september',
|
||||
'october',
|
||||
'november',
|
||||
'december',
|
||||
]
|
||||
|
||||
const OCCURRENCE_OPTIONS = [
|
||||
{ value: 1, label: '1st' },
|
||||
{ value: 2, label: '2nd' },
|
||||
{ value: 3, label: '3rd' },
|
||||
{ value: 4, label: '4th' },
|
||||
{ value: -1, label: 'Last' },
|
||||
]
|
||||
|
||||
const defaultMetadata = () => ({
|
||||
unit: 'days',
|
||||
time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
|
||||
const initLocalState = value => {
|
||||
if (!value) {
|
||||
return {
|
||||
frequencyType: 'daily',
|
||||
frequency: 1,
|
||||
frequencyMetadata: defaultMetadata(),
|
||||
}
|
||||
}
|
||||
|
||||
let { frequencyType, frequency, frequencyMetadata } = value
|
||||
|
||||
// Normalize parser output: interval/1/days → daily, etc.
|
||||
if (frequencyType === 'interval' && frequency === 1) {
|
||||
const unitTypeMap = {
|
||||
days: 'daily',
|
||||
weeks: 'weekly',
|
||||
months: 'monthly',
|
||||
years: 'yearly',
|
||||
}
|
||||
frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
|
||||
}
|
||||
|
||||
return {
|
||||
frequencyType,
|
||||
frequency: frequency ?? 1,
|
||||
frequencyMetadata: {
|
||||
...defaultMetadata(),
|
||||
...frequencyMetadata,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const getDisplayType = frequencyType =>
|
||||
REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
|
||||
|
||||
// Shared section label
|
||||
const SectionLabel = ({ children }) => (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
color: 'text.tertiary',
|
||||
mb: 0.75,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Typography>
|
||||
)
|
||||
|
||||
// Shared time-of-day picker
|
||||
const TimeRow = ({ metadata, onUpdate }) => (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Time of day</SectionLabel>
|
||||
<Input
|
||||
type='time'
|
||||
size='sm'
|
||||
value={moment(metadata?.time).format('HH:mm')}
|
||||
onChange={e =>
|
||||
onUpdate({
|
||||
...metadata,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
time: moment(
|
||||
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
|
||||
).format(),
|
||||
})
|
||||
}
|
||||
sx={{ width: 120 }}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
const pillListSx = {
|
||||
'--List-gap': '8px',
|
||||
'--ListItem-radius': '20px',
|
||||
}
|
||||
|
||||
// Interval section
|
||||
const IntervalSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => (
|
||||
<Box>
|
||||
<SectionLabel>Repeat every</SectionLabel>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
|
||||
>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e =>
|
||||
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
|
||||
}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 999 } }}
|
||||
/>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{['days', 'weeks', 'months', 'years'].map(unit => (
|
||||
<ListItem key={unit}>
|
||||
<Checkbox
|
||||
checked={frequencyMetadata?.unit === unit}
|
||||
onClick={() =>
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
|
||||
}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
|
||||
// Days of week section
|
||||
const DaysOfWeekSection = ({
|
||||
frequencyMetadata,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedDays = frequencyMetadata?.days || []
|
||||
const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
|
||||
const selectedOccurrences = frequencyMetadata?.occurrences || []
|
||||
|
||||
const toggleDay = day => {
|
||||
const next = selectedDays.includes(day)
|
||||
? selectedDays.filter(d => d !== day)
|
||||
: [...selectedDays, day]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
|
||||
}
|
||||
|
||||
const toggleOccurrence = val => {
|
||||
const next = selectedOccurrences.includes(val)
|
||||
? selectedOccurrences.filter(v => v !== val)
|
||||
: [...selectedOccurrences, val]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Days</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{DAYS.map(day => (
|
||||
<ListItem key={day}>
|
||||
<Checkbox
|
||||
checked={selectedDays.includes(day)}
|
||||
onClick={() => toggleDay(day)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<SectionLabel>Pattern</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={weekPattern}
|
||||
onChange={e =>
|
||||
onFrequencyMetadataUpdate({
|
||||
...frequencyMetadata,
|
||||
weekPattern: e.target.value,
|
||||
occurrences:
|
||||
e.target.value === 'every_week' ? [] : selectedOccurrences,
|
||||
})
|
||||
}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ value: 'every_week', label: 'Every week' },
|
||||
{ value: 'week_of_month', label: 'Specific weeks' },
|
||||
].map(opt => (
|
||||
<Radio
|
||||
key={opt.value}
|
||||
value={opt.value}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={opt.label}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
{weekPattern === 'week_of_month' && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<SectionLabel>Occurrences</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{OCCURRENCE_OPTIONS.map(opt => (
|
||||
<ListItem key={opt.value}>
|
||||
<Checkbox
|
||||
checked={selectedOccurrences.includes(opt.value)}
|
||||
onClick={() => toggleOccurrence(opt.value)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={opt.label}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
// Day of month section
|
||||
const DayOfMonthSection = ({
|
||||
frequency,
|
||||
frequencyMetadata,
|
||||
onFrequencyUpdate,
|
||||
onFrequencyMetadataUpdate,
|
||||
}) => {
|
||||
const selectedMonths = frequencyMetadata?.months || []
|
||||
|
||||
const toggleMonth = month => {
|
||||
const next = selectedMonths.includes(month)
|
||||
? selectedMonths.filter(m => m !== month)
|
||||
: [...selectedMonths, month]
|
||||
onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<SectionLabel>Months</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{MONTHS.map(month => (
|
||||
<ListItem key={month}>
|
||||
<Checkbox
|
||||
checked={selectedMonths.includes(month)}
|
||||
onClick={() => toggleMonth(month)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
|
||||
<SectionLabel>Day of month</SectionLabel>
|
||||
<Input
|
||||
type='number'
|
||||
size='sm'
|
||||
value={frequency}
|
||||
onChange={e => {
|
||||
const v = Math.min(
|
||||
31,
|
||||
Math.max(1, parseInt(e.target.value, 10) || 1),
|
||||
)
|
||||
onFrequencyUpdate(v)
|
||||
}}
|
||||
sx={{ width: 72 }}
|
||||
slotProps={{ input: { min: 1, max: 31 } }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<TimeRow
|
||||
metadata={frequencyMetadata}
|
||||
onUpdate={onFrequencyMetadataUpdate}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const RepeatPickerField = ({
|
||||
value,
|
||||
onChange,
|
||||
onClear,
|
||||
emptyDisplay = 'icon-text',
|
||||
size = 'sm',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localFrequencyType, setLocalFrequencyType] = useState('daily')
|
||||
const [localFrequency, setLocalFrequency] = useState(1)
|
||||
const [localFrequencyMetadata, setLocalFrequencyMetadata] =
|
||||
useState(defaultMetadata)
|
||||
const { ResponsiveModal } = useResponsiveModal()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const init = initLocalState(value)
|
||||
setLocalFrequencyType(init.frequencyType)
|
||||
setLocalFrequency(init.frequency)
|
||||
setLocalFrequencyMetadata(init.frequencyMetadata)
|
||||
}, [isOpen, value])
|
||||
|
||||
const hasRepeat = Boolean(value)
|
||||
const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
|
||||
const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
|
||||
const displayType = getDisplayType(localFrequencyType)
|
||||
|
||||
const handleTypeSelect = type => {
|
||||
if (type === 'custom') {
|
||||
setLocalFrequencyType('interval')
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
|
||||
} else {
|
||||
setLocalFrequencyType(type)
|
||||
setLocalFrequency(1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubTypeSelect = newType => {
|
||||
setLocalFrequencyType(newType)
|
||||
if (newType === 'interval') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
|
||||
} else if (newType === 'days_of_the_week') {
|
||||
setLocalFrequencyMetadata(prev => ({
|
||||
...prev,
|
||||
days: [],
|
||||
weekPattern: 'every_week',
|
||||
occurrences: [],
|
||||
}))
|
||||
} else if (newType === 'day_of_the_month') {
|
||||
setLocalFrequency(1)
|
||||
setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onChange({
|
||||
frequencyType: localFrequencyType,
|
||||
frequency: localFrequency,
|
||||
frequencyMetadata: localFrequencyMetadata,
|
||||
})
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
|
||||
<Button
|
||||
size={size}
|
||||
variant={hasRepeat ? 'soft' : 'outlined'}
|
||||
color='neutral'
|
||||
onClick={() => setIsOpen(true)}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
borderRadius: '128px',
|
||||
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',
|
||||
}}
|
||||
>
|
||||
<Repeat sx={{ fontSize: '20px' }} />
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: shouldShowLabel ? 220 : 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',
|
||||
}}
|
||||
>
|
||||
{displayLabel}
|
||||
</Typography>
|
||||
</Button>
|
||||
|
||||
{hasRepeat && onClear && (
|
||||
<IconButton
|
||||
size='sm'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
}}
|
||||
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>
|
||||
|
||||
<ResponsiveModal
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title='Repeat Schedule'
|
||||
footer={
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
|
||||
{hasRepeat && (
|
||||
<Button
|
||||
variant='plain'
|
||||
color='danger'
|
||||
size='lg'
|
||||
onClick={() => {
|
||||
onClear?.()
|
||||
setIsOpen(false)
|
||||
}}
|
||||
sx={{ mr: 'auto' }}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant='outlined'
|
||||
color='neutral'
|
||||
size='lg'
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='solid'
|
||||
color='primary'
|
||||
size='lg'
|
||||
onClick={handleSave}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
{/* Frequency type selector */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||
<Box>
|
||||
<SectionLabel>Frequency</SectionLabel>
|
||||
<List orientation='horizontal' wrap sx={pillListSx}>
|
||||
{FREQUENCY_TYPES.map(type => (
|
||||
<ListItem key={type}>
|
||||
<Checkbox
|
||||
checked={displayType === type}
|
||||
onClick={() => handleTypeSelect(type)}
|
||||
overlay
|
||||
disableIcon
|
||||
variant='soft'
|
||||
label={type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
{/* Custom sub-type + detail panel */}
|
||||
{displayType === 'custom' && (
|
||||
<>
|
||||
<Box>
|
||||
<SectionLabel>Schedule type</SectionLabel>
|
||||
<RadioGroup
|
||||
orientation='horizontal'
|
||||
value={localFrequencyType}
|
||||
onChange={e => handleSubTypeSelect(e.target.value)}
|
||||
sx={{
|
||||
padding: '3px',
|
||||
borderRadius: '10px',
|
||||
bgcolor: 'neutral.softBg',
|
||||
'--RadioGroup-gap': '3px',
|
||||
'--Radio-actionRadius': '7px',
|
||||
display: 'inline-flex',
|
||||
}}
|
||||
>
|
||||
{REPEAT_ON_TYPE.map(type => (
|
||||
<Radio
|
||||
key={type}
|
||||
value={type}
|
||||
color='neutral'
|
||||
disableIcon
|
||||
label={type
|
||||
.split('_')
|
||||
.map((w, i, arr) =>
|
||||
i === 0 || i === arr.length - 1
|
||||
? w.charAt(0).toUpperCase() + w.slice(1)
|
||||
: w,
|
||||
)
|
||||
.join(' ')}
|
||||
variant='plain'
|
||||
sx={{ px: 1.5, py: 0.5 }}
|
||||
slotProps={{
|
||||
action: ({ checked }) => ({
|
||||
sx: checked
|
||||
? {
|
||||
bgcolor: 'background.surface',
|
||||
boxShadow: 'sm',
|
||||
'&:hover': { bgcolor: 'background.surface' },
|
||||
}
|
||||
: {},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{localFrequencyType === 'interval' && (
|
||||
<IntervalSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'days_of_the_week' && (
|
||||
<DaysOfWeekSection
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
{localFrequencyType === 'day_of_the_month' && (
|
||||
<DayOfMonthSection
|
||||
frequency={localFrequency}
|
||||
frequencyMetadata={localFrequencyMetadata}
|
||||
onFrequencyUpdate={setLocalFrequency}
|
||||
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</ResponsiveModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default RepeatPickerField
|
||||
@@ -1,3 +1,18 @@
|
||||
:root,
|
||||
[data-joy-color-scheme='light'] {
|
||||
--highlight-date-color: #b45309;
|
||||
--highlight-repeat-color: #15803d;
|
||||
--highlight-label-color: #1d4ed8;
|
||||
--highlight-priority-color: #be123c;
|
||||
}
|
||||
|
||||
[data-joy-color-scheme='dark'] {
|
||||
--highlight-date-color: #fca5a5;
|
||||
--highlight-repeat-color: #86efac;
|
||||
--highlight-label-color: #93c5fd;
|
||||
--highlight-priority-color: #f9a8d4;
|
||||
}
|
||||
|
||||
.smart-task-display {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
@@ -11,27 +26,36 @@
|
||||
white-space: pre-wrap;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.smart-task-common {
|
||||
font-size: 1.2em;
|
||||
line-height: 1.2em;
|
||||
font-family: inherit;
|
||||
caret-color: #f08080;
|
||||
caret-color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-date {
|
||||
color: #f08080;
|
||||
color: var(--highlight-date-color);
|
||||
}
|
||||
|
||||
.highlight-repeat {
|
||||
color: #90ee90;
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-label {
|
||||
color: #add8e6;
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.highlight-priority {
|
||||
color: #ffb6c1;
|
||||
color: var(--highlight-priority-color);
|
||||
}
|
||||
|
||||
.highlight-assignee {
|
||||
color: var(--highlight-repeat-color);
|
||||
}
|
||||
|
||||
.highlight-points {
|
||||
color: var(--highlight-label-color);
|
||||
}
|
||||
|
||||
.task-input {
|
||||
@@ -39,4 +63,6 @@
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className='task-input overflow-auto rounded border'
|
||||
style={{ minHeight: '2.4em' }}
|
||||
>
|
||||
<div className='task-input' style={{ minHeight: '2.4em' }}>
|
||||
<textarea
|
||||
ref={titleInputRef}
|
||||
autoFocus={autoFocus}
|
||||
|
||||
572
src/views/components/SyncStatusIndicator.jsx
Normal file
572
src/views/components/SyncStatusIndicator.jsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import {
|
||||
CheckCircleOutline,
|
||||
ClearAll,
|
||||
CloudDone,
|
||||
CloudQueue,
|
||||
CloudSync,
|
||||
Refresh,
|
||||
WifiOff,
|
||||
} from '@mui/icons-material'
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Divider,
|
||||
Dropdown,
|
||||
ListItemDecorator,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItem,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import {
|
||||
PENDING_POLL_MS,
|
||||
SERVER_PROBE_MS,
|
||||
} from '../../hooks/useSyncOnReconnect'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import {
|
||||
isOfflineFeatureEnabled,
|
||||
subscribeToOfflineFeature,
|
||||
} from '../../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../../utils/SyncEngine'
|
||||
|
||||
const COMMAND_LABELS = {
|
||||
create_chore: 'Create chore',
|
||||
update_chore: 'Update chore',
|
||||
update_chore_history: 'Edit history',
|
||||
complete_chore: 'Complete chore',
|
||||
skip_chore: 'Skip chore',
|
||||
start_chore: 'Start chore',
|
||||
pause_chore: 'Pause chore',
|
||||
delete_chore: 'Delete chore',
|
||||
delete_chore_history: 'Delete history',
|
||||
reschedule_chore: 'Reschedule chore',
|
||||
archive_chore: 'Archive chore',
|
||||
unarchive_chore: 'Restore chore',
|
||||
}
|
||||
|
||||
const formatCommandLabel = commandType => {
|
||||
return (
|
||||
COMMAND_LABELS[commandType] ||
|
||||
commandType
|
||||
?.replace(/_/g, ' ')
|
||||
?.replace(/\b\w/g, letter => letter.toUpperCase()) ||
|
||||
'Pending action'
|
||||
)
|
||||
}
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
const [failedCommands, setFailedCommands] = useState([])
|
||||
const [syncState, setSyncState] = useState({
|
||||
syncing: false,
|
||||
lastSync: null,
|
||||
error: null,
|
||||
})
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
|
||||
const [offlineReason, setOfflineReason] = useState(networkManager.offlineReason)
|
||||
|
||||
// Mirror the actual intervals used by useSyncOnReconnect so the countdown is accurate
|
||||
const retryInterval = useMemo(
|
||||
() =>
|
||||
!isOnline && offlineReason === 'server'
|
||||
? SERVER_PROBE_MS / 1000
|
||||
: PENDING_POLL_MS / 1000,
|
||||
[isOnline, offlineReason],
|
||||
)
|
||||
|
||||
const [retryIn, setRetryIn] = useState(retryInterval)
|
||||
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeToOfflineFeature(setOfflineFeatureEnabled)
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = syncEngine.onSyncStateChange(state => {
|
||||
setSyncState(prev => ({ ...prev, ...state }))
|
||||
})
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncState.syncing) {
|
||||
setRetryIn(retryInterval)
|
||||
}
|
||||
}, [syncState.syncing, syncState.lastSync, retryInterval])
|
||||
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(online => {
|
||||
setIsOnline(online)
|
||||
setOfflineReason(networkManager.offlineReason)
|
||||
if (!online) setOfflineSince(networkManager.offlineSince)
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Run countdown both when online (pending commands) and when server-unreachable (probe interval)
|
||||
if (syncState.syncing) return
|
||||
if (isOnline && pendingCommands.length === 0) return
|
||||
if (!isOnline && offlineReason === 'device') return
|
||||
const interval = setInterval(() => {
|
||||
setRetryIn(prev => {
|
||||
if (prev <= 1) {
|
||||
console.debug('[SyncStatusIndicator] Retry timer fired', {
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncing: syncState.syncing,
|
||||
lastSync: syncState.lastSync,
|
||||
error: syncState.error,
|
||||
pendingCommands: pendingCommands.length,
|
||||
})
|
||||
return retryInterval
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncState.syncing,
|
||||
syncState.lastSync,
|
||||
syncState.error,
|
||||
pendingCommands.length,
|
||||
retryInterval,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
try {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
} catch {
|
||||
// OfflineDB may not be initialized yet
|
||||
}
|
||||
}
|
||||
update()
|
||||
const interval = setInterval(update, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [syncState])
|
||||
|
||||
const refreshCommands = async () => {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
setPendingCommands(pending)
|
||||
setFailedCommands(failed)
|
||||
}
|
||||
|
||||
const handleForceSync = async () => {
|
||||
const didSync = await syncEngine.sync()
|
||||
if (didSync) queryClient.invalidateQueries()
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const handleDismissFailed = async id => {
|
||||
await commandQueue.cancel(id)
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const handleCancelAll = async () => {
|
||||
const [pending, failed] = await Promise.all([
|
||||
commandQueue.getPending(),
|
||||
commandQueue.getFailed(),
|
||||
])
|
||||
const allCommands = [...pending, ...failed]
|
||||
await Promise.all(allCommands.map(cmd => commandQueue.cancel(cmd.id)))
|
||||
await refreshCommands()
|
||||
}
|
||||
|
||||
const formatTime = timestamp => {
|
||||
if (!timestamp) return 'Never'
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (seconds < 10) return 'Just now'
|
||||
if (seconds < 60) return `${seconds}s ago`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const formatOfflineDuration = timestamp => {
|
||||
if (!timestamp) return ''
|
||||
const minutes = Math.floor((Date.now() - timestamp) / 60000)
|
||||
if (minutes < 1) return 'just now'
|
||||
if (minutes < 60) return `${minutes}m ago`
|
||||
return `${Math.floor(minutes / 60)}h ago`
|
||||
}
|
||||
|
||||
const groupedPending = Object.entries(
|
||||
pendingCommands.reduce((acc, cmd) => {
|
||||
acc[cmd.commandType] = (acc[cmd.commandType] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
)
|
||||
|
||||
const pendingCount = pendingCommands.length
|
||||
const failedCount = failedCommands.length
|
||||
const totalBadge = pendingCount + failedCount
|
||||
|
||||
const getStatusIcon = () => {
|
||||
if (syncState.syncing)
|
||||
return <CloudSync sx={{ fontSize: 20, color: 'primary.500' }} />
|
||||
if (!isOnline) return <WifiOff sx={{ fontSize: 20, color: 'danger.500' }} />
|
||||
if (failedCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'danger.400' }} />
|
||||
if (pendingCount > 0)
|
||||
return <CloudQueue sx={{ fontSize: 20, color: 'warning.500' }} />
|
||||
return <CloudDone sx={{ fontSize: 20, color: 'success.500' }} />
|
||||
}
|
||||
|
||||
if (!offlineFeatureEnabled) return null
|
||||
|
||||
return (
|
||||
<Dropdown>
|
||||
<MenuButton
|
||||
aria-label='Open sync and network status'
|
||||
variant='plain'
|
||||
sx={{
|
||||
p: 0.5,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{ display: 'flex', alignItems: 'center', position: 'relative' }}
|
||||
>
|
||||
{syncState.syncing && (
|
||||
<CircularProgress
|
||||
size='sm'
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
'--CircularProgress-size': '28px',
|
||||
'--CircularProgress-trackThickness': '2px',
|
||||
'--CircularProgress-progressThickness': '2px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{totalBadge > 0 ? (
|
||||
<Badge
|
||||
badgeContent={totalBadge}
|
||||
size='sm'
|
||||
color={failedCount > 0 ? 'danger' : 'warning'}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
fontSize: 9,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{getStatusIcon()}
|
||||
</Badge>
|
||||
) : (
|
||||
getStatusIcon()
|
||||
)}
|
||||
</Box>
|
||||
</MenuButton>
|
||||
|
||||
<Menu
|
||||
placement='bottom-end'
|
||||
sx={{
|
||||
minWidth: 280,
|
||||
p: 1,
|
||||
'--List-gap': '4px',
|
||||
boxShadow: 'var(--joy-shadow-lg)',
|
||||
border: '1px solid var(--joy-palette-divider)',
|
||||
borderRadius: 'var(--joy-radius-md)',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Sheet sx={{ p: 1.5, borderRadius: 'var(--joy-radius-sm)', mb: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOnline
|
||||
? 'var(--joy-palette-success-500)'
|
||||
: 'var(--joy-palette-danger-500)',
|
||||
}}
|
||||
/>
|
||||
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
|
||||
{isOnline ? 'Online' : 'Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
{syncState.syncing && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-primary-500)' }}
|
||||
>
|
||||
Syncing...
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Last sync: {formatTime(syncState.lastSync)}
|
||||
</Typography>
|
||||
|
||||
{!isOnline && offlineSince && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-400)', mt: 0.25 }}
|
||||
>
|
||||
Offline since {formatOfflineDuration(offlineSince)}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{syncState.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
Error: {syncState.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Sheet>
|
||||
|
||||
{/* Pending actions */}
|
||||
{groupedPending.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-text-secondary)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Pending ({pendingCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{groupedPending.map(([type, count]) => (
|
||||
<Box
|
||||
key={type}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 1,
|
||||
py: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
}}
|
||||
>
|
||||
<Typography level='body-sm'>
|
||||
{formatCommandLabel(type)}
|
||||
</Typography>
|
||||
<Chip size='sm' color='warning' variant='soft'>
|
||||
{count}
|
||||
</Chip>
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Failed actions */}
|
||||
{failedCommands.length > 0 && (
|
||||
<>
|
||||
<Box sx={{ px: 1, py: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
color: 'var(--joy-palette-danger-500)',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}
|
||||
>
|
||||
Failed ({failedCount})
|
||||
</Typography>
|
||||
</Box>
|
||||
{failedCommands.map(cmd => (
|
||||
<Box
|
||||
key={cmd.id}
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
mb: 0.5,
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
backgroundColor: 'var(--joy-palette-danger-softBg)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{
|
||||
color: 'var(--joy-palette-danger-700)',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{formatCommandLabel(cmd.commandType)}
|
||||
</Typography>
|
||||
<Button
|
||||
size='sm'
|
||||
variant='plain'
|
||||
color='danger'
|
||||
sx={{ fontSize: 11, py: 0, minHeight: 'unset', px: 0.5 }}
|
||||
onClick={() => handleDismissFailed(cmd.id)}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</Box>
|
||||
{cmd.error && (
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-danger-500)', mt: 0.25 }}
|
||||
>
|
||||
{cmd.error}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* All clear */}
|
||||
{pendingCount === 0 && failedCount === 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
px: 1,
|
||||
py: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
<CheckCircleOutline
|
||||
sx={{ fontSize: 16, color: 'var(--joy-palette-success-500)' }}
|
||||
/>
|
||||
<Typography
|
||||
level='body-sm'
|
||||
sx={{ color: 'var(--joy-palette-text-secondary)' }}
|
||||
>
|
||||
All changes synced
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Next retry / offline hint */}
|
||||
{isOnline && !syncState.syncing && pendingCount > 0 && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Next auto-sync in {retryIn}s
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && offlineReason === 'server' && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
{syncState.syncing
|
||||
? 'Checking server...'
|
||||
: `Retrying in ${retryIn}s`}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{!isOnline && offlineReason !== 'server' && (
|
||||
<Box sx={{ px: 1, pb: 0.5 }}>
|
||||
<Typography
|
||||
level='body-xs'
|
||||
sx={{ color: 'var(--joy-palette-text-tertiary)' }}
|
||||
>
|
||||
Will sync when back online
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 0.5 }} />
|
||||
|
||||
<MenuItem
|
||||
disabled={syncState.syncing || totalBadge === 0}
|
||||
onClick={handleCancelAll}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
<ClearAll sx={{ fontSize: 18 }} />
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
Cancel All
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
|
||||
{/* Sync Now — must be a MenuItem so Menu doesn't swallow the click */}
|
||||
<MenuItem
|
||||
disabled={syncState.syncing}
|
||||
onClick={handleForceSync}
|
||||
sx={{
|
||||
borderRadius: 'var(--joy-radius-sm)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'var(--joy-palette-neutral-softHoverBg)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ListItemDecorator>
|
||||
{syncState.syncing ? (
|
||||
<CircularProgress size='sm' />
|
||||
) : (
|
||||
<Refresh sx={{ fontSize: 18 }} />
|
||||
)}
|
||||
</ListItemDecorator>
|
||||
<Typography level='body-sm' sx={{ fontWeight: 500 }}>
|
||||
{syncState.syncing ? 'Syncing...' : 'Sync Now'}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
|
||||
export default SyncStatusIndicator
|
||||
Reference in New Issue
Block a user