Merge pull request #133 from donetick/bugfixes-07-09-2026
fix: implement UUID generation utility and replace crypto.randomUUID …
This commit is contained in:
10
src/utils/UUID.js
Normal file
10
src/utils/UUID.js
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -99,12 +99,42 @@ const ChoreHistory = () => {
|
||||
type: 'multi-select',
|
||||
icon: <FilterList />,
|
||||
options: [
|
||||
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
|
||||
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
|
||||
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
|
||||
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
|
||||
{
|
||||
value: ChoreHistoryStatus.COMPLETED,
|
||||
label: 'Completed',
|
||||
color: 'success',
|
||||
icon: <Check sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.SKIPPED,
|
||||
label: 'Skipped',
|
||||
color: 'warning',
|
||||
icon: <Redo sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.PENDING_APPROVAL,
|
||||
label: 'Pending',
|
||||
color: 'neutral',
|
||||
icon: <HourglassEmpty sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: ChoreHistoryStatus.REJECTED,
|
||||
label: 'Rejected',
|
||||
color: 'danger',
|
||||
icon: <ThumbDown sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: 'Missed',
|
||||
color: 'danger',
|
||||
icon: <RunningWithErrors sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: 'Rescheduled',
|
||||
color: 'warning',
|
||||
icon: <Schedule sx={{ fontSize: 14 }} />,
|
||||
},
|
||||
],
|
||||
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 = () => {
|
||||
<Container maxWidth='md' sx={{ px: 0 }}>
|
||||
{/* Enhanced Header Section */}
|
||||
<Box sx={{ gap: 2, p: 2 }}>
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
|
||||
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
|
||||
{/* Statistics Cards Grid - Compact Design */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
|
||||
<History sx={{ fontSize: '1.5rem' }} />
|
||||
@@ -374,9 +419,8 @@ const ChoreHistory = () => {
|
||||
</Box>
|
||||
|
||||
{/* History Section Header */}
|
||||
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
|
||||
|
||||
<Analytics sx={{ fontSize: '1.5rem' }} />
|
||||
<Typography
|
||||
level='title-md'
|
||||
@@ -386,17 +430,17 @@ const ChoreHistory = () => {
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2 }}>
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={filteredHistory.length}
|
||||
totalCount={choreHistory.length}
|
||||
/>
|
||||
</Box>
|
||||
{filteredHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box sx={{ px: 2 }}>
|
||||
<FilterBar
|
||||
filterDefs={filterDefs}
|
||||
activeFilters={activeFilters}
|
||||
onSetFilter={setFilter}
|
||||
onClearAll={clearAll}
|
||||
resultCount={filteredHistory.length}
|
||||
totalCount={choreHistory.length}
|
||||
/>
|
||||
</Box>
|
||||
{sortedHistory.length === 0 && activeFilterCount > 0 && (
|
||||
<Box
|
||||
sx={{
|
||||
textAlign: 'center',
|
||||
@@ -420,114 +464,111 @@ const ChoreHistory = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredHistory.length > 0 && (
|
||||
<Sheet
|
||||
variant='plain'
|
||||
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
|
||||
>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
{sortedHistory.length > 0 && (
|
||||
<Sheet variant='plain' sx={{ borderRadius: 'sm', overflow: 'hidden' }}>
|
||||
{/* Chore History List (Updated Style) */}
|
||||
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{filteredHistory.map((historyEntry, index) => (
|
||||
<SwipeableListItem
|
||||
key={historyEntry.id || index}
|
||||
swipeActionOpen={
|
||||
showMoreInfoId === (historyEntry.id || index)
|
||||
? 'trailing'
|
||||
: null
|
||||
}
|
||||
trailingActions={
|
||||
<TrailingActions>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<SwipeAction onClick={() => handleEdit(historyEntry)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'neutral.softBg',
|
||||
color: 'neutral.700',
|
||||
px: 3,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
<SwipeAction onClick={() => handleDelete(historyEntry)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.700',
|
||||
px: 3,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
</Box>
|
||||
</TrailingActions>
|
||||
}
|
||||
>
|
||||
<HistoryCard
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
onViewDetails={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SwipeableListItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
<SwipeableList type={ListType.IOS} fullSwipe={false}>
|
||||
{sortedHistory.map((historyEntry, index) => (
|
||||
<SwipeableListItem
|
||||
key={historyEntry.id || index}
|
||||
swipeActionOpen={
|
||||
showMoreInfoId === (historyEntry.id || index)
|
||||
? 'trailing'
|
||||
: null
|
||||
}
|
||||
trailingActions={
|
||||
<TrailingActions>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
|
||||
zIndex: 0,
|
||||
}}
|
||||
>
|
||||
<SwipeAction onClick={() => handleEdit(historyEntry)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'neutral.softBg',
|
||||
color: 'neutral.700',
|
||||
px: 3,
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Edit
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
<SwipeAction onClick={() => handleDelete(historyEntry)}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bgcolor: 'danger.softBg',
|
||||
color: 'danger.700',
|
||||
px: 3,
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 20 }} />
|
||||
<Typography level='body-xs' sx={{ mt: 0.5 }}>
|
||||
Delete
|
||||
</Typography>
|
||||
</Box>
|
||||
</SwipeAction>
|
||||
</Box>
|
||||
</TrailingActions>
|
||||
}
|
||||
>
|
||||
<HistoryCard
|
||||
historyEntry={historyEntry}
|
||||
performers={performers}
|
||||
allHistory={choreHistory}
|
||||
index={index}
|
||||
onViewDetails={() => {
|
||||
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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SwipeableListItem>
|
||||
))}
|
||||
</SwipeableList>
|
||||
</Sheet>
|
||||
)}
|
||||
<EditHistoryModal
|
||||
config={{
|
||||
@@ -604,7 +645,7 @@ const ChoreHistory = () => {
|
||||
<ConfirmationModal config={confirmModalConfig} />
|
||||
<NoteViewerModal config={noteViewerConfig} />
|
||||
<HistoryDetailModal config={detailModalConfig} />
|
||||
</Container>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
Reference in New Issue
Block a user