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.
This commit is contained in:
Mo Tarbin
2025-09-21 12:46:03 -04:00
parent 79dfb11fa4
commit f27bbd318f
26 changed files with 877 additions and 447 deletions

View File

@@ -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.

View File

@@ -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 = "";

View File

@@ -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 (
<>
<div style={{ paddingBottom: getSafeBottomPadding(2) }}>
<ImpersonateUserProvider>
<NavBar />
<PageTransition>
<Outlet />
</PageTransition>
</ImpersonateUserProvider>
</>
</div>
)
}
function App() {
const navigate = useNavigate()
startApiManager(navigate)
startOpenReplay()
// startOpenReplay()
const { mode, systemMode } = useColorScheme()
@@ -131,7 +133,7 @@ function App() {
}, [])
return (
<div className='min-h-screen'>
<div style={{ paddingBottom: getSafeBottomPadding(2) }}>
<NetworkBanner />
<AuthenticationProvider>

View File

@@ -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'),
},
}),
)
}
}
}

View File

@@ -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 <Context>{acc}</Context>
}, {})
}, children)
}
export default Contexts

View File

@@ -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,
},
},
})

View File

@@ -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])
},
})
}

View File

@@ -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

107
src/queries/TimeQueries.jsx Normal file
View File

@@ -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])
},
})
}

View File

@@ -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
})

View File

@@ -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
}
}

View File

@@ -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()

View File

@@ -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'
>

View File

@@ -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
}
}

View File

@@ -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 = ({
<Edit sx={{ fontSize: 20 }} />
</IconButton>
{isOfficialInstance && (
{isOfficialInstance && (
<IconButton
variant='soft'
color='warning'

View File

@@ -41,12 +41,11 @@ import {
DeleteChore,
MarkChoreComplete,
NudgeChore,
PauseChore,
RejectChore,
StartChore,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../utils/Fetcher'
import { usePauseChore, useStartChore } from '../../queries/TimeQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import DateModal from '../Modals/Inputs/DateModal'
import NudgeModal from '../Modals/Inputs/NudgeModal'
@@ -81,6 +80,8 @@ const CompactChoreCard = ({
const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false)
const [isOfficialInstance, setIsOfficialInstance] = React.useState(false)
const navigate = useNavigate()
const startChore = useStartChore()
const pauseChore = usePauseChore()
const [isPendingCompletion, setIsPendingCompletion] = React.useState(false)
const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null)
@@ -656,29 +657,25 @@ const CompactChoreCard = ({
}
const handleChorePause = () => {
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')
},
})
}

View File

@@ -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) {

View File

@@ -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}

View File

@@ -502,30 +502,23 @@ const HistoryCard = ({
>
<Chip
size='sm'
variant='outlined'
startDecorator={<Person />}
variant='solid'
color='success'
startDecorator={<CheckCircle />}
>
{performer?.displayName || 'Unknown'}
Done by {performer?.displayName || 'Unknown'}
</Chip>
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
</Typography>
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<CheckCircle />}
>
{assignedTo.displayName}
</Chip>
</>
<Chip
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Person />}
>
Assigned to {assignedTo.displayName}
</Chip>
)}
{historyEntry.notes && (

View File

@@ -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',

View File

@@ -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 => {

View File

@@ -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 (
<SettingsLayout title='Notification Settings'>
<div className='grid gap-4 py-4' id='notifications'>
@@ -344,13 +477,60 @@ const NotificationSetting = () => {
{isOfficialInstance && (
<>
<Typography level='h4' sx={{ mt: 2 }}>
Registered Devices
Registered Devices ({deviceTokens ? deviceTokens.length : 0}/5)
</Typography>
<Divider />
<Typography level='body-md' sx={{ mb: 2 }}>
Devices registered to receive push notifications for your account
</Typography>
{/* Show register current device option if not registered */}
{Capacitor.isNativePlatform() &&
currentDevice &&
!isCurrentDeviceRegistered && (
<Card
variant='outlined'
sx={{ p: 2, mb: 2, bgcolor: 'background.level1' }}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
{currentDevice.platform === 'ios' ? (
<Apple sx={{ fontSize: 24, color: '#007AFF' }} />
) : (
<Android sx={{ fontSize: 24, color: '#3DDC84' }} />
)}
<Box>
<Typography level='body-md' sx={{ fontWeight: 'bold' }}>
Current Device:{' '}
{currentDevice.platform === 'ios' ? 'iOS' : 'Android'}{' '}
{currentDevice.model}
</Typography>
<Typography level='body-sm' color='neutral'>
This device is not registered for push notifications
</Typography>
</Box>
</Box>
<Button
variant='solid'
color='primary'
size='sm'
disabled={deviceTokens && deviceTokens.length >= 5}
onClick={handleRegisterCurrentDevice}
>
{deviceTokens && deviceTokens.length >= 5
? 'Limit Reached'
: 'Register Device'}
</Button>
</Box>
</Card>
)}
{deviceTokens && deviceTokens.length > 0 ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{deviceTokens.map(device => (

View File

@@ -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 = () => {
<Box
// variant='outlined'
sx={{
position: 'fixed',
bottom: 0,
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
left: 10,
p: 2, // padding
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>

View File

@@ -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

View File

@@ -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()

View File

@@ -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',