Files
donetick/src/views/Chores/hooks/useChoreActions.js
everysingletear ede1a11099 i18n: extract the remaining task, history, filter and timer screens
Part of #145.

Sixteen files that were left out of my earlier PRs because my branch also
carried unrelated changes in them. Those are stripped here: each file is
your current `develop` version with the string extraction applied on top,
nothing else.

Covered: the chore action hook and its toasts, activities and smart-insight
cards, the chore toolbar, chore history and its card, saved filters, the
timer details view, project and label modals, the notification picker,
the pending badge, the sync status indicator, the SSE settings and hook,
and the profile avatar menu.

All namespaces already exist, so `src/i18n/config.js` is untouched.
Keys added: 83 `chores`, 31 `common`, 10 `timer`, 9 `history`, 6 `labels`,
5 `projects`, 2 `filters`, 1 `settings`.

English only — no translations, no behaviour change. Every t() value is
checked to appear character-for-character in the code it replaces, or to
match the value already in your dictionary for the same key: 221 call
sites, no mismatches.

Five files from the same batch are deliberately left out. They build
translated labels in module-level constant tables, where the hook cannot
be called — `FilterBar`, `RepeatSection`, `RepeatPickerField`,
`FilterBuilderContent` and `AdvancedOptionsSection`. Those need the key
to travel as data and be resolved inside the component, which is a design
change rather than an extraction, so it deserves its own PR.
2026-08-14 11:26:24 +08:00

1229 lines
40 KiB
JavaScript

