Merge pull request #124 from donetick/bug-fixes-06-27-2026

Bug fixes 06 27 2026
This commit is contained in:
Mohamad Tarbin
2026-06-30 01:23:52 -04:00
committed by GitHub
6 changed files with 225 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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