Merge pull request #124 from donetick/bug-fixes-06-27-2026
Bug fixes 06 27 2026
This commit is contained in:
@@ -39,7 +39,7 @@ export const AuthProvider = ({ children }) => {
|
||||
// Ensure apiClient is initialized with the correct URL
|
||||
await apiClient.init()
|
||||
const currentBaseURL = apiClient.getApiURL()
|
||||
|
||||
|
||||
const isNative =
|
||||
typeof window !== 'undefined' && window.Capacitor?.isNativePlatform?.()
|
||||
|
||||
@@ -57,8 +57,8 @@ export const AuthProvider = ({ children }) => {
|
||||
const response = await fetch(`${currentBaseURL}/auth/login`, config)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
return { success: false, error: error.message || 'Login failed' }
|
||||
const res = await response.json()
|
||||
return { success: false, error: res?.error || 'Login failed' }
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
@@ -8,9 +8,9 @@ import { isOfflineFeatureEnabled } from '../utils/OfflineFeatureToggle'
|
||||
import { syncEngine } from '../utils/SyncEngine'
|
||||
import { networkManager } from './NetworkManager'
|
||||
|
||||
const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const PENDING_POLL_MS = 30_000 // retry pending commands every 30s
|
||||
export const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
|
||||
const CACHE_REFRESH_MS = 5 * 60_000 // refresh IDB cache every 5 min while online
|
||||
const SERVER_PROBE_MS = 15_000 // probe server when marked unreachable but device has network
|
||||
|
||||
export function useSyncOnReconnect() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import { Preferences } from '@capacitor/preferences'
|
||||
import { Box, Button, Container, Input, Sheet, Typography } from '@mui/joy'
|
||||
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||
import WifiIcon from '@mui/icons-material/Wifi'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Input,
|
||||
Sheet,
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import React from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { API_URL } from '../../Config'
|
||||
import Logo from '../../Logo'
|
||||
import { useResource } from '../../queries/ResourceQueries'
|
||||
import { useNotification } from '../../service/NotificationProvider'
|
||||
import { apiClient } from '../../utils/ApiClient'
|
||||
|
||||
const CONNECTION_TIMEOUT_MS = 8000
|
||||
|
||||
const LoginSettings = () => {
|
||||
const Navigate = useNavigate()
|
||||
const { refetch: refetchResource } = useResource()
|
||||
const [serverURL, setServerURL] = React.useState('')
|
||||
const { showError } = useNotification()
|
||||
const [status, setStatus] = React.useState('idle') // 'idle' | 'testing' | 'success' | 'error'
|
||||
const [errorMessage, setErrorMessage] = React.useState('')
|
||||
|
||||
React.useEffect(() => {
|
||||
Preferences.get({ key: 'customServerUrl' }).then(result => {
|
||||
@@ -19,10 +34,95 @@ const LoginSettings = () => {
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isValidServerURL = () => {
|
||||
return serverURL.match(/^(http|https):\/\/[^ "]+$/)
|
||||
const isValidURL = url => {
|
||||
return /^(http|https):\/\/[^ "]+$/.test(url.trim())
|
||||
}
|
||||
|
||||
const testConnection = async url => {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
CONNECTION_TIMEOUT_MS,
|
||||
)
|
||||
try {
|
||||
const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
|
||||
const response = await fetch(testURL, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
// Any HTTP response (even 401/404) means the server is reachable
|
||||
if (response.status < 500) {
|
||||
return { ok: true }
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: `Server responded with error ${response.status}. Please check your Donetick server.`,
|
||||
}
|
||||
} catch (err) {
|
||||
clearTimeout(timeoutId)
|
||||
if (err.name === 'AbortError') {
|
||||
return {
|
||||
ok: false,
|
||||
message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. Check the URL and ensure the server is running.`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
'Unable to reach the server. Check the URL, port, and network connection.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmedURL = serverURL.trim()
|
||||
|
||||
if (trimmedURL === '') {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
Navigate('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (!isValidURL(trimmedURL)) {
|
||||
setStatus('error')
|
||||
setErrorMessage(
|
||||
'Invalid URL format. Include the protocol (http:// or https://) and port if needed.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('testing')
|
||||
setErrorMessage('')
|
||||
|
||||
const result = await testConnection(trimmedURL)
|
||||
|
||||
if (!result.ok) {
|
||||
setStatus('error')
|
||||
setErrorMessage(result.message)
|
||||
return
|
||||
}
|
||||
|
||||
await Preferences.set({ key: 'customServerUrl', value: trimmedURL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
setStatus('success')
|
||||
|
||||
setTimeout(() => {
|
||||
Navigate('/login')
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const handleURLChange = e => {
|
||||
setServerURL(e.target.value)
|
||||
if (status !== 'idle') {
|
||||
setStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
const isTesting = status === 'testing'
|
||||
|
||||
return (
|
||||
<Container component='main' maxWidth='xs'>
|
||||
<Box
|
||||
@@ -38,7 +138,6 @@ const LoginSettings = () => {
|
||||
sx={{
|
||||
mt: 1,
|
||||
width: '100%',
|
||||
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
@@ -51,13 +150,7 @@ const LoginSettings = () => {
|
||||
|
||||
<Typography level='h2'>
|
||||
Done
|
||||
<span
|
||||
style={{
|
||||
color: '#06b6d4',
|
||||
}}
|
||||
>
|
||||
tick
|
||||
</span>
|
||||
<span style={{ color: '#06b6d4' }}>tick</span>
|
||||
</Typography>
|
||||
|
||||
<Typography level='body2' alignSelf={'start'} mt={4}>
|
||||
@@ -71,9 +164,22 @@ const LoginSettings = () => {
|
||||
name='serverURL'
|
||||
autoFocus
|
||||
value={serverURL}
|
||||
onChange={e => {
|
||||
setServerURL(e.target.value)
|
||||
}}
|
||||
onChange={handleURLChange}
|
||||
disabled={isTesting}
|
||||
color={
|
||||
status === 'success'
|
||||
? 'success'
|
||||
: status === 'error'
|
||||
? 'danger'
|
||||
: 'neutral'
|
||||
}
|
||||
endDecorator={
|
||||
status === 'success' ? (
|
||||
<CheckCircleOutlineIcon color='success' fontSize='small' />
|
||||
) : status === 'error' ? (
|
||||
<ErrorOutlineIcon color='error' fontSize='small' />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<Typography mt={1} level='body-xs'>
|
||||
@@ -81,72 +187,68 @@ const LoginSettings = () => {
|
||||
own self-hosted Donetick server.
|
||||
</Typography>
|
||||
<Typography mt={1} level='body-xs'>
|
||||
Please ensure to include the protocol (http:// or https://) and the
|
||||
port number if necessary (default Donetick port is 2021).
|
||||
Include the protocol (http:// or https://) and port if necessary
|
||||
(default Donetick port is 2021).
|
||||
</Typography>
|
||||
|
||||
{status === 'error' && (
|
||||
<Alert
|
||||
color='danger'
|
||||
variant='soft'
|
||||
startDecorator={<ErrorOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Alert
|
||||
color='success'
|
||||
variant='soft'
|
||||
startDecorator={<CheckCircleOutlineIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Connected! Redirecting to login...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{status === 'testing' && (
|
||||
<Alert
|
||||
color='neutral'
|
||||
variant='soft'
|
||||
startDecorator={<WifiIcon />}
|
||||
sx={{ mt: 2, width: '100%' }}
|
||||
>
|
||||
Testing connection to server...
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='solid'
|
||||
sx={{
|
||||
width: '100%',
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (serverURL === '') {
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: API_URL,
|
||||
}).then(() => {
|
||||
Navigate('/login')
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!isValidServerURL()) {
|
||||
showError({
|
||||
title: 'Invalid Server URL',
|
||||
message:
|
||||
'Please enter a valid server URL with protocol (http:// or https://)',
|
||||
})
|
||||
return
|
||||
}
|
||||
Preferences.set({
|
||||
key: 'customServerUrl',
|
||||
value: serverURL,
|
||||
}).then(async () => {
|
||||
// apiClient.customServerURL = serverURL + '/api/v1's
|
||||
// Force re-initialization to reload from Preferences
|
||||
await apiClient.init(true)
|
||||
// refetch resource queries to update the API URL
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
})
|
||||
}}
|
||||
disabled={isTesting || status === 'success'}
|
||||
sx={{ width: '100%', mt: 2, mb: 2, borderRadius: '8px' }}
|
||||
onClick={handleSave}
|
||||
startDecorator={
|
||||
isTesting ? <CircularProgress size='sm' /> : undefined
|
||||
}
|
||||
>
|
||||
Save
|
||||
{isTesting ? 'Testing...' : 'Save & Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size='lg'
|
||||
variant='soft'
|
||||
color='danger'
|
||||
sx={{
|
||||
width: '100%',
|
||||
|
||||
mb: 2,
|
||||
border: 'moccasin',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
onClick={() => {
|
||||
Preferences.set({ key: 'customServerUrl', value: API_URL }).then(
|
||||
() => {
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
},
|
||||
)
|
||||
disabled={isTesting}
|
||||
sx={{ width: '100%', mb: 2, borderRadius: '8px' }}
|
||||
onClick={async () => {
|
||||
await Preferences.set({ key: 'customServerUrl', value: API_URL })
|
||||
await apiClient.init(true)
|
||||
refetchResource()
|
||||
Navigate('/login')
|
||||
}}
|
||||
>
|
||||
Cancel and Reset
|
||||
|
||||
@@ -190,7 +190,11 @@ const scheduleChoreNotification = async (
|
||||
for (let i = 0; i < chores.length; i++) {
|
||||
const chore = chores[i]
|
||||
try {
|
||||
if (chore.notification === false || chore.nextDueDate === null) {
|
||||
if (
|
||||
chore.notification === false ||
|
||||
chore.nextDueDate === null ||
|
||||
chore.isActive === false
|
||||
) {
|
||||
continue
|
||||
}
|
||||
scheduleNotificationFromTemplate(
|
||||
|
||||
@@ -420,8 +420,8 @@ export const useChoreActions = ({
|
||||
c => c.id !== chore.id,
|
||||
)
|
||||
setChores(newChores)
|
||||
updateChoreInState(chore.id, 'deleted')
|
||||
setFilteredChores(newFilteredChores)
|
||||
queryClient.invalidateQueries(['chores'])
|
||||
showSuccess({
|
||||
title: 'Task Deleted',
|
||||
message: 'The task has been deleted successfully.',
|
||||
@@ -471,7 +471,7 @@ export const useChoreActions = ({
|
||||
await new Promise((resolve, reject) => {
|
||||
archiveChore.mutate(chore.id, {
|
||||
onSuccess: data => {
|
||||
updateChoreInState(data, 'archive')
|
||||
updateChoreInState(chore, 'archive')
|
||||
resolve(data)
|
||||
},
|
||||
onError: async error => {
|
||||
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
Typography,
|
||||
} from '@mui/joy'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { networkManager } from '../../hooks/NetworkManager'
|
||||
import {
|
||||
PENDING_POLL_MS,
|
||||
SERVER_PROBE_MS,
|
||||
} from '../../hooks/useSyncOnReconnect'
|
||||
import { commandQueue } from '../../utils/CommandQueue'
|
||||
import {
|
||||
isOfflineFeatureEnabled,
|
||||
@@ -57,8 +61,6 @@ const formatCommandLabel = commandType => {
|
||||
)
|
||||
}
|
||||
|
||||
const RETRY_INTERVAL = 30
|
||||
|
||||
function SyncStatusIndicator() {
|
||||
const queryClient = useQueryClient()
|
||||
const [pendingCommands, setPendingCommands] = useState([])
|
||||
@@ -71,7 +73,17 @@ function SyncStatusIndicator() {
|
||||
const [isOnline, setIsOnline] = useState(networkManager.isOnline)
|
||||
const [offlineSince, setOfflineSince] = useState(networkManager.offlineSince)
|
||||
const [offlineReason, setOfflineReason] = useState(networkManager.offlineReason)
|
||||
const [retryIn, setRetryIn] = useState(RETRY_INTERVAL)
|
||||
|
||||
// Mirror the actual intervals used by useSyncOnReconnect so the countdown is accurate
|
||||
const retryInterval = useMemo(
|
||||
() =>
|
||||
!isOnline && offlineReason === 'server'
|
||||
? SERVER_PROBE_MS / 1000
|
||||
: PENDING_POLL_MS / 1000,
|
||||
[isOnline, offlineReason],
|
||||
)
|
||||
|
||||
const [retryIn, setRetryIn] = useState(retryInterval)
|
||||
const [offlineFeatureEnabled, setOfflineFeatureEnabled] = useState(
|
||||
isOfflineFeatureEnabled(),
|
||||
)
|
||||
@@ -90,9 +102,9 @@ function SyncStatusIndicator() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!syncState.syncing) {
|
||||
setRetryIn(RETRY_INTERVAL)
|
||||
setRetryIn(retryInterval)
|
||||
}
|
||||
}, [syncState.syncing, syncState.lastSync])
|
||||
}, [syncState.syncing, syncState.lastSync, retryInterval])
|
||||
|
||||
useEffect(() => {
|
||||
networkManager.registerNetworkListener(online => {
|
||||
@@ -108,10 +120,31 @@ function SyncStatusIndicator() {
|
||||
if (isOnline && pendingCommands.length === 0) return
|
||||
if (!isOnline && offlineReason === 'device') return
|
||||
const interval = setInterval(() => {
|
||||
setRetryIn(prev => (prev <= 1 ? RETRY_INTERVAL : prev - 1))
|
||||
setRetryIn(prev => {
|
||||
if (prev <= 1) {
|
||||
console.debug('[SyncStatusIndicator] Retry timer fired', {
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncing: syncState.syncing,
|
||||
lastSync: syncState.lastSync,
|
||||
error: syncState.error,
|
||||
pendingCommands: pendingCommands.length,
|
||||
})
|
||||
return retryInterval
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [isOnline, offlineReason, syncState.syncing, syncState.lastSync, pendingCommands.length])
|
||||
}, [
|
||||
isOnline,
|
||||
offlineReason,
|
||||
syncState.syncing,
|
||||
syncState.lastSync,
|
||||
syncState.error,
|
||||
pendingCommands.length,
|
||||
retryInterval,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const update = async () => {
|
||||
|
||||
Reference in New Issue
Block a user