import { useQueryClient } from '@tanstack/react-query'
import { useCallback } from 'react'
import {
useArchiveChore,
useUnArchiveChore,
} from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import { commandQueue, CommandType } from '../../../utils/CommandQueue'
import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
SaveChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../../utils/Fetcher'
import { offlineDB } from '../../../utils/OfflineDB'
import { isOfflineFeatureEnabled } from '../../../utils/OfflineFeatureToggle'
import { useTranslation } from 'react-i18next'
// Effectively "can this action be queued offline?" — requires the offline
// feature, otherwise there is no command queue to replay it later.
const isNetworkError = err =>
isOfflineFeatureEnabled() &&
err instanceof TypeError &&
err.message === 'Failed to fetch'
export const useChoreActions = ({
chores,
filteredChores,
setChores,
setFilteredChores,
userProfile,
impersonatedUser,
showSuccess,
showError,
showWarning,
showUndo,
refetchChores,
setConfirmModelConfig,
openModal,
closeModal,
modalChore,
getSelectedChoresData,
clearSelection,
}) => {
const { t } = useTranslation('chores')
const queryClient = useQueryClient()
const archiveChore = useArchiveChore()
const unarchiveChore = useUnArchiveChore()
const startChore = useStartChore()
const pauseChore = usePauseChore()
const updateChoreInState = useCallback(
(updatedChore, event, { skipInvalidation = false } = {}) => {
let newChores = chores.map(c =>
c.id === updatedChore.id ? updatedChore : c,
)
let newFilteredChores = filteredChores.map(c =>
c.id === updatedChore.id ? updatedChore : c,
)
if (
event === 'archive' ||
(event === 'completed' && updatedChore.frequencyType === 'once') ||
updatedChore.frequencyType === 'trigger'
) {
newChores = newChores.filter(c => c.id !== updatedChore.id)
newFilteredChores = newFilteredChores.filter(
c => c.id !== updatedChore.id,
)
}
setChores(newChores)
setFilteredChores(newFilteredChores)
if (!skipInvalidation) {
queryClient.invalidateQueries(['chores'])
}
const undoableActions = {
completed: 'Task completed',
approved: 'Task approved',
rejected: 'Task rejected',
skipped: 'Task skipped',
}
if (undoableActions[event]) {
showSuccess({
message: undoableActions[event],
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(updatedChore.id)
if (undoResponse.ok) {
queryClient.invalidateQueries(['chores'])
const undoMessages = {
completed: 'Task completion has been undone.',
approved: 'Task approval has been undone.',
rejected: 'Task rejection has been undone.',
skipped: 'Task skip has been undone.',
}
showUndo({
title: t('choreView.undoSuccessful'),
message: undoMessages[event],
})
} else {
throw new Error(t('choreView.unableUndo'))
}
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
return
}
const notifications = {
rescheduled: {
type: 'success',
title: t('actions.rescheduledTitle'),
message: t('actions.rescheduledMessage'),
},
'due-date-removed': {
type: 'success',
title: t('actions.unplannedTitle'),
message: t('actions.unplannedMessage'),
},
unarchive: {
type: 'success',
title: t('archived.restoredTitle'),
message: t('archived.restoredMsg'),
},
archive: {
type: 'success',
title: t('actions.archivedTitle'),
message:
'The task has been archived and hidden from the active list.',
},
started: {
type: 'success',
title: t('actions.startedTitle'),
message: t('actions.startedMessage'),
},
paused: {
type: 'warning',
title: t('actions.pausedTitle'),
message: t('actions.pausedMessage'),
},
deleted: {
type: 'success',
title: t('archived.deletedTitle'),
message: t('actions.deletedMessage'),
},
}
const notification = notifications[event]
if (notification) {
const notifyFn =
notification.type === 'warning' ? showWarning : showSuccess
notifyFn({ title: notification.title, message: notification.message })
}
},
[
chores,
filteredChores,
setChores,
setFilteredChores,
queryClient,
showSuccess,
showError,
showWarning,
showUndo,
],
)
const handleChoreAction = useCallback(
async (action, chore, extraData = {}) => {
switch (action) {
case 'complete':
try {
const response = await MarkChoreComplete(
chore.id,
impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
null,
null,
)
if (response.ok) {
// Online: hide the chore and show undo
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev => prev.filter(c => c.id !== chore.id))
queryClient.setQueriesData({ queryKey: ['chores'] }, oldData => {
if (!oldData || !oldData.res) return oldData
return {
...oldData,
res: oldData.res.filter(c => c.id !== chore.id),
}
})
showSuccess({
message: t('actions.undoable.completed'),
undoAction: async () => {
try {
const undoResponse = await UndoChoreAction(chore.id)
if (undoResponse.ok) {
queryClient.invalidateQueries(['chores'])
showUndo({
title: t('choreView.undoSuccessful'),
message: t('choreView.taskCompletionUndone'),
})
} else throw new Error(t('choreView.unableUndo'))
} catch {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
queryClient.invalidateQueries(['chores'])
} else {
refetchChores()
}
} catch (error) {
if (isNetworkError(error)) {
// Offline — queue and show pending badge on the chore (don't hide it)
const cmdId = await commandQueue.enqueue(
CommandType.COMPLETE_CHORE,
chore.id,
{
id: chore.id,
body: impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
completedDate: null,
performer: null,
},
)
await offlineDB.savePendingHistory({
id: -Date.now(),
choreId: chore.id,
completedBy: impersonatedUser?.userId || userProfile?.id || 0,
performedAt: new Date().toISOString(),
dueDate: chore.nextDueDate || null,
notes: null,
status: 1,
points: chore.points || 0,
pending: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
title: t('actions.completionPending'),
message:
"You're offline — completion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
} else {
showError({
title: t('actions.failCompleteTitle'),
message: error?.message || 'Unable to complete chore',
})
}
}
break
case 'start': {
const startedChore = { ...chore, status: 1 }
try {
await startChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? startedChore : c,
),
}
})
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.START_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(startedChore, 'started', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — start will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
})
} else {
showError({
title: t('actions.failStartTitle'),
message: error?.message || 'Unable to start chore',
})
}
}
break
}
case 'pause': {
const pausedChore = { ...chore, status: 2 }
try {
await pauseChore.mutateAsync(chore.id)
queryClient.cancelQueries(['chores'])
queryClient.setQueryData(['chores', false], oldData => {
if (!oldData?.res) return oldData
return {
...oldData,
res: oldData.res.map(c =>
c.id === chore.id ? pausedChore : c,
),
}
})
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
} catch (error) {
if (isNetworkError(error)) {
const previousStatus = chore.status
const cmdId = await commandQueue.enqueue(
CommandType.PAUSE_CHORE,
chore.id,
{ id: chore.id },
)
updateChoreInState(pausedChore, 'paused', {
skipInvalidation: true,
})
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — pause will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
updateChoreInState(
{ ...chore, status: previousStatus },
previousStatus === 2 ? 'paused' : 'started',
{ skipInvalidation: true },
)
},
})
} else {
showError({
title: t('actions.failPauseTitle'),
message: error?.message || 'Unable to pause chore',
})
}
}
break
}
case 'approve':
try {
const response = await ApproveChore(chore.id)
if (response.ok) {
const data = await response.json()
updateChoreInState(data.res, 'approved')
}
} catch (error) {
showError({
title: t('actions.failApproveTitle'),
message: error.message || 'Unable to approve chore',
})
}
break
case 'reject':
try {
const response = await RejectChore(chore.id)
if (response.ok) {
const data = await response.json()
updateChoreInState(data.res, 'rejected')
}
} catch (error) {
showError({
title: t('actions.failRejectTitle'),
message: error.message || 'Unable to reject chore',
})
}
break
case 'delete':
setConfirmModelConfig({
isOpen: true,
title: t('deleteChore'),
confirmText: t('archived.delete'),
cancelText: t('choreView.cancel'),
message: t('edit.deleteConfirm'),
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const response = await DeleteChore(chore.id)
if (response.ok) {
const newChores = chores.filter(c => c.id !== chore.id)
const newFilteredChores = filteredChores.filter(
c => c.id !== chore.id,
)
setChores(newChores)
setFilteredChores(newFilteredChores)
queryClient.invalidateQueries(['chores'])
showSuccess({
title: t('archived.deletedTitle'),
message: t('actions.deletedMessageLong'),
})
}
} catch (error) {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.DELETE_CHORE,
chore.id,
{ id: chore.id },
)
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev =>
prev.filter(c => c.id !== chore.id),
)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — deletion will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
setChores(prev => [...prev, chore])
setFilteredChores(prev => [...prev, chore])
},
})
} else {
showError({
title: t('actions.failDeleteTitle'),
message: error?.message || 'Unable to delete chore',
})
}
}
}
setConfirmModelConfig({})
},
})
break
case 'archive':
try {
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState(chore, 'archive')
resolve(data)
},
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.ARCHIVE_CHORE,
chore.id,
{ id: chore.id },
)
await offlineDB.saveChores([
{ ...chore, isActive: false, _pending: 'archive' },
])
setChores(prev => prev.filter(c => c.id !== chore.id))
setFilteredChores(prev =>
prev.filter(c => c.id !== chore.id),
)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — archive will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
await offlineDB.saveChores([
{ ...chore, isActive: true },
])
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
setChores(prev => [...prev, chore])
setFilteredChores(prev => [...prev, chore])
},
})
resolve()
} else {
showError({
title: t('actions.failArchiveTitle'),
message: error.message || 'Unable to archive chore',
})
reject(error)
}
},
})
})
} catch (error) {}
break
case 'unarchive':
try {
await new Promise((resolve, reject) => {
unarchiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState({ ...chore, isActive: true }, 'unarchive')
resolve(data)
},
onError: async error => {
if (isNetworkError(error)) {
const cmdId = await commandQueue.enqueue(
CommandType.UNARCHIVE_CHORE,
chore.id,
{ id: chore.id },
)
await offlineDB.saveChores([
{ ...chore, isActive: true, _pending: 'unarchive' },
])
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
showSuccess({
message:
"You're offline — restore will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
await offlineDB.saveChores([
{ ...chore, isActive: false },
])
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
resolve()
} else {
showError({
title: t('choreView.restoreFailed'),
message: error.message || 'Unable to restore chore',
})
reject(error)
}
},
})
})
} catch (error) {}
break
case 'skip':
try {
const response = await SkipChore(chore.id)
if (response.ok) {
// Online: update in place (chore gets new due date)
const data = await response.json()
updateChoreInState(data.res, 'skipped')
} else {
refetchChores()
}
} catch (error) {
if (isNetworkError(error)) {
// Offline — queue and show pending badge on the chore
const cmdId = await commandQueue.enqueue(
CommandType.SKIP_CHORE,
chore.id,
{ id: chore.id },
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — skip will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
},
})
} else {
showError({
title: t('actions.failSkipTitle'),
message: error?.message || 'Unable to skip chore',
})
}
}
break
case 'changeDueDate':
if (extraData && 'date' in extraData) {
try {
const response = await UpdateDueDate(chore.id, extraData.date)
if (response.ok) {
chore.nextDueDate = extraData.date
const eventType =
extraData.date === null ? 'due-date-removed' : 'rescheduled'
updateChoreInState(chore, eventType)
}
} catch (error) {
if (isNetworkError(error)) {
const oldDueDate = chore.nextDueDate
const cmdId = await commandQueue.enqueue(
CommandType.RESCHEDULE_CHORE,
chore.id,
{
id: chore.id,
dueDate: extraData.date,
},
)
const eventType =
extraData.date === null ? 'due-date-removed' : 'rescheduled'
updateChoreInState(
{ ...chore, nextDueDate: extraData.date },
eventType,
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message:
"You're offline — reschedule will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({
queryKey: ['pendingCommands'],
})
const undoEventType =
oldDueDate === null ? 'due-date-removed' : 'rescheduled'
updateChoreInState(
{ ...chore, nextDueDate: oldDueDate },
undoEventType,
)
},
})
} else {
showError({
title:
extraData.date === null
? 'Failed to remove due date'
: 'Failed to reschedule',
message: error.message || 'Unable to update due date',
})
}
}
} else {
openModal(action, chore, extraData)
}
break
case 'moveToProject': {
const project = extraData?.project
const projectId = project?.id === null ? null : project?.id
const updatedChore = { ...chore, projectId }
try {
const response = await SaveChore(updatedChore)
if (response.ok) {
updateChoreInState(updatedChore, 'moved-to-project')
showSuccess({
title: t('actions.movedTitle'),
message: `Task moved to ${project?.name || 'Default Project'}.`,
})
}
} catch (error) {
showError({
title: t('actions.failMoveTitle'),
message: error?.message || 'Unable to move task to project',
})
}
break
}
case 'completeWithNote':
case 'completeWithPastDate':
case 'changeAssignee':
case 'writeNFC':
case 'nudge':
openModal(action, chore, extraData)
break
default:
console.warn('Unknown action:', action)
}
},
[
impersonatedUser,
chores,
filteredChores,
setChores,
setFilteredChores,
updateChoreInState,
showError,
showSuccess,
setConfirmModelConfig,
openModal,
archiveChore,
unarchiveChore,
startChore,
pauseChore,
],
)
const handleChangeDueDate = useCallback(
async newDate => {
if (!modalChore) return
closeModal()
try {
const response = await UpdateDueDate(modalChore.id, newDate)
if (response.ok) {
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
}
} catch (error) {
if (isNetworkError(error)) {
const oldDueDate = modalChore.nextDueDate
const cmdId = await commandQueue.enqueue(
CommandType.RESCHEDULE_CHORE,
modalChore.id,
{
id: modalChore.id,
dueDate: newDate,
},
)
updateChoreInState(
{ ...modalChore, nextDueDate: newDate },
'rescheduled',
)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
showSuccess({
message: "You're offline — reschedule will sync when back online",
undoAction: async () => {
await commandQueue.cancel(cmdId)
queryClient.invalidateQueries({ queryKey: ['pendingCommands'] })
updateChoreInState(
{ ...modalChore, nextDueDate: oldDueDate },
'rescheduled',
)
},
})
} else {
showError({
title: t('actions.failRescheduleTitle'),
message: error.message || 'Unable to update due date',
})
}
}
},
[
modalChore,
updateChoreInState,
closeModal,
showSuccess,
showError,
queryClient,
],
)
const handleCompleteWithPastDate = useCallback(
newDate => {
if (!modalChore) return
MarkChoreComplete(
modalChore.id,
impersonatedUser ? { completedBy: impersonatedUser.userId } : null,
new Date(newDate).toISOString(),
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
updateChoreInState(newChore, 'completed')
})
}
})
closeModal()
},
[modalChore, impersonatedUser, updateChoreInState, closeModal],
)
const handleAssigneeChange = useCallback(
assigneeId => {
if (!modalChore) return
UpdateChoreAssignee(modalChore.id, assigneeId).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
updateChoreInState(newChore, 'assigned')
})
}
})
closeModal()
},
[modalChore, updateChoreInState, closeModal],
)
const handleCompleteWithNote = useCallback(
note => {
if (!modalChore) return
MarkChoreComplete(
modalChore.id,
impersonatedUser
? { note, completedBy: impersonatedUser.userId }
: { note },
null,
null,
).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = data.res
updateChoreInState(newChore, 'completed')
})
}
})
closeModal()
},
[modalChore, impersonatedUser, updateChoreInState, closeModal],
)
const handleNudge = useCallback(
async ({ choreId, message, notifyAllAssignees }) => {
try {
const response = await NudgeChore(choreId, {
message,
notifyAllAssignees,
})
if (response.ok) {
const data = await response.json()
showSuccess({
title: t('actions.nudgeSentTitle'),
message: data.message || 'Nudge sent successfully',
})
} else {
throw new Error(t('actions.nudgeFailed'))
}
} catch (error) {
showError({
title: t('actions.failNudgeTitle'),
message: error.message || 'Unable to send nudge at this time',
})
} finally {
closeModal()
}
},
[showSuccess, showError, closeModal],
)
const handleBulkComplete = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: t('actions.bulk.completeTitle'),
confirmText: t('list.complete'),
cancelText: t('choreView.cancel'),
message: `Mark ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} as completed?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const completedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await MarkChoreComplete(
chore.id,
impersonatedUser
? { completedBy: impersonatedUser.userId }
: null,
null,
null,
)
completedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (completedTasks.length > 0) {
showSuccess({
title: t('actions.bulk.completedTitle'),
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('actions.bulk.completeFailedTitle'),
message: t('archived.unexpectedError'),
})
}
}
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
impersonatedUser,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkArchive = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: t('actions.bulk.archiveTitle'),
confirmText: t('actionMenu.archive'),
cancelText: t('choreView.cancel'),
message: `Archive ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const archivedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
archivedTasks.push(data)
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) {}
}
if (archivedTasks.length > 0) {
showSuccess({
title: t('actions.bulk.archivedTitle'),
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('actions.bulk.archiveFailedTitle'),
message: t('archived.unexpectedError'),
})
}
}
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
archiveChore,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkDelete = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: t('actions.bulk.deleteTitle'),
confirmText: t('archived.delete'),
cancelText: t('choreView.cancel'),
message: `Delete ${selectedData.length} task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const deletedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await DeleteChore(chore.id)
deletedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (deletedTasks.length > 0) {
showSuccess({
title: t('archived.deletedBulkTitle'),
message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`,
})
const deletedIds = new Set(deletedTasks.map(c => c.id))
const newChores = chores.filter(c => !deletedIds.has(c.id))
const newFilteredChores = filteredChores.filter(
c => !deletedIds.has(c.id),
)
setChores(newChores)
setFilteredChores(newFilteredChores)
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('archived.bulkDeleteFailTitle'),
message: t('archived.unexpectedError'),
})
}
}
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
chores,
filteredChores,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkSkip = useCallback(async () => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: t('actions.bulk.skipTitle'),
confirmText: t('multiToolbar.skip'),
cancelText: t('choreView.cancel'),
message: `Skip ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to next due date?`,
onClose: async isConfirmed => {
if (isConfirmed === true) {
try {
const skippedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
await SkipChore(chore.id)
skippedTasks.push(chore)
} catch (error) {
failedTasks.push(chore)
}
}
if (skippedTasks.length > 0) {
showSuccess({
title: t('actions.bulk.skippedTitle'),
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
undoAction: async () => {
try {
for (const chore of skippedTasks) {
await UndoChoreAction(chore.id)
}
queryClient.invalidateQueries(['chores'])
showUndo({
title: t('choreView.undoSuccessful'),
message: `Undo skip for ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
})
} catch (error) {
showError({
title: t('choreView.undoFailed'),
message: t('choreView.undoFailedMessage'),
})
}
},
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: t('actions.bulk.skipFailedTitle'),
message: t('archived.unexpectedError'),
})
}
}
setConfirmModelConfig({})
},
})
}, [
getSelectedChoresData,
showSuccess,
showError,
showUndo,
refetchChores,
clearSelection,
setConfirmModelConfig,
])
const handleBulkMoveToProject = useCallback(
async project => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
const projectId = project?.id ?? null
const movedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
movedTasks.push(chore)
} else {
failedTasks.push(chore)
}
} catch (error) {
failedTasks.push(chore)
}
}
if (movedTasks.length > 0) {
const movedIds = new Set(movedTasks.map(c => c.id))
const applyMove = list =>
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
setChores(applyMove)
setFilteredChores(applyMove)
showSuccess({
title: 'Tasks Moved',
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: t('archived.someFailedTitle'),
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
})
}
refetchChores()
clearSelection()
},
[
chores,
getSelectedChoresData,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
],
)
return {
handleChoreAction,
handleChangeDueDate,
handleCompleteWithPastDate,
handleAssigneeChange,
handleCompleteWithNote,
handleNudge,
handleBulkComplete,
handleBulkArchive,
handleBulkDelete,
handleBulkSkip,
handleBulkMoveToProject,
}
}