diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..fb081ea
--- /dev/null
+++ b/.editorconfig
@@ -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
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 8087038..a49b178 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,9 +2,7 @@ name: Build validation
on:
push:
- branches: [ "main", "develop" ]
pull_request:
- branches: [ "main", "develop" ]
jobs:
build:
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d827280..b90cbe7 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -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.
diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle
index 1e82591..9f6e577 100644
--- a/android/app/capacitor.build.gradle
+++ b/android/app/capacitor.build.gradle
@@ -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')
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3d5b3c5..76aa59f 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -20,13 +20,20 @@
-
+
+
+
+
+
+
+
+
+
diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle
index 3ad1593..e49f972 100644
--- a/android/capacitor.settings.gradle
+++ b/android/capacitor.settings.gradle
@@ -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')
diff --git a/index.html b/index.html
index 241f561..93cec0b 100644
--- a/index.html
+++ b/index.html
@@ -1,12 +1,6 @@
-
-
+ com.apple.developer.nfc.readersession.formats
+
+ NDEF
+
aps-environment
development
com.apple.developer.applesignin
diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist
index 533cd70..5a58712 100644
--- a/ios/App/App/Info.plist
+++ b/ios/App/App/Info.plist
@@ -2,6 +2,8 @@
+ NFCReaderUsageDescription
+ Donetick uses NFC to read and write chore tags for quick task completion.
CFBundleDevelopmentRegion
en
CFBundleDisplayName
diff --git a/ios/App/Podfile b/ios/App/Podfile
index 9602381..e5d54c4 100644
--- a/ios/App/Podfile
+++ b/ios/App/Podfile
@@ -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'
diff --git a/ios/App/Podfile.lock b/ios/App/Podfile.lock
index c8fb8cb..2fef6fb 100644
--- a/ios/App/Podfile.lock
+++ b/ios/App/Podfile.lock
@@ -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
diff --git a/package-lock.json b/package-lock.json
index cd71ed0..c02d640 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 63a5185..6aed4fc 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/App.jsx b/src/App.jsx
index 5e023dd..d8ef454 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -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],
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js
index 32bd343..c8fdf21 100644
--- a/src/CapacitorListener.js
+++ b/src/CapacitorListener.js
@@ -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()
}
diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx
index 734fd0b..9f468fa 100644
--- a/src/components/NotificationTemplate.jsx
+++ b/src/components/NotificationTemplate.jsx
@@ -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',
diff --git a/src/hooks/NetworkManager.jsx b/src/hooks/NetworkManager.jsx
index d9c43e4..3a5894c 100644
--- a/src/hooks/NetworkManager.jsx
+++ b/src/hooks/NetworkManager.jsx
@@ -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)
}
diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.jsx
index 4154abb..6fcc744 100644
--- a/src/hooks/useAuth.jsx
+++ b/src/hooks/useAuth.jsx
@@ -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()
diff --git a/src/hooks/useFileUpload.js b/src/hooks/useFileUpload.js
new file mode 100644
index 0000000..1aba6f9
--- /dev/null
+++ b/src/hooks/useFileUpload.js
@@ -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) }
+}
diff --git a/src/hooks/usePendingCommands.js b/src/hooks/usePendingCommands.js
new file mode 100644
index 0000000..47df642
--- /dev/null
+++ b/src/hooks/usePendingCommands.js
@@ -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,
+ })
+}
diff --git a/src/hooks/useSyncOnReconnect.js b/src/hooks/useSyncOnReconnect.js
new file mode 100644
index 0000000..0c3220e
--- /dev/null
+++ b/src/hooks/useSyncOnReconnect.js
@@ -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])
+}
diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx
index c17bf8b..ec4883d 100644
--- a/src/queries/ChoreQueries.jsx
+++ b/src/queries/ChoreQueries.jsx
@@ -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])
diff --git a/src/queries/SubtaskQueries.jsx b/src/queries/SubtaskQueries.jsx
index 616b703..14f57ab 100644
--- a/src/queries/SubtaskQueries.jsx
+++ b/src/queries/SubtaskQueries.jsx
@@ -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
- }
- },
})
}
diff --git a/src/queries/TimeQueries.jsx b/src/queries/TimeQueries.jsx
index 009ea99..074b258 100644
--- a/src/queries/TimeQueries.jsx
+++ b/src/queries/TimeQueries.jsx
@@ -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])
diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx
index 160de07..588f1a2 100644
--- a/src/queries/UserQueries.jsx
+++ b/src/queries/UserQueries.jsx
@@ -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,
diff --git a/src/service/NFCWriter.jsx b/src/service/NFCWriter.jsx
index 91ac978..cc6f37a 100644
--- a/src/service/NFCWriter.jsx
+++ b/src/service/NFCWriter.jsx
@@ -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.')
diff --git a/src/utils/ApiClient.js b/src/utils/ApiClient.js
index 3da4e0a..d517c91 100644
--- a/src/utils/ApiClient.js
+++ b/src/utils/ApiClient.js
@@ -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
}
diff --git a/src/utils/CommandQueue.js b/src/utils/CommandQueue.js
new file mode 100644
index 0000000..f9f8794
--- /dev/null
+++ b/src/utils/CommandQueue.js
@@ -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()
diff --git a/src/utils/FeatureToggle.js b/src/utils/FeatureToggle.js
index 212a2df..3174a04 100644
--- a/src/utils/FeatureToggle.js
+++ b/src/utils/FeatureToggle.js
@@ -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} - 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,
}
diff --git a/src/utils/LocalStore.jsx b/src/utils/LocalStore.jsx
deleted file mode 100644
index 0573af7..0000000
--- a/src/utils/LocalStore.jsx
+++ /dev/null
@@ -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()
diff --git a/src/utils/OfflineDB.js b/src/utils/OfflineDB.js
new file mode 100644
index 0000000..24b8b68
--- /dev/null
+++ b/src/utils/OfflineDB.js
@@ -0,0 +1,1090 @@
+import { CapacitorSQLite } from '@capacitor-community/sqlite'
+import { Capacitor } from '@capacitor/core'
+import { isOfflineFeatureEnabled } from './OfflineFeatureToggle'
+
+const DB_NAME = 'donetick_offline'
+const DB_VERSION = 2
+const IDB_NAME = 'donetick_offline'
+const IDB_VERSION = 2
+
+// Cache platform detection
+let _isNative = null
+const isNative = () => {
+ if (_isNative === null) {
+ try {
+ _isNative = Capacitor.isNativePlatform()
+ } catch {
+ _isNative = false
+ }
+ }
+ return _isNative
+}
+
+// ── SQLite backend (iOS/Android) ──
+
+class SQLiteBackend {
+ constructor() {
+ this.db = null
+ this.initialized = false
+ this._initPromise = null
+ }
+
+ async init() {
+ if (this.initialized) return
+ // Return the in-flight promise if init is already underway (prevents double createConnection)
+ if (this._initPromise) return this._initPromise
+
+ this._initPromise = this._doInit().finally(() => {
+ this._initPromise = null
+ })
+ return this._initPromise
+ }
+
+ async _doInit() {
+ try {
+ this.db = await CapacitorSQLite.createConnection({
+ database: DB_NAME,
+ version: DB_VERSION,
+ encrypted: false,
+ mode: 'no-encryption',
+ })
+ } catch (err) {
+ // Connection already open (e.g. React StrictMode double-mount) — reuse it
+ if (!err?.message?.includes('already exists')) throw err
+ }
+ await CapacitorSQLite.open({ database: DB_NAME })
+
+ await CapacitorSQLite.execute({
+ database: DB_NAME,
+ statements: `
+ CREATE TABLE IF NOT EXISTS cached_chores (
+ id INTEGER PRIMARY KEY,
+ data TEXT NOT NULL,
+ sync_version INTEGER NOT NULL DEFAULT 0,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS command_queue (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ command_type TEXT NOT NULL,
+ entity_id TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ status TEXT NOT NULL DEFAULT 'pending',
+ error TEXT
+ );
+
+ CREATE TABLE IF NOT EXISTS sync_meta (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL
+ );
+
+ CREATE TABLE IF NOT EXISTS cached_history (
+ id INTEGER PRIMARY KEY,
+ chore_id INTEGER NOT NULL,
+ data TEXT NOT NULL,
+ performed_at INTEGER NOT NULL,
+ pending INTEGER NOT NULL DEFAULT 0,
+ cached_at INTEGER NOT NULL
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_history_chore_id ON cached_history(chore_id);
+ CREATE INDEX IF NOT EXISTS idx_history_performed_at ON cached_history(performed_at DESC);
+ CREATE INDEX IF NOT EXISTS idx_history_pending ON cached_history(pending);
+ `,
+ })
+
+ this.initialized = true
+ }
+
+ // ── Chore cache ──
+
+ async saveChores(chores) {
+ if (!chores.length) return
+
+ const statements = chores.map(chore => ({
+ statement:
+ 'INSERT OR REPLACE INTO cached_chores (id, data, sync_version, cached_at) VALUES (?, ?, ?, ?)',
+ values: [
+ chore.id,
+ JSON.stringify(chore),
+ chore.syncVersion || 0,
+ Date.now(),
+ ],
+ }))
+
+ await CapacitorSQLite.executeSet({
+ database: DB_NAME,
+ set: statements,
+ })
+ }
+
+ async getChores(includeArchive = false) {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT data FROM cached_chores',
+ values: [],
+ })
+ const chores = (result.values || []).map(row => JSON.parse(row.data))
+ if (includeArchive) {
+ return chores
+ }
+ return chores.filter(chore => chore.isActive !== false)
+ }
+
+ async getChore(id) {
+ const numericId = Number(id)
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT data FROM cached_chores WHERE id = ?',
+ values: [isNaN(numericId) ? id : numericId],
+ })
+ if (result.values && result.values.length > 0) {
+ return JSON.parse(result.values[0].data)
+ }
+ return null
+ }
+
+ async deleteChores(ids) {
+ if (!ids.length) return
+ const statements = ids.map(id => ({
+ statement: 'DELETE FROM cached_chores WHERE id = ?',
+ values: [id],
+ }))
+ await CapacitorSQLite.executeSet({
+ database: DB_NAME,
+ set: statements,
+ })
+ }
+
+ async clearChores() {
+ await CapacitorSQLite.execute({
+ database: DB_NAME,
+ statements: 'DELETE FROM cached_chores',
+ })
+ }
+
+ // ── History cache ──
+
+ async saveHistory(entries) {
+ if (!entries.length) return
+ // Delete pending entries for the affected chore IDs first
+ const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
+ if (choreIds.length) {
+ const placeholders = choreIds.map(() => '?').join(', ')
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: `DELETE FROM cached_history WHERE pending = 1 AND chore_id IN (${placeholders})`,
+ values: choreIds,
+ })
+ }
+ const statements = entries.map(entry => ({
+ statement:
+ 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
+ values: [
+ entry.id,
+ Number(entry.choreId),
+ JSON.stringify(entry),
+ new Date(entry.performedAt).getTime(),
+ 0,
+ Date.now(),
+ ],
+ }))
+ await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements })
+ }
+
+ async savePendingHistory(entry) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement:
+ 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
+ values: [
+ entry.id,
+ Number(entry.choreId),
+ JSON.stringify(entry),
+ new Date(entry.performedAt).getTime(),
+ 1,
+ Date.now(),
+ ],
+ })
+ }
+
+ async getHistoryByChore(choreId) {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement:
+ 'SELECT data FROM cached_history WHERE chore_id = ? ORDER BY performed_at DESC',
+ values: [Number(choreId)],
+ })
+ return (result.values || []).map(row => JSON.parse(row.data))
+ }
+
+ async getHistoryByDays(days) {
+ const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement:
+ since === 0
+ ? 'SELECT data FROM cached_history ORDER BY performed_at DESC'
+ : 'SELECT data FROM cached_history WHERE performed_at >= ? ORDER BY performed_at DESC',
+ values: since === 0 ? [] : [since],
+ })
+ return (result.values || []).map(row => JSON.parse(row.data))
+ }
+
+ async deleteHistory(ids) {
+ if (!ids.length) return
+ const statements = ids.map(id => ({
+ statement: 'DELETE FROM cached_history WHERE id = ?',
+ values: [id],
+ }))
+ await CapacitorSQLite.executeSet({ database: DB_NAME, set: statements })
+ }
+
+ async updateHistoryEntry(choreId, historyId, updates) {
+ const existing = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT data, pending FROM cached_history WHERE id = ?',
+ values: [historyId],
+ })
+
+ if (!existing.values?.length) return
+
+ const row = existing.values[0]
+ const current = JSON.parse(row.data)
+ const merged = {
+ ...current,
+ ...updates,
+ id: historyId,
+ choreId: Number(choreId),
+ }
+
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement:
+ 'INSERT OR REPLACE INTO cached_history (id, chore_id, data, performed_at, pending, cached_at) VALUES (?, ?, ?, ?, ?, ?)',
+ values: [
+ historyId,
+ Number(choreId),
+ JSON.stringify(merged),
+ new Date(merged.performedAt).getTime(),
+ row.pending || 0,
+ Date.now(),
+ ],
+ })
+ }
+
+ async deleteHistoryEntry(historyId) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: 'DELETE FROM cached_history WHERE id = ?',
+ values: [historyId],
+ })
+ }
+
+ async clearHistory() {
+ await CapacitorSQLite.execute({
+ database: DB_NAME,
+ statements: 'DELETE FROM cached_history',
+ })
+ }
+
+ // ── Command queue ──
+
+ async enqueueCommand(command) {
+ const result = await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: `INSERT INTO command_queue (command_type, entity_id, payload, created_at, status, error)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ values: [
+ command.commandType,
+ command.entityId,
+ command.payload,
+ command.createdAt,
+ command.status,
+ command.error,
+ ],
+ })
+ return result.changes?.lastId
+ }
+
+ async getCommands() {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT * FROM command_queue ORDER BY created_at ASC',
+ values: [],
+ })
+ return (result.values || []).map(row => ({
+ id: row.id,
+ commandType: row.command_type,
+ entityId: row.entity_id,
+ payload: row.payload,
+ createdAt: row.created_at,
+ status: row.status,
+ error: row.error,
+ }))
+ }
+
+ async getCommandsByEntity(entityId) {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement:
+ 'SELECT * FROM command_queue WHERE entity_id = ? ORDER BY created_at ASC',
+ values: [entityId],
+ })
+ return (result.values || []).map(row => ({
+ id: row.id,
+ commandType: row.command_type,
+ entityId: row.entity_id,
+ payload: row.payload,
+ createdAt: row.created_at,
+ status: row.status,
+ error: row.error,
+ }))
+ }
+
+ async updateCommandStatus(id, status, error) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: 'UPDATE command_queue SET status = ?, error = ? WHERE id = ?',
+ values: [status, error, id],
+ })
+ }
+
+ async updateCommand(id, updates) {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT * FROM command_queue WHERE id = ?',
+ values: [id],
+ })
+
+ if (!result.values?.length) return
+
+ const row = result.values[0]
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: `UPDATE command_queue
+ SET command_type = ?, entity_id = ?, payload = ?, created_at = ?, status = ?, error = ?
+ WHERE id = ?`,
+ values: [
+ updates.commandType ?? row.command_type,
+ updates.entityId ?? row.entity_id,
+ updates.payload ?? row.payload,
+ updates.createdAt ?? row.created_at,
+ updates.status ?? row.status,
+ Object.prototype.hasOwnProperty.call(updates, 'error')
+ ? updates.error
+ : row.error,
+ id,
+ ],
+ })
+ }
+
+ async removeCommand(id) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: 'DELETE FROM command_queue WHERE id = ?',
+ values: [id],
+ })
+ }
+
+ async clearCommands() {
+ await CapacitorSQLite.execute({
+ database: DB_NAME,
+ statements: 'DELETE FROM command_queue',
+ })
+ }
+
+ // ── Sync metadata ──
+
+ async getSyncCursor() {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: "SELECT value FROM sync_meta WHERE key = 'sync_cursor'",
+ values: [],
+ })
+ if (result.values && result.values.length > 0) {
+ return Number(result.values[0].value)
+ }
+ return 0
+ }
+
+ async setSyncCursor(cursor) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement:
+ "INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('sync_cursor', ?)",
+ values: [String(cursor)],
+ })
+ }
+
+ async getLastSyncTime() {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: "SELECT value FROM sync_meta WHERE key = 'last_sync_time'",
+ values: [],
+ })
+ if (result.values && result.values.length > 0) {
+ return Number(result.values[0].value)
+ }
+ return null
+ }
+
+ async setLastSyncTime(time) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement:
+ "INSERT OR REPLACE INTO sync_meta (key, value) VALUES ('last_sync_time', ?)",
+ values: [String(time)],
+ })
+ }
+
+ async saveKV(key, value) {
+ await CapacitorSQLite.run({
+ database: DB_NAME,
+ statement: 'INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)',
+ values: [key, JSON.stringify(value)],
+ })
+ }
+
+ async getKV(key) {
+ const result = await CapacitorSQLite.query({
+ database: DB_NAME,
+ statement: 'SELECT value FROM sync_meta WHERE key = ?',
+ values: [key],
+ })
+ if (result.values && result.values.length > 0) {
+ try {
+ return JSON.parse(result.values[0].value)
+ } catch {
+ return null
+ }
+ }
+ return null
+ }
+
+ async clearAll() {
+ await CapacitorSQLite.execute({
+ database: DB_NAME,
+ statements: `
+ DELETE FROM cached_chores;
+ DELETE FROM command_queue;
+ DELETE FROM sync_meta;
+ DELETE FROM cached_history;
+ `,
+ })
+ }
+}
+
+// ── IndexedDB backend (Web) ──
+
+class IndexedDBBackend {
+ constructor() {
+ this.db = null
+ this.initialized = false
+ }
+
+ _open() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(IDB_NAME, IDB_VERSION)
+
+ request.onupgradeneeded = event => {
+ const db = event.target.result
+
+ if (!db.objectStoreNames.contains('cached_chores')) {
+ db.createObjectStore('cached_chores', { keyPath: 'id' })
+ }
+
+ if (!db.objectStoreNames.contains('command_queue')) {
+ const cmdStore = db.createObjectStore('command_queue', {
+ keyPath: 'id',
+ autoIncrement: true,
+ })
+ cmdStore.createIndex('entity_id', 'entityId', { unique: false })
+ cmdStore.createIndex('created_at', 'createdAt', { unique: false })
+ cmdStore.createIndex('status', 'status', { unique: false })
+ }
+
+ if (!db.objectStoreNames.contains('sync_meta')) {
+ db.createObjectStore('sync_meta', { keyPath: 'key' })
+ }
+
+ if (!db.objectStoreNames.contains('cached_history')) {
+ const histStore = db.createObjectStore('cached_history', {
+ keyPath: 'id',
+ })
+ histStore.createIndex('chore_id', 'choreId', { unique: false })
+ histStore.createIndex('performed_at', 'performedAt', {
+ unique: false,
+ })
+ histStore.createIndex('pending', 'pending', { unique: false })
+ }
+ }
+
+ request.onsuccess = event => resolve(event.target.result)
+ request.onerror = event => reject(event.target.error)
+ })
+ }
+
+ async init() {
+ if (this.initialized) return
+ this.db = await this._open()
+ // Re-open if browser closes the connection (e.g. after device sleep)
+ this.db.onclose = () => {
+ this.initialized = false
+ }
+ this.initialized = true
+ }
+
+ async _tx(storeName, mode = 'readonly') {
+ // If the connection was closed (e.g. laptop sleep), re-open transparently
+ if (!this.initialized || !this.db) {
+ await this.init()
+ }
+ try {
+ const tx = this.db.transaction(storeName, mode)
+ const store = tx.objectStore(storeName)
+ return { tx, store }
+ } catch (err) {
+ // InvalidStateError = connection closed; re-open once and retry
+ if (
+ err.name === 'InvalidStateError' ||
+ err.name === 'TransactionInactiveError'
+ ) {
+ this.initialized = false
+ await this.init()
+ const tx = this.db.transaction(storeName, mode)
+ const store = tx.objectStore(storeName)
+ return { tx, store }
+ }
+ throw err
+ }
+ }
+
+ _request(idbRequest) {
+ return new Promise((resolve, reject) => {
+ idbRequest.onsuccess = () => resolve(idbRequest.result)
+ idbRequest.onerror = () => reject(idbRequest.error)
+ })
+ }
+
+ // ── Chore cache ──
+
+ async saveChores(chores) {
+ if (!chores.length) return
+
+ const { tx, store } = await this._tx('cached_chores', 'readwrite')
+
+ for (const chore of chores) {
+ store.put({
+ id: chore.id,
+ data: chore,
+ syncVersion: chore.syncVersion || 0,
+ cachedAt: Date.now(),
+ })
+ }
+
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ }
+
+ async getChores(includeArchive = false) {
+ const { store } = await this._tx('cached_chores')
+ const rows = await this._request(store.getAll())
+ const chores = rows.map(row => row.data)
+ if (includeArchive) {
+ return chores
+ }
+ return chores.filter(chore => chore.isActive !== false)
+ }
+
+ async getChore(id) {
+ const { store } = await this._tx('cached_chores')
+ // Try numeric ID first (chores are stored with numeric keys from the server)
+ // URL params are strings so we need to coerce
+ const numericId = Number(id)
+ const row = await this._request(
+ store.get(isNaN(numericId) ? id : numericId),
+ )
+ return row ? row.data : null
+ }
+
+ async deleteChores(ids) {
+ if (!ids.length) return
+ const { tx, store } = await this._tx('cached_chores', 'readwrite')
+ for (const id of ids) {
+ store.delete(id)
+ }
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ }
+
+ async clearChores() {
+ const { store } = await this._tx('cached_chores', 'readwrite')
+ await this._request(store.clear())
+ }
+
+ // ── History cache ──
+
+ async saveHistory(entries) {
+ if (!entries.length) return
+ // Delete pending entries for the affected chore IDs first
+ const choreIds = [...new Set(entries.map(e => Number(e.choreId)))]
+ await this._deletePendingHistoryByChoreIds(choreIds)
+ // Upsert real entries
+ const { tx, store } = await this._tx('cached_history', 'readwrite')
+ for (const entry of entries) {
+ store.put({
+ id: entry.id,
+ choreId: Number(entry.choreId),
+ data: entry,
+ performedAt: new Date(entry.performedAt).getTime(),
+ pending: 0,
+ cachedAt: Date.now(),
+ })
+ }
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ }
+
+ async savePendingHistory(entry) {
+ const { store } = await this._tx('cached_history', 'readwrite')
+ await this._request(
+ store.put({
+ id: entry.id,
+ choreId: Number(entry.choreId),
+ data: entry,
+ performedAt: new Date(entry.performedAt).getTime(),
+ pending: 1,
+ cachedAt: Date.now(),
+ }),
+ )
+ }
+
+ async _deletePendingHistoryByChoreIds(choreIds) {
+ if (!choreIds.length) return
+ const choreIdSet = new Set(choreIds)
+ // Tx 1: read all pending entries
+ const { store: readStore } = await this._tx('cached_history')
+ const index = readStore.index('pending')
+ const rows = await this._request(index.getAll(1))
+ const toDelete = rows
+ .filter(row => choreIdSet.has(Number(row.choreId)))
+ .map(row => row.id)
+ if (!toDelete.length) return
+ // Tx 2: delete them
+ const { tx, store: writeStore } = await this._tx(
+ 'cached_history',
+ 'readwrite',
+ )
+ for (const id of toDelete) {
+ writeStore.delete(id)
+ }
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ }
+
+ async getHistoryByChore(choreId) {
+ const { store } = await this._tx('cached_history')
+ const index = store.index('chore_id')
+ const rows = await this._request(index.getAll(Number(choreId)))
+ return rows
+ .map(row => row.data)
+ .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt))
+ }
+
+ async getHistoryByDays(days) {
+ const since = days >= 365 ? 0 : Date.now() - days * 24 * 60 * 60 * 1000
+ const { store } = await this._tx('cached_history')
+ const rows = await this._request(store.getAll())
+ return rows
+ .map(row => row.data)
+ .filter(entry =>
+ since === 0 ? true : new Date(entry.performedAt).getTime() >= since,
+ )
+ .sort((a, b) => new Date(b.performedAt) - new Date(a.performedAt))
+ }
+
+ async deleteHistory(ids) {
+ if (!ids.length) return
+ const { tx, store } = await this._tx('cached_history', 'readwrite')
+ for (const id of ids) {
+ store.delete(id)
+ }
+ return new Promise((resolve, reject) => {
+ tx.oncomplete = () => resolve()
+ tx.onerror = () => reject(tx.error)
+ })
+ }
+
+ async updateHistoryEntry(choreId, historyId, updates) {
+ const { store } = await this._tx('cached_history')
+ const existing = await this._request(store.get(historyId))
+ if (!existing) return
+
+ const merged = {
+ ...existing,
+ choreId: Number(choreId),
+ data: {
+ ...existing.data,
+ ...updates,
+ id: historyId,
+ choreId: Number(choreId),
+ },
+ }
+ merged.performedAt = new Date(merged.data.performedAt).getTime()
+ merged.cachedAt = Date.now()
+
+ const { store: writeStore } = await this._tx('cached_history', 'readwrite')
+ await this._request(writeStore.put(merged))
+ }
+
+ async deleteHistoryEntry(historyId) {
+ const { store } = await this._tx('cached_history', 'readwrite')
+ await this._request(store.delete(historyId))
+ }
+
+ async clearHistory() {
+ const { store } = await this._tx('cached_history', 'readwrite')
+ await this._request(store.clear())
+ }
+
+ // ── Command queue ──
+
+ async enqueueCommand(command) {
+ const { store } = await this._tx('command_queue', 'readwrite')
+ const id = await this._request(
+ store.add({
+ commandType: command.commandType,
+ entityId: command.entityId,
+ payload: command.payload,
+ createdAt: command.createdAt,
+ status: command.status,
+ error: command.error,
+ }),
+ )
+ return id
+ }
+
+ async getCommands() {
+ const { store } = await this._tx('command_queue')
+ const index = store.index('created_at')
+ const rows = await this._request(index.getAll())
+ return rows
+ }
+
+ async getCommandsByEntity(entityId) {
+ const { store } = await this._tx('command_queue')
+ const index = store.index('entity_id')
+ const rows = await this._request(index.getAll(entityId))
+ return rows.sort((a, b) => a.createdAt - b.createdAt)
+ }
+
+ async updateCommandStatus(id, status, error) {
+ const { store } = await this._tx('command_queue', 'readwrite')
+ const row = await this._request(store.get(id))
+ if (row) {
+ row.status = status
+ row.error = error
+ await this._request(store.put(row))
+ }
+ }
+
+ async updateCommand(id, updates) {
+ const { store } = await this._tx('command_queue', 'readwrite')
+ const row = await this._request(store.get(id))
+ if (row) {
+ await this._request(
+ store.put({
+ ...row,
+ ...updates,
+ }),
+ )
+ }
+ }
+
+ async removeCommand(id) {
+ const { store } = await this._tx('command_queue', 'readwrite')
+ await this._request(store.delete(id))
+ }
+
+ async clearCommands() {
+ const { store } = await this._tx('command_queue', 'readwrite')
+ await this._request(store.clear())
+ }
+
+ // ── Sync metadata ──
+
+ async getSyncCursor() {
+ const { store } = await this._tx('sync_meta')
+ const row = await this._request(store.get('sync_cursor'))
+ return row ? Number(row.value) : 0
+ }
+
+ async setSyncCursor(cursor) {
+ const { store } = await this._tx('sync_meta', 'readwrite')
+ await this._request(
+ store.put({ key: 'sync_cursor', value: String(cursor) }),
+ )
+ }
+
+ async getLastSyncTime() {
+ const { store } = await this._tx('sync_meta')
+ const row = await this._request(store.get('last_sync_time'))
+ return row ? Number(row.value) : null
+ }
+
+ async setLastSyncTime(time) {
+ const { store } = await this._tx('sync_meta', 'readwrite')
+ await this._request(
+ store.put({ key: 'last_sync_time', value: String(time) }),
+ )
+ }
+
+ async saveKV(key, value) {
+ const { store } = await this._tx('sync_meta', 'readwrite')
+ await this._request(store.put({ key, value: JSON.stringify(value) }))
+ }
+
+ async getKV(key) {
+ const { store } = await this._tx('sync_meta')
+ const row = await this._request(store.get(key))
+ if (row) {
+ try {
+ return JSON.parse(row.value)
+ } catch {
+ return null
+ }
+ }
+ return null
+ }
+
+ async clearAll() {
+ const storeNames = [
+ 'cached_chores',
+ 'command_queue',
+ 'sync_meta',
+ 'cached_history',
+ ]
+ for (const storeName of storeNames) {
+ const { store } = await this._tx(storeName, 'readwrite')
+ await this._request(store.clear())
+ }
+ }
+}
+
+// ── OfflineDB facade ──
+
+class OfflineDB {
+ constructor() {
+ this.backend = null
+ this.initialized = false
+
+ // When the tab becomes visible after being hidden (laptop wake/tab switch),
+ // reset so the next operation re-validates the IDB connection.
+ if (typeof document !== 'undefined') {
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible' && this.backend) {
+ // Signal the IDB backend to re-open on next use
+ this.backend.initialized = false
+ }
+ })
+ }
+ }
+
+ async init() {
+ if (this.initialized) return
+ if (this._initPromise) return this._initPromise
+
+ this._initPromise = (async () => {
+ this.backend = isNative() ? new SQLiteBackend() : new IndexedDBBackend()
+ await this.backend.init()
+ this.initialized = true
+ this._initPromise = null
+ })()
+
+ return this._initPromise
+ }
+
+ async _ensureInit() {
+ if (!this.initialized) {
+ await this.init()
+ }
+ }
+
+ // Chore cache
+ async saveChores(chores) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.saveChores(chores)
+ }
+
+ async getChores(includeArchive = false) {
+ if (!isOfflineFeatureEnabled()) return []
+ await this._ensureInit()
+ return this.backend.getChores(includeArchive)
+ }
+
+ async getChore(id) {
+ if (!isOfflineFeatureEnabled()) return null
+ await this._ensureInit()
+ return this.backend.getChore(id)
+ }
+
+ async deleteChores(ids) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.deleteChores(ids)
+ }
+
+ async clearChores() {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.clearChores()
+ }
+
+ // Command queue
+ async enqueueCommand(command) {
+ if (!isOfflineFeatureEnabled()) return null
+ await this._ensureInit()
+ return this.backend.enqueueCommand(command)
+ }
+
+ async getCommands() {
+ if (!isOfflineFeatureEnabled()) return []
+ await this._ensureInit()
+ return this.backend.getCommands()
+ }
+
+ async getCommandsByEntity(entityId) {
+ if (!isOfflineFeatureEnabled()) return []
+ await this._ensureInit()
+ return this.backend.getCommandsByEntity(entityId)
+ }
+
+ async updateCommandStatus(id, status, error) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.updateCommandStatus(id, status, error)
+ }
+
+ async updateCommand(id, updates) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.updateCommand(id, updates)
+ }
+
+ async removeCommand(id) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.removeCommand(id)
+ }
+
+ async clearCommands() {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.clearCommands()
+ }
+
+ // Sync metadata
+ async getSyncCursor() {
+ if (!isOfflineFeatureEnabled()) return 0
+ await this._ensureInit()
+ return this.backend.getSyncCursor()
+ }
+
+ async setSyncCursor(cursor) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.setSyncCursor(cursor)
+ }
+
+ async getLastSyncTime() {
+ if (!isOfflineFeatureEnabled()) return null
+ await this._ensureInit()
+ return this.backend.getLastSyncTime()
+ }
+
+ async setLastSyncTime(time) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.setLastSyncTime(time)
+ }
+
+ // History cache
+ async saveHistory(entries) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.saveHistory(entries)
+ }
+
+ async savePendingHistory(entry) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.savePendingHistory(entry)
+ }
+
+ async getHistoryByChore(choreId) {
+ if (!isOfflineFeatureEnabled()) return []
+ await this._ensureInit()
+ return this.backend.getHistoryByChore(choreId)
+ }
+
+ async getHistoryByDays(days) {
+ if (!isOfflineFeatureEnabled()) return []
+ await this._ensureInit()
+ return this.backend.getHistoryByDays(days)
+ }
+
+ async deleteHistory(ids) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.deleteHistory(ids)
+ }
+
+ async updateHistoryEntry(choreId, historyId, updates) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.updateHistoryEntry(choreId, historyId, updates)
+ }
+
+ async deleteHistoryEntry(historyId) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.deleteHistoryEntry(historyId)
+ }
+
+ async clearHistory() {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.clearHistory()
+ }
+
+ // General key-value cache (uses sync_meta store)
+ async saveKV(key, value) {
+ if (!isOfflineFeatureEnabled()) return
+ await this._ensureInit()
+ return this.backend.saveKV(key, value)
+ }
+
+ async getKV(key) {
+ if (!isOfflineFeatureEnabled()) return null
+ await this._ensureInit()
+ return this.backend.getKV(key)
+ }
+
+ async clearAll() {
+ await this._ensureInit()
+ return this.backend.clearAll()
+ }
+}
+
+export const offlineDB = new OfflineDB()
diff --git a/src/utils/OfflineFeatureToggle.js b/src/utils/OfflineFeatureToggle.js
new file mode 100644
index 0000000..cb2cb2b
--- /dev/null
+++ b/src/utils/OfflineFeatureToggle.js
@@ -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
+ }
+}
diff --git a/src/utils/SyncEngine.js b/src/utils/SyncEngine.js
new file mode 100644
index 0000000..2d247a1
--- /dev/null
+++ b/src/utils/SyncEngine.js
@@ -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()
diff --git a/src/utils/SyncManager.jsx b/src/utils/SyncManager.jsx
deleted file mode 100644
index 679019a..0000000
--- a/src/utils/SyncManager.jsx
+++ /dev/null
@@ -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()
diff --git a/src/utils/TokenStorage.js b/src/utils/TokenStorage.js
index cae31c3..6710ca2 100644
--- a/src/utils/TokenStorage.js
+++ b/src/utils/TokenStorage.js
@@ -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')
diff --git a/src/views/Authorization/Authenticating.jsx b/src/views/Authorization/Authenticating.jsx
index 693fd4b..4420b35 100644
--- a/src/views/Authorization/Authenticating.jsx
+++ b/src/views/Authorization/Authenticating.jsx
@@ -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 = () => {
Go back Login
)}
+
+ {
+ setMessage('Authentication failed')
+ setSubMessage('Two-factor authentication failed. Please try again')
+ }}
+ />
)
diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx
index 1a85de3..e8fe85e 100644
--- a/src/views/Authorization/LoginSettings.jsx
+++ b/src/views/Authorization/LoginSettings.jsx
@@ -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 (
{
sx={{
mt: 1,
width: '100%',
-
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
@@ -51,13 +150,7 @@ const LoginSettings = () => {
Done
-
- tick
-
+ tick
@@ -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' ? (
+
+ ) : status === 'error' ? (
+
+ ) : null
+ }
/>
@@ -81,72 +187,68 @@ const LoginSettings = () => {
own self-hosted Donetick server.
- 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).
+
+ {status === 'error' && (
+ }
+ sx={{ mt: 2, width: '100%' }}
+ >
+ {errorMessage}
+
+ )}
+
+ {status === 'success' && (
+ }
+ sx={{ mt: 2, width: '100%' }}
+ >
+ Connected! Redirecting to login...
+
+ )}
+
+ {status === 'testing' && (
+ }
+ sx={{ mt: 2, width: '100%' }}
+ >
+ Testing connection to server...
+
+ )}
+
{
- 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 ? : undefined
+ }
>
- Save
+ {isTesting ? 'Testing...' : 'Save & Connect'}
{
- 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
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index 8dcf06b..4d5c78e 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -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 = () => {
{
- setAssignees([])
+ setAnyone(!anyone)
setIsPrivate(false)
}}
overlay
@@ -944,19 +976,25 @@ const ChoreEdit = () => {
{performers?.map((item, index) => (
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) => (
- {
- setAssignedTo(item.userId)
- }}
- >
+
{item.displayName}
))}
diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx
index 9548bc8..e2629d5 100644
--- a/src/views/ChoreEdit/ChoreView.jsx
+++ b/src/views/ChoreEdit/ChoreView.jsx
@@ -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: ,
+ 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: ,
+ 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: ,
+ title: t('choreView.statistics'),
+ text: `${t('choreView.completed')}: ${completionCount} ${t('choreView.times')}`,
+ },
+ {
+ size: 6,
+ icon: ,
+ 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: ,
- 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: ,
- 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: ,
- title: t('choreView.statistics'),
- text: `${t('choreView.completed')}: ${chore.totalCompletedCount || 0} ${t('choreView.times')}`,
- },
- {
- size: 6,
- icon: ,
- 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,
}}
>
-
- {chore.name}
-
+ {chore.name}
+
+
{chore.isActive === false && (
}
@@ -747,7 +908,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
-
+ {(() => {
+ const raw = chore.description || ''
+ const shouldRenderHtml = hasHtmlTags(raw)
+
+ return shouldRenderHtml ? (
+
+ ) : (
+
+ {decodeHtmlEntities(raw)}
+
+ )
+ })()}
>
@@ -792,7 +976,30 @@ const ChoreView = () => {
overflow: 'hidden',
}}
>
-
+ {(() => {
+ const raw = chore.notes || ''
+ const shouldRenderHtml = hasHtmlTags(raw)
+
+ return shouldRenderHtml ? (
+
+ ) : (
+
+ {decodeHtmlEntities(raw)}
+
+ )
+ })()}
>
diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx
index a7d3c44..4cb4fa9 100644
--- a/src/views/ChoreEdit/RepeatSection.jsx
+++ b/src/views/ChoreEdit/RepeatSection.jsx
@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
return `Every ${dayNames} at ${timeStr}`
}
-const RepeatOnSections = ({
+export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,
diff --git a/src/views/Chores/ActivitesCard.jsx b/src/views/Chores/ActivitesCard.jsx
index 54c31cb..5108633 100644
--- a/src/views/Chores/ActivitesCard.jsx
+++ b/src/views/Chores/ActivitesCard.jsx
@@ -111,6 +111,18 @@ const ActivityItem = ({ activity, members, onViewNote }) => {
text: 'Rejected',
icon: ,
}
+ } else if (activity.status === 5) {
+ return {
+ color: 'danger',
+ text: 'Missed',
+ icon: ,
+ }
+ } else if (activity.status === 6) {
+ return {
+ color: 'neutral',
+ text: 'Rescheduled',
+ icon: ,
+ }
}
// Fallback for completed status
diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx
index e78794c..cf118f3 100644
--- a/src/views/Chores/ArchivedTasks.jsx
+++ b/src/views/Chores/ArchivedTasks.jsx
@@ -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) {
diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx
index 5b4ce40..8c955d3 100644
--- a/src/views/Chores/ChoreCard.jsx
+++ b/src/views/Chores/ChoreCard.jsx
@@ -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 (
-
+
+
+
+
+ {chore.status === 3 && (
+
+
+ Pending
+
+ )}
{showActions && (
{chore.name}
-
+ {(chore._pending || (pendingCmds && pendingCmds.length > 0)) && (
+
+ )}
{/* Due Date - Inline with name */}
{
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 = ({
)}
- {options?.map(item => (
+ {menuOptions.map(item => (
{
diff --git a/src/views/Chores/LocalNotificationScheduler.js b/src/views/Chores/LocalNotificationScheduler.js
index 7ca1968..d5f5572 100644
--- a/src/views/Chores/LocalNotificationScheduler.js
+++ b/src/views/Chores/LocalNotificationScheduler.js
@@ -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(
diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx
index a0f63f1..84a0424 100644
--- a/src/views/Chores/MyChores.jsx
+++ b/src/views/Chores/MyChores.jsx
@@ -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 = () => {
)}
)}
- {searchTerm?.length > 0 &&
- viewMode !== 'calendar' && (
-
- )}
+ {searchTerm?.length > 0 && viewMode !== 'calendar' && (
+
+ )}
{viewMode === 'calendar' && (
<>
{/* Summary Chips when no date selected */}
@@ -1296,87 +1282,86 @@ const MyChores = () => {
)}
>
)}
- {searchTerm.length === 0 &&
- viewMode !== 'calendar' && (
-
- {choreSections.map((section, index) => {
- if (section.content.length === 0) return null
- return (
-
-
- {
- if (openChoreSections[index]) {
- const newOpenChoreSections = {
- ...openChoreSections,
- }
- delete newOpenChoreSections[index]
- setOpenChoreSectionsWithCache(newOpenChoreSections)
- } else {
- setOpenChoreSectionsWithCache({
- ...openChoreSections,
- [index]: true,
- })
+ {searchTerm.length === 0 && viewMode !== 'calendar' && (
+
+ {choreSections.map((section, index) => {
+ if (section.content.length === 0) return null
+ return (
+
+
+ {
+ if (openChoreSections[index]) {
+ const newOpenChoreSections = {
+ ...openChoreSections,
}
- }}
- endDecorator={
- openChoreSections[index] ? (
-
- ) : (
-
- )
+ delete newOpenChoreSections[index]
+ setOpenChoreSectionsWithCache(newOpenChoreSections)
+ } else {
+ setOpenChoreSectionsWithCache({
+ ...openChoreSections,
+ [index]: true,
+ })
}
- startDecorator={
- <>
-
- {section?.content?.length}
-
- >
- }
- >
- {section.name}
-
-
- *']: {
- // px: 0.5,
- px: 0.5,
- // pr: 0,
- },
}}
+ endDecorator={
+ openChoreSections[index] ? (
+
+ ) : (
+
+ )
+ }
+ startDecorator={
+ <>
+
+ {section?.content?.length}
+
+ >
+ }
>
-
-
-
- )
- })}
-
- )}
+ {section.name}
+
+
+ *']: {
+ // px: 0.5,
+ px: 0.5,
+ // pr: 0,
+ },
+ }}
+ >
+
+
+
+ )
+ })}
+
+ )}
{
/>
+ Capacitor.getPlatform() === 'android'
+ ? `donetick://chores/${choreId}`
+ : `${window.location.origin}/chores/${choreId}`
+
const ChoreModals = ({
activeModal,
modalChore,
@@ -65,12 +71,13 @@ const ChoreModals = ({
)}
+
{activeModal === 'nudge' && modalChore && (
+ 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,
diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx
index b2a213b..84ac352 100644
--- a/src/views/History/ChoreHistory.jsx
+++ b/src/views/History/ChoreHistory.jsx
@@ -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.`,
+ })
+ }
},
},
)
diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx
index 6004071..da13e60 100644
--- a/src/views/History/HistoryCard.jsx
+++ b/src/views/History/HistoryCard.jsx
@@ -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 = ({
)}
+ {pendingCommands?.length > 0 && (
+
+ )}
)
}
diff --git a/src/views/Labels/LabelQueries.jsx b/src/views/Labels/LabelQueries.jsx
index 5fec321..ac4598b 100644
--- a/src/views/Labels/LabelQueries.jsx
+++ b/src/views/Labels/LabelQueries.jsx
@@ -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 []
+ }
+ },
})
}
diff --git a/src/views/Modals/Inputs/ConfirmationModal.jsx b/src/views/Modals/Inputs/ConfirmationModal.jsx
index f86668e..e2ae0b8 100644
--- a/src/views/Modals/Inputs/ConfirmationModal.jsx
+++ b/src/views/Modals/Inputs/ConfirmationModal.jsx
@@ -73,7 +73,7 @@ function ConfirmationModal({ config }) {
return (
handleAction(false)}
size='sm'
unmountDelay={250}
>
diff --git a/src/views/Modals/Inputs/WriteNFCModal.jsx b/src/views/Modals/Inputs/WriteNFCModal.jsx
index bde9aba..447e883 100644
--- a/src/views/Modals/Inputs/WriteNFCModal.jsx
+++ b/src/views/Modals/Inputs/WriteNFCModal.jsx
@@ -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 (
+
+ URL written to NFC tag successfully!
+
+ )
}
- return url
+ if (nfcStatus === 'waiting_for_tag') {
+ return (
+ <>
+
+
+
+ Hold your device near the NFC tag
+
+
+
+ Cancel
+
+ >
+ )
+ }
+
+ return (
+ <>
+
+ {nfcStatus === 'error'
+ ? errorMessage
+ : 'Press the button below to write to NFC.'}
+
+ {
+ navigator.clipboard.writeText(getURL())
+ alert('URL copied to clipboard!')
+ }}
+ />
+ }
+ />
+
+ setIsAutoCompleteWhenScan(e.target.checked)}
+ label='Auto-complete when scanned'
+ />
+
+
+
+ Write NFC
+
+
+ >
+ )
}
+
return (
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
-
- {nfcStatus === 'success' ? (
-
- URL written to NFC tag successfully!
-
- ) : (
- <>
-
- {nfcStatus === 'error'
- ? errorMessage
- : 'Press the button below to write to NFC.'}
-
- {
- navigator.clipboard.writeText(getURL())
- alert('URL copied to clipboard!')
- }}
- />
- }
- />
-
- setIsAutoCompleteWhenScan(e.target.checked)}
- label='Auto-complete when scanned'
- />
-
-
- writeToNFC(getURL())}
- fullWidth
- sx={{ mr: 1 }}
- disabled={nfcStatus === 'writing'}
- >
- Write NFC
-
-
- Request Access
-
-
- >
- )}
+ {renderBody()}
)
}
diff --git a/src/views/Projects/ProjectQueries.js b/src/views/Projects/ProjectQueries.js
index e2a71b4..039d0b4 100644
--- a/src/views/Projects/ProjectQueries.js
+++ b/src/views/Projects/ProjectQueries.js
@@ -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,
})
-}
\ No newline at end of file
+}
diff --git a/src/views/Settings/AdvancedSettings.jsx b/src/views/Settings/AdvancedSettings.jsx
index 7200844..d97ad46 100644
--- a/src/views/Settings/AdvancedSettings.jsx
+++ b/src/views/Settings/AdvancedSettings.jsx
@@ -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 (
-
- Loading...
-
- )
+ 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 (
+ //
+ // Loading...
+ //
+ // )
+ // }
+
return (
-
+
- Configure advanced features like webhooks and real-time updates for enhanced productivity.
+ Configure advanced features like webhooks and real-time updates for
+ enhanced productivity.
+
+ Offline Support
+
+ Early Access
+
+
+
+ Keep using Donetick when you're offline on this device/browser. Your
+ changes are saved locally and synced when you're back online.
+
+
+
+
+ Turning this off removes unsynced offline changes and saved offline
+ data from this device/browser.
+
+
+
{/* Webhook Settings - Only show for admins */}
{isAdmin && (
<>
@@ -152,12 +271,17 @@ const AdvancedSettings = () => {
Real-time Updates
- 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.
+
+ {confirmModalConfig?.isOpen && (
+
+ )}
)
}
-export default AdvancedSettings
\ No newline at end of file
+export default AdvancedSettings
diff --git a/src/views/Settings/DeveloperSettings.jsx b/src/views/Settings/DeveloperSettings.jsx
index 35ef183..b400f88 100644
--- a/src/views/Settings/DeveloperSettings.jsx
+++ b/src/views/Settings/DeveloperSettings.jsx
@@ -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 (
Developer Settings
@@ -377,6 +505,143 @@ const DeveloperSettings = () => {
+
+
+
+ Sync & Network Diagnostics
+ }
+ onClick={refreshSyncDiagnostics}
+ >
+ Refresh
+
+
+
+
+
+
+ Network Status
+
+ Connection:{' '}
+
+ {syncDiagnostics.isOnline ? 'Online' : 'Offline'}
+
+
+
+ Device Network:{' '}
+
+ {syncDiagnostics.isNetworkOn === false
+ ? 'Disconnected'
+ : syncDiagnostics.isNetworkOn === true
+ ? 'Connected'
+ : 'Unknown'}
+
+
+
+ Offline Since: {formatDateTime(syncDiagnostics.offlineSince)}
+
+
+ Last Network Check: {formatDateTime(syncDiagnostics.lastChecked)}
+
+
+
+
+
+
+ Sync Offset Information
+
+ Sync Cursor:{' '}
+
+ {syncDiagnostics.cursor ?? 'N/A'}
+
+
+
+ Last Sync:{' '}
+
+ {formatDateTime(syncDiagnostics.lastSync)}
+
+
+
+ Sync State:{' '}
+
+ {syncDiagnostics.syncing ? 'Syncing' : 'Idle'}
+
+
+
+ Pending Commands:{' '}
+
+ {syncDiagnostics.pendingCount}
+
+
+
+ Failed Commands:{' '}
+ 0 ? 'danger' : 'success'}
+ >
+ {syncDiagnostics.failedCount}
+
+
+ {syncDiagnostics.syncError && (
+
+ Sync Error: {syncDiagnostics.syncError}
+
+ )}
+
+
+
+
+
+ Recovery Actions
+
+ Clears local offline cache, sync cursor, and queued commands, then
+ re-syncs from the beginning.
+
+
+
+ Clear DB & Full Re-Sync
+
+
+
+
+
+
{isNativePlatform && (
@@ -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 (
{
+
+
)
}
diff --git a/src/views/Settings/StorageSettings.jsx b/src/views/Settings/StorageSettings.jsx
index 66259c8..284710b 100644
--- a/src/views/Settings/StorageSettings.jsx
+++ b/src/views/Settings/StorageSettings.jsx
@@ -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 = () => {
)}
-
-
- Experimental Features
-
- Coming Soon
-
-
-
-
-
- Enable Offline Mode
-
-
- 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.
-
-
-
handleOfflineModeToggle(event.target.checked)}
- sx={{ ml: 2 }}
- />
-
- {offlineModeEnabled && (
-
- ⚠️ Offline mode is enabled. If you experience slowness, disable
- this setting.
-
- )}
-
-
{Capacitor.isNativePlatform() ? 'App' : 'Browser'} Local Storage &
Cache
- 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.
{
>
Clear All Local Storage and Cache
- {
- 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
-
{Capacitor.isNativePlatform() && (
diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx
index 349e15b..5cd8d9e 100644
--- a/src/views/Timer/TimerDetails.jsx
+++ b/src/views/Timer/TimerDetails.jsx
@@ -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(
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index 2254cf7..cffb741 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -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 }) => {
,
)
- // 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 }) => {
{
setTaskText(text)
}}
@@ -806,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}}
/>
- {/*
- Title:
- setTaskTitle(e.target.value)}
- sx={{ width: '100%', fontSize: '16px' }}
- />
- */}
+
+ // 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',
+ }}
+ >
+ {
+ setDueDate(null)
+ setDueDateOnly(null)
+ setDueTime(null)
+ setUseCustomTime(false)
+ }}
+ />
+ setFrequency(null)}
+ />
+ setPriority(0)}
+ emptyDisplay={pickerEmptyDisplay}
+ priorityColors={priorityColors}
+ priorityLabels={priorityLabels}
+ />
+ {
+ if (!userId) {
+ setAssignees([])
+ } else {
+ setAssignees([{ userId }])
+ }
+ }}
+ onClear={() => setAssignees([])}
+ currentUserId={userProfile?.id}
+ members={circleMembers?.res || []}
+ />
+
+ setLabelsV2([])}
+ labels={userLabels || []}
+ />
+ setAttachments([])}
+ emptyDisplay={pickerEmptyDisplay}
+ entityType='chore_attachment'
+ />
+ setNotificationMetadata({ templates: [] })}
+ emptyDisplay={pickerEmptyDisplay}
+ />
+
+
+
{!hasDescription && (
}
- 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 && (
}
- variant='plain'
- size='sm'
+ variant='outlined'
+ color='neutral'
+ size='md'
onClick={() => {
setHasSubTasks(true)
}}
@@ -848,52 +926,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
Subtasks
)}
- {!dueDate && (
- }
- 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 &&
- }
- >
- Due Date
-
- )}
- {!hasNotifications && dueDate && (
- }
- variant='plain'
- size='sm'
- onClick={() => {
- setHasNotifications(true)
- setFrequencyHumanReadable('Once')
- setFrequency(null)
- }}
- >
- Edit Notifications
-
- )}
- {/* {!hasDeadline && dueDate && (
- }
- variant='plain'
- size='sm'
- onClick={() => {
- setHasDeadline(true)
- setDeadlineOffset(86400)
- }}
- >
- Set Deadline
-
- )} */}
{hasDescription && (
@@ -919,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
/>
)}
-
-
- {priority > 0 && (
-
- Priority
- setPriority(value)}
- >
- No Priority
- P1
- P2
- P3
- P4
-
-
- )}
- {dueDate && (
-
- Due Date
-
- handleUseCustomTimeChange(e.target.checked)}
- label='Set a specific time'
- sx={{ mt: 1 }}
- />
-
- {useCustomTime
- ? 'Task will be due at the specified time'
- : 'Task will be due at the end of the day (11:59 PM)'}
-
- {useCustomTime && (
-
- )}
-
- )}
-
- {/* {projects.length >= 1 && (
-
- Project
- setProjectId(newValue)}
- sx={{ minWidth: '15rem' }}
- >
-
-
-
- {(() => {
- const IconComponent = getIconComponent('FolderOpen')
- return (
-
- )
- })()}
-
- Default Project
-
-
- {projects.map(project => (
-
-
-
- {project.icon ? (
- (() => {
- const IconComponent = getIconComponent(project.icon)
- return (
-
- )
- })()
- ) : (
- <>>
- )}
-
- {project.name}
-
-
- ))}
-
-
- )} */}
-
- {/*
- Assignees
-
- {assignees.length > 0 ? (
- assignees.map((assignee, index) => (
-
- {assignee.displayName || assignee.username}
-
- ))
- ) : (
-
- {userProfile.displayName}
-
- )}
-
- */}
- {/* {hasDeadline && dueDate && (
-
- Deadline
-
-
- after due date
-
-
- )} */}
- {hasNotifications && dueDate && (
-
- Notification Schedule
-
- {
- if (
- metadata.notifications !== notificationMetadata.templates
- ) {
- const newNotificationMetadata = {
- ...notificationMetadata,
- templates: metadata.notifications,
- }
- setNotificationMetadata(newNotificationMetadata)
- }
- }}
- value={notificationMetadata}
- showTimeline={false}
- />
-
-
- )}
-
)
}
diff --git a/src/views/components/AssigneePickerField.jsx b/src/views/components/AssigneePickerField.jsx
new file mode 100644
index 0000000..b9444c7
--- /dev/null
+++ b/src/views/components/AssigneePickerField.jsx
@@ -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 (
+ item.userId}
+ getItemLabel={item => item.displayName}
+ renderTriggerIcon={() => }
+ renderItemStart={() => }
+ getTriggerText={({ selectedItems, isEmpty }) =>
+ isEmpty ? 'Assignee' : selectedItems[0].displayName
+ }
+ menuMinWidth={220}
+ />
+ )
+}
+
+export default AssigneePickerField
diff --git a/src/views/components/AttachmentPickerField.jsx b/src/views/components/AttachmentPickerField.jsx
new file mode 100644
index 0000000..6f9c385
--- /dev/null
+++ b/src/views/components/AttachmentPickerField.jsx
@@ -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 (
+ <>
+
+ 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 ? (
+
+ ) : (
+
+ )}
+
+ {isEmpty
+ ? 'Attachments'
+ : `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
+
+
+ {!isEmpty && onClear && (
+
+
+
+ )}
+
+
+ {isOpen && (
+
+ setIsOpen(false)}>
+
+ {attachments.length > 0 && (
+
+ {attachments.map((attachment, index) => (
+
+ {
+ e.target.style.display = 'none'
+ e.target.nextSibling.style.display = 'flex'
+ }}
+ />
+
+
+
+
+ {attachment.name}
+
+ handleRemove(index)}
+ sx={{ flexShrink: 0 }}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleAddFile}
+ disabled={isUploading}
+ >
+ {isUploading ? 'Uploading…' : 'Add image'}
+
+
+
+
+ )}
+ >
+ )
+}
+
+export default AttachmentPickerField
diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx
new file mode 100644
index 0000000..c176020
--- /dev/null
+++ b/src/views/components/BaseOptionPicker.jsx
@@ -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 (
+ <>
+
+ 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 })}
+
+ {triggerText}
+
+
+ {!isEmpty && onClear && (
+
+
+
+ )}
+
+
+ {isOpen && (
+
+ setIsOpen(false)}>
+
+ {items.map((item, index) => {
+ const optionValue = getItemValue(item)
+ const selected = isSelected(item)
+ const itemColor = getItemColor ? getItemColor(item) : undefined
+
+ return (
+ 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 })}
+
+ {getItemLabel(item)}
+
+
+ )
+ })}
+
+
+
+ )}
+ >
+ )
+}
+
+export default BaseOptionPicker
diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js
index 0a45e2d..8e5cb4e 100644
--- a/src/views/components/CustomParsers.js
+++ b/src/views/components/CustomParsers.js
@@ -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
diff --git a/src/views/components/DueDatePickerField.jsx b/src/views/components/DueDatePickerField.jsx
new file mode 100644
index 0000000..bb0685a
--- /dev/null
+++ b/src/views/components/DueDatePickerField.jsx
@@ -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 (
+ <>
+
+ 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',
+ }}
+ >
+
+
+ {dueDateLabel}
+
+
+ {hasDueDate && onClear && (
+ {
+ e.stopPropagation()
+ onClear?.()
+ }}
+ sx={{
+ position: 'absolute',
+ top: -12,
+ right: -16,
+ zIndex: 10,
+ maxHeight: 18,
+ maxWidth: 18,
+ borderRadius: '50%',
+ '&:hover': {
+ bgcolor: 'danger.softBg',
+ },
+ }}
+ >
+
+
+ )}
+
+
+ setIsOpen(false)}
+ title='Due Date'
+ footer={
+
+ {hasDueDate && (
+ {
+ onClear?.()
+ setIsOpen(false)
+ }}
+ sx={{ mr: 'auto' }}
+ >
+ Remove
+
+ )}
+ setIsOpen(false)}
+ >
+ Cancel
+
+
+ Apply
+
+
+ }
+ >
+
+ {/* Date shortcuts */}
+
+ Quick date
+
+
+ {[
+ {
+ key: 'today',
+ label: 'Today',
+ icon: ,
+ },
+ {
+ key: 'tomorrow',
+ label: 'Tomorrow',
+ icon: ,
+ },
+ {
+ key: 'weekend',
+ label: 'Weekend',
+ icon: ,
+ },
+ {
+ key: 'next-week',
+ label: 'Next week',
+ icon: ,
+ },
+ {
+ key: 'next-month',
+ label: 'Next month',
+ icon: ,
+ },
+ ].map(opt => {
+ const dateStr = getQuickScheduleDate(opt.key)
+ .toISOString()
+ .split('T')[0]
+ return (
+
+ handleQuickSchedule(opt.key)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={
+
+ {opt.icon}
+ {opt.label}
+
+ }
+ />
+
+ )
+ })}
+
+
+ {/* Time shortcuts */}
+
+ Quick time
+
+
+ {[
+ {
+ time: '09:00',
+ label: 'Morning',
+ icon: ,
+ },
+ {
+ time: '12:00',
+ label: 'Noon',
+ icon: ,
+ },
+ {
+ time: '15:00',
+ label: 'Afternoon',
+ icon: ,
+ },
+ {
+ time: '18:00',
+ label: 'Evening',
+ icon: ,
+ },
+ {
+ time: '22:00',
+ label: 'Night',
+ icon: ,
+ },
+ ].map(opt => (
+
+ handleQuickTime(opt.time)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={
+
+ {opt.icon}
+ {opt.label}
+
+ }
+ />
+
+ ))}
+
+
+
+
+ ['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()]
+ }
+ />
+
+
+ Custom time
+
+
+
+ setLocalUseCustomTime(false)}
+ >
+ Anytime
+
+ setLocalUseCustomTime(true)}
+ >
+ Specific time
+
+
+
+
+ >
+ )
+}
+
+export default DueDatePickerField
diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx
new file mode 100644
index 0000000..bf74602
--- /dev/null
+++ b/src/views/components/LabelsPickerField.jsx
@@ -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 (
+ item.id}
+ getItemLabel={item => item.name}
+ getItemColor={item => item.color}
+ renderTriggerIcon={() => }
+ renderItemStart={({ item }) => (
+
+ )}
+ getTriggerText={({ selectedItems, isEmpty }) => {
+ if (isEmpty) return 'Labels'
+ if (selectedItems.length === 1) return selectedItems[0].name
+ return `${selectedItems.length} labels`
+ }}
+ menuMinWidth={220}
+ />
+ )
+}
+
+export default LabelsPickerField
diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx
index 5a74b29..c073e9a 100644
--- a/src/views/components/NavBar.jsx
+++ b/src/views/components/NavBar.jsx
@@ -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()}
+
{/* */}
diff --git a/src/views/components/NetworkBanner.jsx b/src/views/components/NetworkBanner.jsx
index d97b641..a6d98ad 100644
--- a/src/views/components/NetworkBanner.jsx
+++ b/src/views/components/NetworkBanner.jsx
@@ -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 (
- {!isOnline && (
+ {!isOnline && isBannerVisible && (
{
}}
startDecorator={ }
>
- You are currently offline. Some features may not be available.
+ {message}
)}
diff --git a/src/views/components/NotificationPickerField.jsx b/src/views/components/NotificationPickerField.jsx
new file mode 100644
index 0000000..cb51134
--- /dev/null
+++ b/src/views/components/NotificationPickerField.jsx
@@ -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 = (
+
+ {hasNotifications && (
+ {
+ onClear?.()
+ setIsOpen(false)
+ }}
+ sx={{ mr: 'auto' }}
+ >
+ Remove all
+
+ )}
+ setIsOpen(false)}
+ >
+ Cancel
+
+
+ Apply
+
+
+ )
+
+ return (
+ <>
+
+ 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',
+ }}
+ >
+
+
+ {displayLabel}
+
+
+
+ {hasNotifications && onClear && (
+ {
+ e.stopPropagation()
+ onClear?.()
+ }}
+ sx={{
+ position: 'absolute',
+ top: -12,
+ right: -16,
+ zIndex: 10,
+ maxHeight: 18,
+ maxWidth: 18,
+ borderRadius: '50%',
+ '&:hover': { bgcolor: 'danger.softBg' },
+ }}
+ >
+
+
+ )}
+
+
+ setIsOpen(false)}
+ title='Reminders'
+ footer={footer}
+ >
+ {
+ latestTemplatesRef.current = notifications
+ }}
+ showTimeline
+ />
+
+ >
+ )
+}
+
+export default NotificationPickerField
diff --git a/src/views/components/PendingBadge.jsx b/src/views/components/PendingBadge.jsx
new file mode 100644
index 0000000..aba2ace
--- /dev/null
+++ b/src/views/components/PendingBadge.jsx
@@ -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 (
+
+
+ {/* */}
+
+ {/* */}
+
+
+
+
+ Pending actions
+
+
+ {commands.length} action{commands.length > 1 ? 's' : ''} waiting to be
+ synced.
+
+
+
+ {commands.map(cmd => (
+
+
+
+ {formatCommandLabel(cmd.commandType)}
+
+
+ {new Date(cmd.createdAt).toLocaleString()}
+
+
+
+ handleUndo(e, cmd.id)}
+ disabled={Boolean(cancelingIds[cmd.id]) || isCancelingAll}
+ >
+
+
+
+ ))}
+
+
+
+
+
+
+ Close
+
+
+ Cancel all
+
+
+
+
+ )
+}
+
+export default PendingBadge
diff --git a/src/views/components/PriorityPickerField.jsx b/src/views/components/PriorityPickerField.jsx
new file mode 100644
index 0000000..b4eca5e
--- /dev/null
+++ b/src/views/components/PriorityPickerField.jsx
@@ -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 (
+ 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 }) => (
+
+ )}
+ renderItemStart={({ item }) => (
+
+ )}
+ menuMinWidth={180}
+ />
+ )
+}
+
+export default PriorityPickerField
diff --git a/src/views/components/ProjectPickerField.jsx b/src/views/components/ProjectPickerField.jsx
new file mode 100644
index 0000000..2ca9cb0
--- /dev/null
+++ b/src/views/components/ProjectPickerField.jsx
@@ -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 (
+ item.id}
+ getItemLabel={item => item.name}
+ getItemColor={item => item.color}
+ renderTriggerIcon={() => }
+ renderItemStart={({ item }) => (
+
+ )}
+ getTriggerText={({ selectedItems, isEmpty }) =>
+ isEmpty ? 'Project' : selectedItems[0].name
+ }
+ menuMinWidth={240}
+ />
+ )
+}
+
+export default ProjectPickerField
diff --git a/src/views/components/RepeatPickerField.jsx b/src/views/components/RepeatPickerField.jsx
new file mode 100644
index 0000000..f4f9299
--- /dev/null
+++ b/src/views/components/RepeatPickerField.jsx
@@ -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 }) => (
+
+ {children}
+
+)
+
+// Shared time-of-day picker
+const TimeRow = ({ metadata, onUpdate }) => (
+
+ Time of day
+
+ onUpdate({
+ ...metadata,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ time: moment(
+ moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
+ ).format(),
+ })
+ }
+ sx={{ width: 120 }}
+ />
+
+)
+
+const pillListSx = {
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+}
+
+// Interval section
+const IntervalSection = ({
+ frequency,
+ frequencyMetadata,
+ onFrequencyUpdate,
+ onFrequencyMetadataUpdate,
+}) => (
+
+ Repeat every
+
+
+ onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
+ }
+ sx={{ width: 72 }}
+ slotProps={{ input: { min: 1, max: 999 } }}
+ />
+
+ {['days', 'weeks', 'months', 'years'].map(unit => (
+
+
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
+ }
+ overlay
+ disableIcon
+ variant='soft'
+ label={unit.charAt(0).toUpperCase() + unit.slice(1)}
+ />
+
+ ))}
+
+
+
+
+)
+
+// 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 (
+
+ Days
+
+ {DAYS.map(day => (
+
+ toggleDay(day)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
+ />
+
+ ))}
+
+
+
+ Pattern
+
+ 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 => (
+ ({
+ sx: checked
+ ? {
+ bgcolor: 'background.surface',
+ boxShadow: 'sm',
+ '&:hover': { bgcolor: 'background.surface' },
+ }
+ : {},
+ }),
+ }}
+ />
+ ))}
+
+
+
+ {weekPattern === 'week_of_month' && (
+
+ Occurrences
+
+ {OCCURRENCE_OPTIONS.map(opt => (
+
+ toggleOccurrence(opt.value)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={opt.label}
+ />
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
+
+// 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 (
+
+ Months
+
+ {MONTHS.map(month => (
+
+ toggleMonth(month)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
+ />
+
+ ))}
+
+
+
+ Day of month
+ {
+ 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 } }}
+ />
+
+
+
+
+ )
+}
+
+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 (
+ <>
+
+ 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',
+ }}
+ >
+
+
+ {displayLabel}
+
+
+
+ {hasRepeat && onClear && (
+ {
+ e.stopPropagation()
+ onClear?.()
+ }}
+ sx={{
+ position: 'absolute',
+ top: -12,
+ right: -16,
+ zIndex: 10,
+ maxHeight: 18,
+ maxWidth: 18,
+ borderRadius: '50%',
+ '&:hover': { bgcolor: 'danger.softBg' },
+ }}
+ >
+
+
+ )}
+
+
+ setIsOpen(false)}
+ title='Repeat Schedule'
+ footer={
+
+ {hasRepeat && (
+ {
+ onClear?.()
+ setIsOpen(false)
+ }}
+ sx={{ mr: 'auto' }}
+ >
+ Remove
+
+ )}
+ setIsOpen(false)}
+ >
+ Cancel
+
+
+ Apply
+
+
+ }
+ >
+ {/* Frequency type selector */}
+
+
+ Frequency
+
+ {FREQUENCY_TYPES.map(type => (
+
+ handleTypeSelect(type)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={type.charAt(0).toUpperCase() + type.slice(1)}
+ />
+
+ ))}
+
+
+
+ {/* Custom sub-type + detail panel */}
+ {displayType === 'custom' && (
+ <>
+
+ Schedule type
+ 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 => (
+
+ 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' },
+ }
+ : {},
+ }),
+ }}
+ />
+ ))}
+
+
+
+
+
+ {localFrequencyType === 'interval' && (
+
+ )}
+ {localFrequencyType === 'days_of_the_week' && (
+
+ )}
+ {localFrequencyType === 'day_of_the_month' && (
+
+ )}
+ >
+ )}
+
+
+ >
+ )
+}
+
+export default RepeatPickerField
diff --git a/src/views/components/SmartTaskTitleInput.css b/src/views/components/SmartTaskTitleInput.css
index 34ebe4a..c2bb002 100644
--- a/src/views/components/SmartTaskTitleInput.css
+++ b/src/views/components/SmartTaskTitleInput.css
@@ -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;
}
diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx
index 8432c44..afc873e 100644
--- a/src/views/components/SmartTaskTitleInput.jsx
+++ b/src/views/components/SmartTaskTitleInput.jsx
@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
return (