From f27bbd318fe1ea9a034994e12d3d92c8cce24d27 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Sun, 21 Sep 2025 12:46:03 -0400 Subject: [PATCH] Refactor chore management to use React Query hooks for timer and chore actions - Replaced direct API calls with React Query hooks in ChoreView, ChoreCard, CompactChoreCard, and TimerDetails components for better state management and error handling. - Updated ArchivedTasks to utilize useUnArchiveChore hook for restoring archived chores. - Enhanced NotificationSetting to include device registration logic and improved user feedback for push notifications. - Refactored TimerEditModal and TimerDetails to streamline timer session updates and deletions using hooks. - Improved ChoreActionMenu to handle archiving and unarchiving chores with hooks. - Adjusted various components to use getSafeBottomStyles for consistent bottom padding. - Cleaned up unused imports and optimized loading states across components. --- android/app/build.gradle | 4 +- ios/App/App.xcodeproj/project.pbxproj | 11 +- src/App.jsx | 10 +- src/CapacitorListener.js | 115 ++++++++----- src/contexts/Contexts.jsx | 4 +- src/contexts/QueryContext.jsx | 6 +- src/queries/ChoreQueries.jsx | 131 +++++++++++++++ src/queries/ResourceQueries.jsx | 2 +- src/queries/TimeQueries.jsx | 107 ++++++++++++ src/queries/UserQueries.jsx | 2 +- src/utils/FeatureToggle.js | 19 ++- src/utils/TokenManager.jsx | 39 ++++- src/views/ChoreEdit/ChoreView.jsx | 94 +++++------ src/views/Chores/ArchivedTasks.jsx | 24 ++- src/views/Chores/ChoreCard.jsx | 50 +++--- src/views/Chores/CompactChoreCard.jsx | 41 +++-- src/views/Chores/MyChores.jsx | 26 ++- src/views/History/ChoreHistory.jsx | 125 +++++++------- src/views/History/HistoryCard.jsx | 31 ++-- src/views/Labels/LabelView.jsx | 4 +- src/views/Modals/Inputs/TimerEditModal.jsx | 124 +++++++------- src/views/Settings/NotificationSetting.jsx | 182 ++++++++++++++++++++- src/views/Things/ThingsView.jsx | 6 +- src/views/Timer/TimerDetails.jsx | 132 +++++++-------- src/views/components/ChoreActionMenu.jsx | 29 ++-- vite.config.js | 6 +- 26 files changed, 877 insertions(+), 447 deletions(-) create mode 100644 src/queries/TimeQueries.jsx diff --git a/android/app/build.gradle b/android/app/build.gradle index 2832749..347ae66 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 10 - versionName "1.0.1" + versionCode 13 + versionName "1.0.4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 26d52b8..35da000 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -355,16 +355,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.0.1; - OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; - PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app; + MARKETING_VERSION = 1.0.4; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -377,12 +374,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = App/App.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 13; DEVELOPMENT_TEAM = 6UJJ78R3BS; INFOPLIST_FILE = App/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; - MARKETING_VERSION = 1.0.1; + MARKETING_VERSION = 1.0.4; PRODUCT_BUNDLE_IDENTIFIER = com.donetick.app; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = ""; diff --git a/src/App.jsx b/src/App.jsx index bbae0a9..4d6f1de 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -10,6 +10,8 @@ import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' import { AuthenticationProvider } from './service/AuthenticationService' import { useNotification } from './service/NotificationProvider' import { apiManager } from './utils/TokenManager' + +import { getSafeBottomPadding } from './utils/SafeAreaUtils' import NetworkBanner from './views/components/NetworkBanner' const add = className => { @@ -87,21 +89,21 @@ const AppContent = () => { }, [needRefresh, showNotification, updateServiceWorker, setNeedRefresh]) return ( - <> +
- +
) } function App() { const navigate = useNavigate() startApiManager(navigate) - startOpenReplay() + // startOpenReplay() const { mode, systemMode } = useColorScheme() @@ -131,7 +133,7 @@ function App() { }, []) return ( -
+
diff --git a/src/CapacitorListener.js b/src/CapacitorListener.js index 7090da7..cbb8071 100644 --- a/src/CapacitorListener.js +++ b/src/CapacitorListener.js @@ -23,8 +23,8 @@ const localNotificationListenerRegistration = () => { const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => { try { - const stored = await Preferences.get({ key: 'deviceRegistration' }) - const lastReg = stored.value ? JSON.parse(stored.value) : null + // const stored = await Preferences.get({ key: 'deviceRegistration' }) + // const lastReg = stored.value ? JSON.parse(stored.value) : null const current = { token: token.value, @@ -34,53 +34,86 @@ const registerTokenIfNeeded = async (token, deviceInfo, deviceId, platform) => { registeredAt: Date.now(), } - const shouldRegister = - !lastReg || - lastReg.token !== current.token || - lastReg.appVersion !== current.appVersion || - Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000 + // const shouldRegister = + // !lastReg || + // lastReg.token !== current.token || + // lastReg.appVersion !== current.appVersion || + // Date.now() - lastReg.registeredAt > 7 * 24 * 60 * 60 * 1000 - if (shouldRegister) { - console.log('Registering device token:', { - reason: !lastReg - ? 'first_time' - : lastReg.token !== current.token - ? 'token_changed' - : lastReg.appVersion !== current.appVersion - ? 'app_updated' - : 'periodic_refresh', - }) + // console.log('Registering device token:', { + // reason: !lastReg + // ? 'first_time' + // : lastReg.token !== current.token + // ? 'token_changed' + // : lastReg.appVersion !== current.appVersion + // ? 'app_updated' + // : 'periodic_refresh', + // }) - const result = await RegisterDeviceToken( - token.value, - deviceId.identifier, - platform, - deviceInfo.appVersion, - deviceInfo.model, - ) - - if (result && !result.error) { - await Preferences.set({ - key: 'deviceRegistration', - value: JSON.stringify(current), - }) - console.log('Device token registered successfully') - } - } else { - console.log('Device token already registered, skipping') - } - } catch (error) { - console.error( - 'Error in token registration check, registering anyway:', - error, - ) - await RegisterDeviceToken( + const result = await RegisterDeviceToken( token.value, deviceId.identifier, platform, deviceInfo.appVersion, deviceInfo.model, ) + + if (result && result.ok) { + await Preferences.set({ + key: 'deviceRegistration', + value: JSON.stringify(current), + }) + console.log('Device token registered successfully') + + // Emit event to notify UI components of successful registration + window.dispatchEvent(new CustomEvent('deviceTokenRegistered')) + } else if (result) { + // Handle registration errors + console.error('Device registration failed:', result.status) + + // Emit event with error details for UI to handle + window.dispatchEvent( + new CustomEvent('deviceTokenRegistrationFailed', { + detail: { + status: result.status, + error: await result.text().catch(() => 'Unknown error'), + }, + }), + ) + } + } catch (error) { + console.error( + 'Error in token registration check, registering anyway:', + error, + ) + const fallbackResult = await RegisterDeviceToken( + token.value, + deviceId.identifier, + platform, + deviceInfo.appVersion, + deviceInfo.model, + ) + + if (fallbackResult && fallbackResult.ok) { + // Emit event to notify UI components of successful registration + window.dispatchEvent(new CustomEvent('deviceTokenRegistered')) + } else if (fallbackResult) { + // Handle registration errors + console.error( + 'Fallback device registration failed:', + fallbackResult.status, + ) + + // Emit event with error details for UI to handle + window.dispatchEvent( + new CustomEvent('deviceTokenRegistrationFailed', { + detail: { + status: fallbackResult.status, + error: await fallbackResult.text().catch(() => 'Unknown error'), + }, + }), + ) + } } } diff --git a/src/contexts/Contexts.jsx b/src/contexts/Contexts.jsx index 1bef341..542795a 100644 --- a/src/contexts/Contexts.jsx +++ b/src/contexts/Contexts.jsx @@ -6,7 +6,7 @@ import SSEProvider from './SSEContext' import ThemeContext from './ThemeContext' import WebSocketProvider from './WebSocketContext' -const Contexts = () => { +const Contexts = ({ children }) => { const contexts = [ AlertsProvider, ThemeContext, @@ -19,7 +19,7 @@ const Contexts = () => { return contexts.reduceRight((acc, Context) => { return {acc} - }, {}) + }, children) } export default Contexts diff --git a/src/contexts/QueryContext.jsx b/src/contexts/QueryContext.jsx index 8de0668..3d775a9 100644 --- a/src/contexts/QueryContext.jsx +++ b/src/contexts/QueryContext.jsx @@ -4,10 +4,10 @@ const QueryContext = ({ children }) => { const queryClient = new QueryClient({ defaultOptions: { queries: { - staleTime: 60000, // 60 seconds - gcTime: 300000, // 5 minutes + staleTime: 300000, // 5 minutes + gcTime: 600000, // 10 minutes refetchOnWindowFocus: false, - retry: 0, + retry: 1, }, }, }) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 68ad1b3..4ba2443 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -3,12 +3,21 @@ import { useState } from 'react' import { networkManager } from '../hooks/NetworkManager' import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle' import { + ApproveChore, + ArchiveChore, CreateChore, + DeleteChoreHistory, GetChoreByID, GetChoreDetailById, + GetChoreHistory, GetChoresHistory, GetChoresNew, + MarkChoreComplete, + RejectChore, SaveChore, + SkipChore, + UnArchiveChore, + UpdateChoreHistory, } from '../utils/Fetcher' import { localStore } from '../utils/LocalStore' @@ -154,6 +163,8 @@ export const useUpdateChore = () => { onSuccess: (data, variables) => { // Invalidate the chores query to refresh the data queryClient.invalidateQueries(['chores']) + // Invalidate history for the specific chore + queryClient.invalidateQueries(['choreHistory', variables.id]) }, onMutate: async updatedChore => { if (!networkManager.isOnline && isFeatureEnabled(FEATURES.OFFLINE_MODE)) { @@ -255,3 +266,123 @@ export const useChore = choreId => { }, }) } + +export const useArchiveChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ArchiveChore, + onSuccess: () => { + queryClient.invalidateQueries(['chores']) + }, + }) +} + +export const useUnArchiveChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: UnArchiveChore, + onSuccess: () => { + queryClient.invalidateQueries(['chores']) + }, + }) +} + +export const useChoreHistory = choreId => { + return useQuery({ + queryKey: ['choreHistory', choreId], + queryFn: async () => { + if (!choreId) { + throw new Error('Chore ID is required to fetch history') + } + const response = await GetChoreHistory(choreId) + if (response && response.ok) { + return await response.json() + } + throw new Error('Failed to fetch chore history') + }, + enabled: !!choreId, + staleTime: 0, // Always consider data stale + cacheTime: 0, // Don't cache the data + refetchOnMount: true, // Always refetch when component mounts + refetchOnWindowFocus: true, // Refetch when window gains focus + }) +} + +export const useUpdateChoreHistory = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ choreId, historyId, historyData }) => + UpdateChoreHistory(choreId, historyId, historyData), + onSuccess: (data, { choreId }) => { + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useDeleteChoreHistory = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ choreId, historyId }) => + DeleteChoreHistory(choreId, historyId), + onSuccess: (data, { choreId }) => { + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useMarkChoreComplete = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ choreId, body, completedDate, performer }) => + MarkChoreComplete(choreId, body, completedDate, performer), + onSuccess: (data, { choreId }) => { + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + queryClient.invalidateQueries(['choreDetails', choreId]) + }, + }) +} + +export const useSkipChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: SkipChore, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + queryClient.invalidateQueries(['choreDetails', choreId]) + }, + }) +} + +export const useApproveChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ApproveChore, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + queryClient.invalidateQueries(['choreDetails', choreId]) + }, + }) +} + +export const useRejectChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: RejectChore, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + queryClient.invalidateQueries(['choreDetails', choreId]) + }, + }) +} diff --git a/src/queries/ResourceQueries.jsx b/src/queries/ResourceQueries.jsx index bcae542..21a3679 100644 --- a/src/queries/ResourceQueries.jsx +++ b/src/queries/ResourceQueries.jsx @@ -3,7 +3,7 @@ import { GetResource } from '../utils/Fetcher' export const useResource = () => { const { data, isLoading, error } = useQuery({ - queryKey: [], + queryKey: ['resource'], queryFn: async () => { const response = await GetResource() return response diff --git a/src/queries/TimeQueries.jsx b/src/queries/TimeQueries.jsx new file mode 100644 index 0000000..009ea99 --- /dev/null +++ b/src/queries/TimeQueries.jsx @@ -0,0 +1,107 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + ClearChoreTimer, + DeleteTimeSession, + GetChoreTimer, + PauseChore, + ResetChoreTimer, + StartChore, + UpdateTimeSession, +} from '../utils/Fetcher' + +export const useChoreTimer = choreId => { + return useQuery({ + queryKey: ['choreTimer', choreId], + queryFn: async () => { + if (!choreId) { + throw new Error('Chore ID is required to fetch timer') + } + const response = await GetChoreTimer(choreId) + if (response && response.ok) { + return await response.json() + } + throw new Error('Failed to fetch chore timer') + }, + enabled: !!choreId, + }) +} + +export const useStartChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: StartChore, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const usePauseChore = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: PauseChore, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useUpdateTimeSession = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ choreId, sessionId, sessionData }) => + UpdateTimeSession(choreId, sessionId, sessionData), + onSuccess: (data, { choreId }) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useDeleteTimeSession = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ choreId, sessionId }) => + DeleteTimeSession(choreId, sessionId), + onSuccess: (data, { choreId }) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useResetChoreTimer = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ResetChoreTimer, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} + +export const useClearChoreTimer = () => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ClearChoreTimer, + onSuccess: (data, choreId) => { + queryClient.invalidateQueries(['choreTimer', choreId]) + queryClient.invalidateQueries(['chores']) + queryClient.invalidateQueries(['choreHistory', choreId]) + }, + }) +} diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index 772f238..a0162a9 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -67,7 +67,7 @@ export const useDeviceTokens = () => { const result = await resp.json() return result.res || [] }, - staleTime: 5 * 60 * 1000, // 5 minutes + staleTime: 0, // Always fetch fresh data gcTime: 10 * 60 * 1000, // 10 minutes }) diff --git a/src/utils/FeatureToggle.js b/src/utils/FeatureToggle.js index adbcc13..bb2f331 100644 --- a/src/utils/FeatureToggle.js +++ b/src/utils/FeatureToggle.js @@ -99,17 +99,20 @@ export const isOfficialDonetickInstance = async () => { */ export const isOfficialDonetickInstanceSync = () => { try { - // Import here to avoid circular dependencies - const { apiManager } = require('../utils/TokenManager') - - const currentApiUrl = apiManager.getApiURL() - - // Check if the API URL contains donetick.com - return currentApiUrl.toLowerCase().includes('donetick.com') + // Dynamic import to avoid circular dependencies + return import('../utils/TokenManager').then(({ apiManager }) => { + const currentApiUrl = apiManager.getApiURL() + // Check if the API URL contains donetick.com + return currentApiUrl.toLowerCase().includes('donetick.com') + }).catch(error => { + console.warn('FeatureToggle: Error checking server instance (sync):', error) + // Default to false for safety (self-hosted assumption) + return false + }) } catch (error) { console.warn('FeatureToggle: Error checking server instance (sync):', error) // Default to false for safety (self-hosted assumption) - return true + return false } } diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx index 98d0065..6331198 100644 --- a/src/utils/TokenManager.jsx +++ b/src/utils/TokenManager.jsx @@ -2,6 +2,7 @@ import { Preferences } from '@capacitor/preferences' import Cookies from 'js-cookie' import murmurhash from 'murmurhash' import { API_URL } from '../Config' +import { FEATURES, isFeatureEnabled } from '../utils/FeatureToggle' import { networkManager } from '../hooks/NetworkManager' import { RefreshToken } from './Fetcher' import { localStore } from './LocalStore' @@ -105,11 +106,14 @@ export async function Fetch(url, options) { const response = await fetch(fullURL, options) if (response.ok) { - const data = await response.clone().json() - const optionWithoutToken = { ...options } - delete optionWithoutToken.headers.Authorization - const optionsHash = murmurhash.v3(JSON.stringify(optionWithoutToken)) - await localStore.saveToCache(fullURL + optionsHash, data) + // Only cache data if offline mode is enabled + if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { + const data = await response.clone().json() + const optionWithoutToken = { ...options } + delete optionWithoutToken.headers.Authorization + const optionsHash = murmurhash.v3(JSON.stringify(optionWithoutToken)) + await localStore.saveToCache(fullURL + optionsHash, data) + } networkManager.setOnline() } else if (response.status === 401) { // Handle 401 Unauthorized @@ -124,15 +128,24 @@ export async function Fetch(url, options) { response.status === 0 ) { networkManager.setOffline() - return handleOfflineRequest(fullURL, options) + // Only handle offline requests if offline mode is enabled + if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { + return handleOfflineRequest(fullURL, options) + } + // If offline mode is disabled, just throw the error + throw new Error(`Request failed with status ${response.status}`) } // return promise that resolves to response object: return Promise.resolve(response) } catch (error) { networkManager.setOffline() console.error('Fetch error:', error) - // throw error - return handleOfflineRequest(fullURL, options) + // Only handle offline requests if offline mode is enabled + if (isFeatureEnabled(FEATURES.OFFLINE_MODE)) { + return handleOfflineRequest(fullURL, options) + } + // If offline mode is disabled, just throw the error + throw error } } @@ -184,6 +197,11 @@ export const refreshAccessToken = () => { } async function handleOfflineRequest(url, options) { + // Only handle offline requests if offline mode is enabled + if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) { + throw new Error('Network request failed and offline mode is disabled') + } + // if get request then attempt to fetch from cache otherewise queue it : if (options.method === 'GET') { return attemptFetchFromCache(url, options) @@ -200,6 +218,11 @@ async function handleOfflineRequest(url, options) { } } async function attemptFetchFromCache(url, options) { + // Only attempt cache fetch if offline mode is enabled + if (!isFeatureEnabled(FEATURES.OFFLINE_MODE)) { + throw new Error('Cache access disabled - offline mode is not enabled') + } + const optionsHash = murmurhash.v3(JSON.stringify(options)) const cachedData = await localStore.getFromCache(url + optionsHash) networkManager.setOffline() diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 923a2e1..a43e299 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -49,18 +49,21 @@ import { ChoreStatus, notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { ApproveChore, - DeleteTimeSession, GetChoreDetailById, - GetChoreTimer, MarkChoreComplete, - PauseChore, RejectChore, - ResetChoreTimer, SkipChore, - StartChore, UpdateChorePriority, } from '../../utils/Fetcher' +import { + useChoreTimer, + useDeleteTimeSession, + usePauseChore, + useResetChoreTimer, + useStartChore, +} from '../../queries/TimeQueries' import Priorities from '../../utils/Priorities' +import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import LoadingComponent from '../components/Loading.jsx' import RichTextEditor from '../components/RichTextEditor.jsx' @@ -95,6 +98,12 @@ const ChoreView = () => { const { data: choreData, isLoading: isChoreLoading } = useChoreDetails(choreId) + const startChore = useStartChore() + const pauseChore = usePauseChore() + const deleteTimeSession = useDeleteTimeSession() + const resetChoreTimer = useResetChoreTimer() + const { data: choreTimer } = useChoreTimer(choreId) + useEffect(() => { if (!choreData || !choreData.res || !circleMembersData) { return @@ -241,30 +250,26 @@ const ChoreView = () => { }) } const handleChoreStart = () => { - StartChore(choreId).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - ...data.res, - } - setChore(newChore) - }) - } + startChore.mutate(choreId, { + onSuccess: data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + }, }) } const handleChorePause = () => { - PauseChore(choreId).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - ...data.res, - } - setChore(newChore) - }) - } + pauseChore.mutate(choreId, { + onSuccess: data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + }, }) } @@ -278,17 +283,14 @@ const ChoreView = () => { cancelText: 'Cancel', onClose: confirmed => { if (confirmed) { - ResetChoreTimer(choreId).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - ...data.res, - } - setChore(newChore) - queryClient.invalidateQueries(['chores']) - }) - } + resetChoreTimer.mutate(choreId, { + onSuccess: data => { + const newChore = { + ...chore, + ...data.res, + } + setChore(newChore) + }, }) } setTimerActionConfig({}) @@ -306,22 +308,19 @@ const ChoreView = () => { cancelText: 'Cancel', onClose: async confirmed => { if (confirmed) { - const resp = await GetChoreTimer(choreId) - if (resp.ok) { - const data = await resp.json() - const sessionId = data?.res?.id - DeleteTimeSession(choreId, sessionId).then(response => { - if (response.ok) { - response.json().then(data => { + if (choreTimer?.res?.id) { + deleteTimeSession.mutate( + { choreId, sessionId: choreTimer.res.id }, + { + onSuccess: data => { const newChore = { ...chore, ...data.res, } setChore(newChore) - queryClient.invalidateQueries(['chores']) - }) - } - }) + }, + }, + ) } } setTimerActionConfig({}) @@ -712,6 +711,7 @@ const ChoreView = () => { p: 2, borderRadius: 'md', boxShadow: 'sm', + paddingBottom: getSafeBottomPadding(2, '8px'), }} variant='soft' > diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index 8931e7b..810d5ba 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -25,14 +25,11 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' +import { useUnArchiveChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { ChoreSorter } from '../../utils/Chores' -import { - DeleteChore, - GetArchivedChores, - UnArchiveChore, -} from '../../utils/Fetcher' +import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher' import LoadingComponent from '../components/Loading' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import ChoreCard from './ChoreCard' @@ -44,6 +41,7 @@ const ArchivedTasks = () => { useUserProfile() const { showSuccess, showError } = useNotification() const { impersonatedUser } = useImpersonateUser() + const unArchiveChore = useUnArchiveChore() const [archivedChores, setArchivedChores] = useState([]) const [filteredChores, setFilteredChores] = useState([]) const [searchTerm, setSearchTerm] = useState('') @@ -317,10 +315,20 @@ const ArchivedTasks = () => { for (const chore of selectedData) { try { - await UnArchiveChore(chore.id) - restoredTasks.push(chore) + await new Promise((resolve, reject) => { + unArchiveChore.mutate(chore.id, { + onSuccess: data => { + restoredTasks.push(chore) + resolve(data) + }, + onError: error => { + failedTasks.push(chore) + reject(error) + }, + }) + }) } catch (error) { - failedTasks.push(chore) + // Error already handled in onError callback } } diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index 6cfa0b8..4448c4f 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -32,6 +32,7 @@ import moment from 'moment' import React from 'react' import { useNavigate } from 'react-router-dom' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' +import { usePauseChore, useStartChore } from '../../queries/TimeQueries' import { useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' import { notInCompletionWindow } from '../../utils/Chores.jsx' @@ -42,9 +43,7 @@ import { DeleteChore, MarkChoreComplete, NudgeChore, - PauseChore, RejectChore, - StartChore, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' @@ -91,6 +90,8 @@ const ChoreCard = ({ const { impersonatedUser } = useImpersonateUser() const { showError, showNotification } = useNotification() + const startChore = useStartChore() + const pauseChore = usePauseChore() // Swipe functionality state const [swipeTranslateX, setSwipeTranslateX] = React.useState(0) @@ -109,7 +110,7 @@ const ChoreCard = ({ setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) } checkTouchDevice() - + // Check if this is the official donetick.com instance try { setIsOfficialInstance(isOfficialDonetickInstanceSync()) @@ -277,7 +278,10 @@ const ChoreCard = ({ const handleNudge = async ({ choreId, message, notifyAllAssignees }) => { try { - const response = await NudgeChore(choreId, { message, notifyAllAssignees }) + const response = await NudgeChore(choreId, { + message, + notifyAllAssignees, + }) if (response.ok) { const data = await response.json() showNotification({ @@ -490,30 +494,26 @@ const ChoreCard = ({ // Handlers for start/pause/complete functionality const handleChorePause = () => { - PauseChore(chore.id).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - status: data.res.status, - } - onChoreUpdate(newChore, 'paused') - }) - } + pauseChore.mutate(chore.id, { + onSuccess: data => { + const newChore = { + ...chore, + status: data.res.status, + } + onChoreUpdate(newChore, 'paused') + }, }) } const handleChoreStart = () => { - StartChore(chore.id).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - status: data.res.status, - } - onChoreUpdate(newChore, 'started') - }) - } + startChore.mutate(chore.id, { + onSuccess: data => { + const newChore = { + ...chore, + status: data.res.status, + } + onChoreUpdate(newChore, 'started') + }, }) } @@ -836,7 +836,7 @@ const ChoreCard = ({ -{isOfficialInstance && ( + {isOfficialInstance && ( { - PauseChore(chore.id).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - ...data.res, - } - onChoreUpdate(newChore, 'paused') - }) - } + pauseChore.mutate(chore.id, { + onSuccess: data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'paused') + }, }) } const handleChoreStart = () => { - StartChore(chore.id).then(response => { - if (response.ok) { - response.json().then(data => { - const newChore = { - ...chore, - ...data.res, - } - onChoreUpdate(newChore, 'started') - }) - } + startChore.mutate(chore.id, { + onSuccess: data => { + const newChore = { + ...chore, + ...data.res, + } + onChoreUpdate(newChore, 'started') + }, }) } diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 6bf80f4..1043e52 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -39,10 +39,9 @@ import { import Fuse from 'fuse.js' import { useEffect, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { useChores } from '../../queries/ChoreQueries' +import { useChores, useArchiveChore } from '../../queries/ChoreQueries' import { useNotification } from '../../service/NotificationProvider' import { TASK_COLOR } from '../../utils/Colors' -import { ArchiveChore } from '../../utils/Fetcher' import Priorities from '../../utils/Priorities' import LoadingComponent from '../components/Loading' import { useLabels } from '../Labels/LabelQueries' @@ -76,6 +75,7 @@ const MyChores = () => { const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) const { showSuccess, showError, showWarning } = useNotification() const { impersonatedUser } = useImpersonateUser() + const archiveChore = useArchiveChore() const [chores, setChores] = useState([]) const [filteredChores, setFilteredChores] = useState([]) const [searchFilter, setSearchFilter] = useState('All') @@ -920,13 +920,23 @@ const MyChores = () => { const failedTasks = [] for (const chore of selectedData) { try { - const archivedChore = await ArchiveChore(chore.id) - archivedTasks.push(archivedChore) - // Remove from chores and filteredChores - setChores(chores.filter(c => c.id !== chore.id)) - setFilteredChores(filteredChores.filter(c => c.id !== chore.id)) + await new Promise((resolve, reject) => { + archiveChore.mutate(chore.id, { + onSuccess: (data) => { + archivedTasks.push(data) + // Remove from chores and filteredChores + setChores(prev => prev.filter(c => c.id !== chore.id)) + setFilteredChores(prev => prev.filter(c => c.id !== chore.id)) + resolve(data) + }, + onError: (error) => { + failedTasks.push(chore) + reject(error) + } + }) + }) } catch (error) { - failedTasks.push(chore) + // Error already handled in onError callback } } if (archivedTasks.length > 0) { diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx index 90b7453..037824a 100644 --- a/src/views/History/ChoreHistory.jsx +++ b/src/views/History/ChoreHistory.jsx @@ -24,39 +24,41 @@ import { Link, useParams } from 'react-router-dom' import useConfirmationModal from '../../hooks/useConfirmationModal' import { ChoreHistoryStatus } from '../../utils/Chores' import { - DeleteChoreHistory, - GetAllCircleMembers, - GetChoreHistory, - UpdateChoreHistory, -} from '../../utils/Fetcher' + useChoreHistory, + useDeleteChoreHistory, + useUpdateChoreHistory, +} from '../../queries/ChoreQueries' +import { useCircleMembers } from '../../queries/UserQueries' import LoadingComponent from '../components/Loading' import EditHistoryModal from '../Modals/EditHistoryModal' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import HistoryCard from './HistoryCard' const ChoreHistory = () => { - const [choreHistory, setChoresHistory] = useState([]) const [userHistory, setUserHistory] = useState([]) - const [performers, setPerformers] = useState([]) const [historyInfo, setHistoryInfo] = useState([]) - - const [isLoading, setIsLoading] = useState(true) // Add loading state const { choreId } = useParams() const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [editHistory, setEditHistory] = useState({}) const { confirmModalConfig, showConfirmation } = useConfirmationModal() + // React Query hooks + const { data: choreHistoryData, isLoading } = useChoreHistory(choreId) + const { data: circleMembersData } = useCircleMembers() + const updateChoreHistory = useUpdateChoreHistory() + const deleteChoreHistory = useDeleteChoreHistory() + + const choreHistory = choreHistoryData?.res || [] + const performers = circleMembersData?.res || [] + const handleDelete = historyEntry => { showConfirmation( `Are you sure you want to delete this history record?`, 'Delete History Record', () => { - DeleteChoreHistory(choreId, historyEntry.id).then(() => { - const newHistory = choreHistory.filter( - record => record.id !== historyEntry.id, - ) - setChoresHistory(newHistory) - updateHistoryInfo(newHistory, userHistory, performers) + deleteChoreHistory.mutate({ + choreId, + historyId: historyEntry.id, }) }, 'Delete', @@ -71,33 +73,16 @@ const ChoreHistory = () => { } useEffect(() => { - setIsLoading(true) // Start loading - - Promise.all([ - GetChoreHistory(choreId).then(res => res.json()), - GetAllCircleMembers(), - ]) - .then(([historyData, usersData]) => { - setChoresHistory(historyData.res) - - const newUserChoreHistory = {} - historyData.res.forEach(choreHistory => { - const userId = choreHistory.completedBy - newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1 - }) - setUserHistory(newUserChoreHistory) - - setPerformers(usersData.res) - updateHistoryInfo(historyData.res, newUserChoreHistory, usersData.res) + if (choreHistory.length > 0 && performers.length > 0) { + const newUserChoreHistory = {} + choreHistory.forEach(historyEntry => { + const userId = historyEntry.completedBy + newUserChoreHistory[userId] = (newUserChoreHistory[userId] || 0) + 1 }) - .catch(error => { - console.error('Error fetching data:', error) - // Handle errors, e.g., show an error message to the user - }) - .finally(() => { - setIsLoading(false) // Finish loading - }) - }, [choreId]) + setUserHistory(newUserChoreHistory) + updateHistoryInfo(choreHistory, newUserChoreHistory, performers) + } + }, [choreHistory, performers]) const updateHistoryInfo = (histories, userHistories, performers) => { // average delay for task completaion from due date: @@ -327,35 +312,39 @@ const ChoreHistory = () => { setIsEditModalOpen(false) }, onSave: updated => { - UpdateChoreHistory(choreId, editHistory.id, { - performedAt: updated.performedAt, - dueDate: updated.dueDate, - notes: updated.notes, - }).then(res => { - if (!res.ok) { - console.error('Failed to update chore history:', res) - return - } - - const newRecord = res.json().then(data => { - const newRecord = data.res - const newHistory = choreHistory.map(record => - record.id === newRecord.id ? newRecord : record, - ) - setChoresHistory(newHistory) - setEditHistory(newRecord) - setIsEditModalOpen(false) - }) - }) + updateChoreHistory.mutate( + { + choreId, + historyId: editHistory.id, + historyData: { + performedAt: updated.performedAt, + dueDate: updated.dueDate, + notes: updated.notes, + }, + }, + { + onSuccess: data => { + setEditHistory(data.res) + setIsEditModalOpen(false) + }, + onError: error => { + console.error('Failed to update chore history:', error) + }, + }, + ) }, onDelete: () => { - DeleteChoreHistory(choreId, editHistory.id).then(() => { - const newHistory = choreHistory.filter( - record => record.id !== editHistory.id, - ) - setChoresHistory(newHistory) - setIsEditModalOpen(false) - }) + deleteChoreHistory.mutate( + { + choreId, + historyId: editHistory.id, + }, + { + onSuccess: () => { + setIsEditModalOpen(false) + }, + }, + ) }, }} historyRecord={editHistory} diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index 642f9d8..e54e212 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -502,30 +502,23 @@ const HistoryCard = ({ > } + variant='solid' + color='success' + startDecorator={} > - {performer?.displayName || 'Unknown'} + Done by {performer?.displayName || 'Unknown'} {historyEntry.completedBy !== historyEntry.assignedTo && assignedTo && ( - <> - - → - - } - > - {assignedTo.displayName} - - + } + > + Assigned to {assignedTo.displayName} + )} {historyEntry.notes && ( diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 2e4fb46..b8b31a6 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -21,7 +21,7 @@ import LABEL_COLORS, { getTextColorFromBackgroundColor, } from '../../utils/Colors' import { DeleteLabel } from '../../utils/Fetcher' -import { getSafeBottom, getSafeBottomStyles } from '../../utils/SafeAreaUtils' +import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import { useLabels } from './LabelQueries' @@ -222,7 +222,7 @@ const LabelCard = ({ label, onEditClick, onDeleteClick, currentUserId }) => { position: 'absolute', right: 0, top: 0, - bottom: getSafeBottom(), + bottom: 0, width: maxSwipeDistance, display: 'flex', alignItems: 'center', diff --git a/src/views/Modals/Inputs/TimerEditModal.jsx b/src/views/Modals/Inputs/TimerEditModal.jsx index fa27ec8..d6b4b2e 100644 --- a/src/views/Modals/Inputs/TimerEditModal.jsx +++ b/src/views/Modals/Inputs/TimerEditModal.jsx @@ -15,10 +15,10 @@ import { useEffect, useState } from 'react' import { useResponsiveModal } from '../../../hooks/useResponsiveModal' import { useNotification } from '../../../service/NotificationProvider' import { - DeleteTimeSession, - GetChoreTimer, - UpdateTimeSession, -} from '../../../utils/Fetcher' + useChoreTimer, + useDeleteTimeSession, + useUpdateTimeSession, +} from '../../../queries/TimeQueries' import ConfirmationModal from './ConfirmationModal' const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { @@ -31,13 +31,17 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { const [currentTime, setCurrentTime] = useState(new Date()) const { showError, showSuccess } = useNotification() - // Fetch timer data when modal opens + // Timer hooks + const { data: choreTimer, refetch: refetchTimer } = useChoreTimer(choreId) + const updateTimeSession = useUpdateTimeSession() + const deleteTimeSession = useDeleteTimeSession() + + // Update timerData when choreTimer data changes useEffect(() => { - if (isOpen && choreId) { - fetchTimerData() + if (choreTimer?.res) { + setTimerData(choreTimer.res) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, choreId]) + }, [choreTimer]) // Real-time update interval for active timers useEffect(() => { @@ -53,28 +57,6 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { } }, [isOpen, timerData]) - const fetchTimerData = async () => { - setLoading(true) - try { - const response = await GetChoreTimer(choreId) - if (response.ok) { - const data = await response.json() - setTimerData(data.res) // data.res is the timer session object - } else { - showError({ - title: 'Failed to fetch timer data', - message: 'Please try again.', - }) - } - } catch (error) { - showError({ - title: 'Error fetching timer data', - message: error.message, - }) - } finally { - setLoading(false) - } - } const formatTime = seconds => { const hours = Math.floor(seconds / 3600) @@ -192,21 +174,26 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { pauseLog: editingData.pauseLog, } - const response = await UpdateTimeSession(choreId, sessionId, updateData) - if (response.ok) { - showSuccess({ - title: 'Session updated', - message: 'Timer session has been updated successfully.', - }) - await fetchTimerData() - cancelEditingSession(sessionId) - onTimerUpdate?.() - } else { - showError({ - title: 'Failed to update session', - message: 'Please try again.', - }) - } + updateTimeSession.mutate( + { choreId, sessionId, sessionData: updateData }, + { + onSuccess: () => { + showSuccess({ + title: 'Session updated', + message: 'Timer session has been updated successfully.', + }) + refetchTimer() + cancelEditingSession(sessionId) + onTimerUpdate?.() + }, + onError: () => { + showError({ + title: 'Failed to update session', + message: 'Please try again.', + }) + }, + }, + ) } catch (error) { showError({ title: 'Error updating session', @@ -219,29 +206,28 @@ const TimerEditModal = ({ isOpen, onClose, choreId, onTimerUpdate }) => { const deleteSession = async sessionId => { setLoading(true) - try { - const response = await DeleteTimeSession(choreId, sessionId) - if (response.ok) { - showSuccess({ - title: 'Session deleted', - message: 'Timer session has been deleted successfully.', - }) - await fetchTimerData() - onTimerUpdate?.() - } else { - showError({ - title: 'Failed to delete session', - message: 'Please try again.', - }) - } - } catch (error) { - showError({ - title: 'Error deleting session', - message: error.message, - }) - } finally { - setLoading(false) - } + deleteTimeSession.mutate( + { choreId, sessionId }, + { + onSuccess: () => { + showSuccess({ + title: 'Session deleted', + message: 'Timer session has been deleted successfully.', + }) + refetchTimer() + onTimerUpdate?.() + }, + onError: error => { + showError({ + title: 'Error deleting session', + message: error.message, + }) + }, + onSettled: () => { + setLoading(false) + }, + }, + ) } const confirmDeleteSession = sessionId => { diff --git a/src/views/Settings/NotificationSetting.jsx b/src/views/Settings/NotificationSetting.jsx index c219260..ffb042a 100644 --- a/src/views/Settings/NotificationSetting.jsx +++ b/src/views/Settings/NotificationSetting.jsx @@ -1,4 +1,5 @@ import { Capacitor } from '@capacitor/core' +import { Device } from '@capacitor/device' import { LocalNotifications } from '@capacitor/local-notifications' import { Preferences } from '@capacitor/preferences' import { Android, Apple } from '@mui/icons-material' @@ -72,6 +73,9 @@ const NotificationSetting = () => { const [naggingNotification, setNaggingNotification] = useState(false) const [pushNotification, setPushNotification] = useState(false) const [isOfficialInstance, setIsOfficialInstance] = useState(false) + const [currentDevice, setCurrentDevice] = useState(null) + const [isCurrentDeviceRegistered, setIsCurrentDeviceRegistered] = + useState(true) useEffect(() => { getNotificationPreferences().then(resp => { @@ -95,6 +99,28 @@ const NotificationSetting = () => { console.warn('Error checking instance type:', error) setIsOfficialInstance(false) } + + // Get current device info if on native platform + if (Capacitor.isNativePlatform()) { + const getCurrentDeviceInfo = async () => { + try { + const deviceInfo = await Device.getInfo() + const deviceId = await Device.getId() + const platform = + Capacitor.getPlatform() === 'android' ? 'android' : 'ios' + + setCurrentDevice({ + id: deviceId.identifier, + platform, + model: deviceInfo.model, + appVersion: deviceInfo.appVersion, + }) + } catch (error) { + console.error('Error getting device info:', error) + } + } + getCurrentDeviceInfo() + } }, []) const [notificationTarget, setNotificationTarget] = useState( @@ -107,6 +133,64 @@ const NotificationSetting = () => { userProfile?.notification_target?.target_id ?? 0, ) const [error, setError] = useState('') + + // Check if current device is registered whenever deviceTokens or currentDevice changes + useEffect(() => { + if (currentDevice && deviceTokens && isOfficialInstance) { + const isRegistered = deviceTokens.some( + device => device.deviceId === currentDevice.id, + ) + setIsCurrentDeviceRegistered(isRegistered) + } + }, [currentDevice, deviceTokens, isOfficialInstance]) + + // Listen for device registration events from CapacitorListener + useEffect(() => { + const handleDeviceRegistered = () => { + refetchDevices() + showWarning({ + title: 'Success', + message: 'Device registered successfully for push notifications.', + }) + } + + const handleDeviceRegistrationFailed = event => { + const { status, error } = event.detail || {} + + if (status === 409) { + showWarning({ + title: 'Device Limit Reached', + message: + 'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.', + }) + } else { + showWarning({ + title: 'Registration Failed', + message: + error || + 'Failed to register device automatically. Please try again.', + }) + } + } + + // Listen for the custom events that CapacitorListener might emit + window.addEventListener('deviceTokenRegistered', handleDeviceRegistered) + window.addEventListener( + 'deviceTokenRegistrationFailed', + handleDeviceRegistrationFailed, + ) + + return () => { + window.removeEventListener( + 'deviceTokenRegistered', + handleDeviceRegistered, + ) + window.removeEventListener( + 'deviceTokenRegistrationFailed', + handleDeviceRegistrationFailed, + ) + } + }, [refetchDevices, showWarning]) const SaveValidation = () => { switch (notificationTarget) { case '1': @@ -146,6 +230,55 @@ const NotificationSetting = () => { alert('Notification target updated') }) } + + const handleRegisterCurrentDevice = async () => { + if (!currentDevice) return + + // Check device limit before attempting registration + const currentDeviceCount = deviceTokens ? deviceTokens.length : 0 + if (currentDeviceCount >= 5) { + showWarning({ + title: 'Device Limit Reached', + message: + 'You have reached the maximum limit of 5 registered devices. Please remove a device before registering this one.', + }) + return + } + + try { + // First request push notification permission + const permStatus = await PushNotifications.requestPermissions() + + if (permStatus.receive !== 'granted') { + showWarning({ + title: 'Permission Required', + message: + 'Push notification permission is required to register this device.', + }) + return + } + + // Ensure push notification listeners are set up before registration + + await registerPushNotifications() + + // Store registration preferences immediately since permission was granted + await setPushNotificationPreferences({ granted: true }) + setPushNotification(true) + + showWarning({ + title: 'Registration Initiated', + message: + 'Push notification registration has been initiated. The device will be registered automatically.', + }) + } catch (error) { + console.error('Error registering device:', error) + showWarning({ + title: 'Error', + message: 'Failed to register device. Please try again.', + }) + } + } return (
@@ -344,13 +477,60 @@ const NotificationSetting = () => { {isOfficialInstance && ( <> - Registered Devices + Registered Devices ({deviceTokens ? deviceTokens.length : 0}/5) Devices registered to receive push notifications for your account + {/* Show register current device option if not registered */} + {Capacitor.isNativePlatform() && + currentDevice && + !isCurrentDeviceRegistered && ( + + + + {currentDevice.platform === 'ios' ? ( + + ) : ( + + )} + + + Current Device:{' '} + {currentDevice.platform === 'ios' ? 'iOS' : 'Android'}{' '} + {currentDevice.model} + + + This device is not registered for push notifications + + + + + + + )} + {deviceTokens && deviceTokens.length > 0 ? ( {deviceTokens.map(device => ( diff --git a/src/views/Things/ThingsView.jsx b/src/views/Things/ThingsView.jsx index 73ecba5..0e4791f 100644 --- a/src/views/Things/ThingsView.jsx +++ b/src/views/Things/ThingsView.jsx @@ -27,6 +27,7 @@ import { SaveThing, UpdateThingState, } from '../../utils/Fetcher' +import { getSafeBottomStyles } from '../../utils/SafeAreaUtils' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import CreateThingModal from '../Modals/Inputs/CreateThingModal' import EditThingStateModal from '../Modals/Inputs/EditThingState' @@ -724,14 +725,11 @@ const ThingsView = () => { diff --git a/src/views/Timer/TimerDetails.jsx b/src/views/Timer/TimerDetails.jsx index 9f1a0cd..3b0d6bb 100644 --- a/src/views/Timer/TimerDetails.jsx +++ b/src/views/Timer/TimerDetails.jsx @@ -30,11 +30,11 @@ import { useParams } from 'react-router-dom' import { useCircleMembers } from '../../queries/UserQueries' import { useNotification } from '../../service/NotificationProvider' import { - GetChoreTimer, - PauseChore, - StartChore, - UpdateTimeSession, -} from '../../utils/Fetcher' + useChoreTimer, + usePauseChore, + useStartChore, + useUpdateTimeSession, +} from '../../queries/TimeQueries' import { resolvePhotoURL } from '../../utils/Helpers' import { getSafeBottom } from '../../utils/SafeAreaUtils' import LoadingComponent from '../components/Loading' @@ -60,6 +60,12 @@ const TimerDetails = () => { const { data: circleMembersData, isLoading: isCircleMembersLoading } = useCircleMembers() + // Timer hooks + const { data: choreTimer, refetch: refetchTimer } = useChoreTimer(choreId) + const startChore = useStartChore() + const pauseChore = usePauseChore() + const updateTimeSession = useUpdateTimeSession() + const members = circleMembersData?.res || [] // Helper function to find member by user ID @@ -75,13 +81,12 @@ const TimerDetails = () => { checkTouchDevice() }, []) - // Fetch timer data when component mounts + // Update timerData when choreTimer data changes useEffect(() => { - if (choreId) { - fetchTimerData() + if (choreTimer?.res) { + setTimerData(choreTimer.res) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [choreId]) + }, [choreTimer]) // Real-time update interval for active timers useEffect(() => { @@ -97,28 +102,6 @@ const TimerDetails = () => { } }, [timerData]) - const fetchTimerData = async () => { - setLoading(true) - try { - const response = await GetChoreTimer(choreId) - if (response.ok) { - const data = await response.json() - setTimerData(data.res) - } else { - showError({ - title: 'Failed to fetch timer data', - message: 'Please try again.', - }) - } - } catch (error) { - showError({ - title: 'Error fetching timer data', - message: error.message, - }) - } finally { - setLoading(false) - } - } const formatTime = seconds => { const hours = Math.floor(seconds / 3600) @@ -236,20 +219,25 @@ const TimerDetails = () => { pauseLog: editingData.pauseLog, } - const response = await UpdateTimeSession(choreId, sessionId, updateData) - if (response.ok) { - showSuccess({ - title: 'Session updated', - message: 'Timer session has been updated successfully.', - }) - await fetchTimerData() - cancelEditingSession(sessionId) - } else { - showError({ - title: 'Failed to update session', - message: 'Please try again.', - }) - } + updateTimeSession.mutate( + { choreId, sessionId, sessionData: updateData }, + { + onSuccess: () => { + showSuccess({ + title: 'Session updated', + message: 'Timer session has been updated successfully.', + }) + refetchTimer() + cancelEditingSession(sessionId) + }, + onError: () => { + showError({ + title: 'Failed to update session', + message: 'Please try again.', + }) + }, + }, + ) } catch (error) { showError({ title: 'Error updating session', @@ -261,56 +249,48 @@ const TimerDetails = () => { } // Timer control functions - const handleStartTimer = async () => { + const handleStartTimer = () => { setTimerActionLoading(true) - try { - const response = await StartChore(choreId) - if (response.ok) { + startChore.mutate(choreId, { + onSuccess: () => { showSuccess({ title: 'Timer Started', message: 'Work session has been started successfully.', }) - await fetchTimerData() - } else { + refetchTimer() + }, + onError: () => { showError({ title: 'Failed to start timer', message: 'Please try again.', }) - } - } catch (error) { - showError({ - title: 'Error starting timer', - message: error.message, - }) - } finally { - setTimerActionLoading(false) - } + }, + onSettled: () => { + setTimerActionLoading(false) + }, + }) } - const handlePauseTimer = async () => { + const handlePauseTimer = () => { setTimerActionLoading(true) - try { - const response = await PauseChore(choreId) - if (response.ok) { + pauseChore.mutate(choreId, { + onSuccess: () => { showSuccess({ title: 'Timer Paused', message: 'Work session has been paused.', }) - await fetchTimerData() - } else { + refetchTimer() + }, + onError: () => { showError({ title: 'Failed to pause timer', message: 'Please try again.', }) - } - } catch (error) { - showError({ - title: 'Error pausing timer', - message: error.message, - }) - } finally { - setTimerActionLoading(false) - } + }, + onSettled: () => { + setTimerActionLoading(false) + }, + }) } // Determine if timer is currently running diff --git a/src/views/components/ChoreActionMenu.jsx b/src/views/components/ChoreActionMenu.jsx index 546fc8a..8794074 100644 --- a/src/views/components/ChoreActionMenu.jsx +++ b/src/views/components/ChoreActionMenu.jsx @@ -26,12 +26,11 @@ import { useNavigate } from 'react-router-dom' import { useNotification } from '../../service/NotificationProvider' import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import { - ArchiveChore, DeleteChore, SkipChore, - UnArchiveChore, UpdateDueDate, } from '../../utils/Fetcher' +import { useArchiveChore, useUnArchiveChore } from '../../queries/ChoreQueries' const ChoreActionMenu = ({ chore, @@ -55,6 +54,8 @@ const ChoreActionMenu = ({ const menuRef = React.useRef(null) const navigate = useNavigate() const { showError } = useNotification() + const archiveChore = useArchiveChore() + const unArchiveChore = useUnArchiveChore() // Check if this is the official donetick.com instance useEffect(() => { @@ -126,22 +127,18 @@ const ChoreActionMenu = ({ const handleArchive = () => { if (chore.isActive) { - ArchiveChore(chore.id).then(response => { - if (response.ok) { - response.json().then(() => { - const newChore = { ...chore, isActive: false } - onChoreUpdate?.(newChore, 'archive') - }) - } + archiveChore.mutate(chore.id, { + onSuccess: () => { + const newChore = { ...chore, isActive: false } + onChoreUpdate?.(newChore, 'archive') + }, }) } else { - UnArchiveChore(chore.id).then(response => { - if (response.ok) { - response.json().then(() => { - const newChore = { ...chore, isActive: true } - onChoreUpdate?.(newChore, 'unarchive') - }) - } + unArchiveChore.mutate(chore.id, { + onSuccess: () => { + const newChore = { ...chore, isActive: true } + onChoreUpdate?.(newChore, 'unarchive') + }, }) } handleMenuClose() diff --git a/vite.config.js b/vite.config.js index 6d6044b..593b81d 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,4 +1,4 @@ -import react from '@vitejs/plugin-react-swc' +import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' // https://vitejs.dev/config/ @@ -14,10 +14,6 @@ export default defineConfig({ 'safari-pinned-tab.svg', 'mstile-150x150.png', ], - injectManifest: { - globPatterns: ['**/*.{js,css,html,png,svg}'], - globIgnores: ['index.html'], - }, manifest: { name: 'Donetick: Simplify Tasks & Chores, Together.', short_name: 'Donetick',