diff --git a/src/utils/UUID.js b/src/utils/UUID.js
new file mode 100644
index 0000000..49b1d2c
--- /dev/null
+++ b/src/utils/UUID.js
@@ -0,0 +1,10 @@
+export function generateUUID() {
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
+ return crypto.randomUUID()
+ }
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+ const r = (Math.random() * 16) | 0
+ const v = c === 'x' ? r : (r & 0x3) | 0x8
+ return v.toString(16)
+ })
+}
diff --git a/src/views/Authorization/LoginSettings.jsx b/src/views/Authorization/LoginSettings.jsx
index e8fe85e..914e0e6 100644
--- a/src/views/Authorization/LoginSettings.jsx
+++ b/src/views/Authorization/LoginSettings.jsx
@@ -39,13 +39,14 @@ const LoginSettings = () => {
}
const testConnection = async url => {
+ const testURL = url.replace(/\/+$/, '') + '/api/v1/resource'
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,
@@ -55,18 +56,79 @@ const LoginSettings = () => {
if (response.status < 500) {
return { ok: true }
}
+ if (response.status === 503) {
+ return {
+ ok: false,
+ message:
+ 'Server is starting up or temporarily unavailable (503). Try again in a moment.',
+ }
+ }
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.`,
+ message: `Connection timed out after ${CONNECTION_TIMEOUT_MS / 1000}s. The host may be unreachable or behind a firewall — check the IP/hostname and network.`,
}
}
+
+ // Try no-cors to distinguish CORS misconfiguration from server being down
+ const noCorsController = new AbortController()
+ const noCorsTimeout = setTimeout(() => noCorsController.abort(), 3000)
+ try {
+ const probeStart = Date.now()
+ const probe = await fetch(testURL, {
+ method: 'GET',
+ mode: 'no-cors',
+ signal: noCorsController.signal,
+ })
+ clearTimeout(noCorsTimeout)
+ if (probe.type === 'opaque') {
+ // Server responded but CORS headers blocked the real request
+ return {
+ ok: false,
+ message:
+ 'Server is reachable but blocked the request (CORS). Ensure your Donetick server allows requests from this origin, or check the server CORS config.',
+ }
+ }
+ // Opaque is the only expected type for no-cors success; anything else is odd
+ void probeStart
+ } catch (probeErr) {
+ clearTimeout(noCorsTimeout)
+ if (probeErr.name !== 'AbortError') {
+ // Both normal and no-cors fetch threw immediately → port refused
+ const msg = probeErr.message?.toLowerCase() ?? ''
+ if (
+ msg.includes('getaddrinfo') ||
+ msg.includes('name not resolved') ||
+ msg.includes('err_name')
+ ) {
+ return {
+ ok: false,
+ message:
+ 'Hostname could not be resolved. Check the URL for typos or verify DNS.',
+ }
+ }
+ return {
+ ok: false,
+ message:
+ 'Connection refused. The port may be wrong or nothing is listening — verify the URL and port (default Donetick port is 2021).',
+ }
+ }
+ // no-cors also timed out → server/host truly unreachable
+ return {
+ ok: false,
+ message:
+ 'Unable to reach the server. Check the URL, port, and network connection.',
+ }
+ }
+
+ // Fallback (should rarely hit)
return {
ok: false,
message:
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
index 7ac6cd4..23f3fee 100644
--- a/src/views/ChoreEdit/ChoreEdit.jsx
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -58,6 +58,7 @@ import {
UploadChoreAttachment,
} from '../../utils/Fetcher'
import { isPlusAccount, resolvePhotoURL } from '../../utils/Helpers'
+import { generateUUID } from '../../utils/UUID'
import Priorities from '../../utils/Priorities.jsx'
import { getIconComponent } from '../../utils/ProjectIcons'
import { getSafeBottomPadding } from '../../utils/SafeAreaUtils.js'
@@ -131,7 +132,7 @@ const ChoreEdit = () => {
const [createdBy, setCreatedBy] = useState(0)
const [errors, setErrors] = useState({})
const [attemptToSave, setAttemptToSave] = useState(false)
- const [draftId] = useState(() => crypto.randomUUID())
+ const [draftId] = useState(() => generateUUID())
const [attachments, setAttachments] = useState([])
const [isUploadingAttachment, setIsUploadingAttachment] = useState(false)
const [addLabelModalOpen, setAddLabelModalOpen] = useState(false)
diff --git a/src/views/History/ChoreHistory.jsx b/src/views/History/ChoreHistory.jsx
index 84ac352..3298408 100644
--- a/src/views/History/ChoreHistory.jsx
+++ b/src/views/History/ChoreHistory.jsx
@@ -99,12 +99,42 @@ const ChoreHistory = () => {
type: 'multi-select',
icon: ,
options: [
- { value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: },
- { value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: },
- { value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: },
- { value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: },
- { value: 5, label: 'Missed', color: 'danger', icon: },
- { value: 6, label: 'Rescheduled', color: 'warning', icon: },
+ {
+ value: ChoreHistoryStatus.COMPLETED,
+ label: 'Completed',
+ color: 'success',
+ icon: ,
+ },
+ {
+ value: ChoreHistoryStatus.SKIPPED,
+ label: 'Skipped',
+ color: 'warning',
+ icon: ,
+ },
+ {
+ value: ChoreHistoryStatus.PENDING_APPROVAL,
+ label: 'Pending',
+ color: 'neutral',
+ icon: ,
+ },
+ {
+ value: ChoreHistoryStatus.REJECTED,
+ label: 'Rejected',
+ color: 'danger',
+ icon: ,
+ },
+ {
+ value: 5,
+ label: 'Missed',
+ color: 'danger',
+ icon: ,
+ },
+ {
+ value: 6,
+ label: 'Rescheduled',
+ color: 'warning',
+ icon: ,
+ },
],
filterFn: (item, values) => values.includes(item.status),
},
@@ -143,8 +173,23 @@ const ChoreHistory = () => {
[performers],
)
- const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
- useFilter(choreHistory, filterDefs)
+ const {
+ filteredData: filteredHistory,
+ activeFilters,
+ setFilter,
+ clearAll,
+ activeFilterCount,
+ } = useFilter(choreHistory, filterDefs)
+
+ const sortedHistory = useMemo(
+ () =>
+ [...filteredHistory].sort(
+ (a, b) =>
+ new Date(b.performedAt || b.updatedAt) -
+ new Date(a.performedAt || a.updatedAt),
+ ),
+ [filteredHistory],
+ )
const handleDelete = historyEntry => {
showConfirmation(
@@ -296,7 +341,7 @@ const ChoreHistory = () => {
{/* Enhanced Header Section */}
- {/* */}
+ {/* */}
{/* Statistics Cards Grid - Compact Design */}
@@ -374,9 +419,8 @@ const ChoreHistory = () => {
{/* History Section Header */}
-
+
-
{
-
-
-
- {filteredHistory.length === 0 && activeFilterCount > 0 && (
+
+
+
+ {sortedHistory.length === 0 && activeFilterCount > 0 && (
{
)}
- {filteredHistory.length > 0 && (
-
- {/* Chore History List (Updated Style) */}
+ {sortedHistory.length > 0 && (
+
+ {/* Chore History List (Updated Style) */}
-
- {filteredHistory.map((historyEntry, index) => (
-
-
- handleEdit(historyEntry)}>
-
-
-
- Edit
-
-
-
- handleDelete(historyEntry)}>
-
-
-
- Delete
-
-
-
-
-
- }
- >
- {
- setDetailModalConfig({
- isOpen: true,
- entry: historyEntry,
- performers,
- onClose: () => setDetailModalConfig({ isOpen: false }),
- onEdit: record => {
- setDetailModalConfig({ isOpen: false })
- setEditHistory(record)
- setIsEditModalOpen(true)
- },
- })
- }}
- pendingCommands={pendingByHistoryId[historyEntry.id] || []}
- onViewNote={notes => {
- setNoteViewerConfig({
- isOpen: true,
- title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`,
- content: notes,
- onClose: () => setNoteViewerConfig({ isOpen: false }),
- })
- }}
- onToggleActions={() => {
- const id = historyEntry.id || index
- if (showMoreInfoId === id) {
- setShowMoreInfoId(null)
- } else {
- setShowMoreInfoId(id)
- }
- }}
- />
-
- ))}
-
-
+
+ {sortedHistory.map((historyEntry, index) => (
+
+
+ handleEdit(historyEntry)}>
+
+
+
+ Edit
+
+
+
+ handleDelete(historyEntry)}>
+
+
+
+ Delete
+
+
+
+
+
+ }
+ >
+ {
+ setDetailModalConfig({
+ isOpen: true,
+ entry: historyEntry,
+ performers,
+ onClose: () => setDetailModalConfig({ isOpen: false }),
+ onEdit: record => {
+ setDetailModalConfig({ isOpen: false })
+ setEditHistory(record)
+ setIsEditModalOpen(true)
+ },
+ })
+ }}
+ pendingCommands={pendingByHistoryId[historyEntry.id] || []}
+ onViewNote={notes => {
+ setNoteViewerConfig({
+ isOpen: true,
+ title: `Updated at ${fmt.dateTime(historyEntry.updatedAt)}`,
+ content: notes,
+ onClose: () => setNoteViewerConfig({ isOpen: false }),
+ })
+ }}
+ onToggleActions={() => {
+ const id = historyEntry.id || index
+ if (showMoreInfoId === id) {
+ setShowMoreInfoId(null)
+ } else {
+ setShowMoreInfoId(id)
+ }
+ }}
+ />
+
+ ))}
+
+
)}
{
-
+
)
}
diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx
index e73c121..fcf6207 100644
--- a/src/views/components/AddTaskModal.jsx
+++ b/src/views/components/AddTaskModal.jsx
@@ -8,6 +8,7 @@ import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { isPlusAccount } from '../../utils/Helpers'
+import { generateUUID } from '../../utils/UUID'
import { useLabels } from '../Labels/LabelQueries'
import { useProjects } from '../Projects/ProjectQueries'
import {
@@ -106,7 +107,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
const [projectId, setProjectId] = useState(getInitialProject())
const [attachments, setAttachments] = useState([])
- const [draftId, setDraftId] = useState(() => crypto.randomUUID())
+
+ const [draftId, setDraftId] = useState(() => generateUUID())
const [showScan, setShowScan] = useState(false)
const [scanAutoCapture, setScanAutoCapture] = useState(false)
const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null)
@@ -595,7 +597,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
setDueTime(null)
setUseCustomTime(false)
setAttachments([])
- setDraftId(crypto.randomUUID())
+ setDraftId(generateUUID())
}
const createChore = () => {