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... + + )} + + + ) + } + + 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' + /> + + + + + + ) } + 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' - /> - - - - - - - )} + {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 + + + + + + + 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. + + + + + + + + {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. - {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 && ( )} - {!dueDate && ( - - )} - {!hasNotifications && dueDate && ( - - )} - {/* {!hasDeadline && dueDate && ( - - )} */} {hasDescription && ( @@ -919,210 +951,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { /> )} - - - {priority > 0 && ( - - Priority - - - )} - {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 - - - )} */} - - {/* - 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 ( + <> + + + {!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 }} + > + + + + ))} + + )} + + + + + + )} + + ) +} + +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 ( + <> + + + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {items.map((item, index) => { + const optionValue = getItemValue(item) + const selected = isSelected(item) + const itemColor = getItemColor ? getItemColor(item) : undefined + + return ( + + ) + })} + + + + )} + + ) +} + +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 ( + <> + + + {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 && ( + + )} + + + + } + > + + {/* 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 + + + + + + + + + + ) +} + +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={() =>