Refactor MyChores component for improved maintainability

Reduced MyChores.jsx from 2,839 to 1,364 lines (52% reduction) by extracting reusable hooks and components.

   Created 6 custom hooks:
   - useMultiSelect: Multi-select mode and selection state management
   - useChoreModals: Centralized modal state management
   - useProjectFilter: Project filtering with localStorage sync
   - useChoreFilters: Multi-layer filtering (project, search, user filters)
   - useKeyboardShortcuts: All keyboard event handling
   - useChoreActions: Chore actions and bulk operations

   Created 3  components:
   - SearchBar: Clean search input with keyboard hints
   - MultiSelectToolbar: Full multi-select toolbar with action buttons
   - ChoreModals: Centralized modal rendering
This commit is contained in:
Mo Tarbin
2026-01-25 21:26:15 -05:00
parent 8ec8816e45
commit 57bc6ad98e
10 changed files with 1801 additions and 1699 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,88 @@
import DateModal from '../../Modals/Inputs/DateModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal'
import WriteNFCModal from '../../Modals/Inputs/WriteNFCModal'
const ChoreModals = ({
activeModal,
modalChore,
membersData,
onChangeDueDate,
onCompleteWithPastDate,
onAssigneeChange,
onCompleteWithNote,
onNudge,
onClose,
}) => {
return (
<>
{activeModal === 'changeDueDate' && modalChore && (
<DateModal
isOpen={true}
key={'changeDueDate' + modalChore.id}
current={modalChore.nextDueDate}
title='Change due date'
onClose={onClose}
onSave={onChangeDueDate}
/>
)}
{activeModal === 'completeWithPastDate' && modalChore && (
<DateModal
isOpen={true}
key={'completedInPast' + modalChore.id}
current={modalChore.nextDueDate}
title='Save Chore that you completed in the past'
onClose={onClose}
onSave={onCompleteWithPastDate}
/>
)}
{activeModal === 'changeAssignee' && modalChore && (
<SelectModal
isOpen={true}
options={membersData?.res || []}
displayKey='displayName'
title='Delegate to someone else'
placeholder='Select a performer'
onClose={onClose}
onSave={selected => onAssigneeChange(selected.id)}
/>
)}
{activeModal === 'completeWithNote' && modalChore && (
<TextModal
isOpen={true}
title='Add note to attach to this completion:'
onClose={onClose}
okText='Complete'
onSave={onCompleteWithNote}
/>
)}
{activeModal === 'writeNFC' && modalChore && (
<WriteNFCModal
config={{
isOpen: true,
url: `${window.location.origin}/chores/${modalChore.id}`,
onClose: onClose,
}}
/>
)}
{activeModal === 'nudge' && modalChore && (
<NudgeModal
config={{
isOpen: true,
choreId: modalChore.id,
onClose: onClose,
onConfirm: onNudge,
}}
/>
)}
</>
)
}
export default ChoreModals

View File

@@ -0,0 +1,278 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
Done,
SelectAll,
SkipNext,
} from '@mui/icons-material'
import { Box, Button, Divider, Typography } from '@mui/joy'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
const MultiSelectToolbar = ({
isVisible,
selectedCount,
onSelectAll,
onClear,
onComplete,
onSkip,
onArchive,
onDelete,
showKeyboardShortcuts,
selectAllDisabled,
}) => {
return (
<Box
sx={{
position: 'sticky',
top: 0,
zIndex: 1000,
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: isVisible ? '200px' : '0',
opacity: isVisible ? 1 : 0,
transform: isVisible ? 'translateY(0)' : 'translateY(-20px)',
marginBottom: isVisible ? 2 : 0,
}}
>
<Box
sx={{
backgroundColor: 'background.surface',
backdropFilter: 'blur(8px)',
borderRadius: 'lg',
p: 2,
border: '1px solid',
borderColor: 'divider',
boxShadow: 'm',
gap: 2,
display: 'flex',
flexDirection: {
sm: 'column',
md: 'row',
},
alignItems: {
xs: 'stretch',
sm: 'center',
},
justifyContent: {
xs: 'center',
sm: 'space-between',
},
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 2,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-start',
},
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<CheckBox sx={{ color: 'primary.500' }} />
<Typography level='body-sm' fontWeight='md'>
{selectedCount} task{selectedCount !== 1 ? 's' : ''} selected
</Typography>
</Box>
<Divider
orientation='vertical'
sx={{
display: { xs: 'none', sm: 'block' },
}}
/>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
onClick={onSelectAll}
startDecorator={<SelectAll />}
disabled={selectAllDisabled}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title='Select all visible tasks (Ctrl+A)'
>
All
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='A'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='outlined'
onClick={onClear}
startDecorator={
selectedCount === 0 ? <Close /> : <CheckBoxOutlineBlank />
}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
position: 'relative',
}}
title={`${selectedCount === 0 ? 'Close' : 'Clear'} multi-select (Esc)`}
>
{selectedCount === 0 ? 'Close' : 'Clear'}
{showKeyboardShortcuts && (
<KeyboardShortcutHint
withCtrl={false}
shortcut='Esc'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: {
xs: 'wrap',
sm: 'nowrap',
},
justifyContent: {
xs: 'center',
sm: 'flex-end',
},
}}
>
<Button
size='sm'
variant='solid'
color='success'
onClick={onComplete}
startDecorator={<Done />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Complete selected tasks (Enter)'
>
Complete
{showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint
shortcut='Enter'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='soft'
color='warning'
onClick={onSkip}
startDecorator={<SkipNext />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Skip selected tasks (/)'
>
Skip
{showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint
shortcut='/'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={onArchive}
startDecorator={<Archive />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Archive selected tasks (X)'
>
Archive
{showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint
shortcut='X'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
<Button
size='sm'
variant='soft'
color='danger'
onClick={onDelete}
startDecorator={<Delete />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
position: 'relative',
}}
title='Delete selected tasks (Shift+X)'
>
Delete
{showKeyboardShortcuts && selectedCount > 0 && (
<KeyboardShortcutHint
shortcut='E'
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
)}
</Button>
</Box>
</Box>
</Box>
)
}
export default MultiSelectToolbar

View File

@@ -0,0 +1,46 @@
import { CancelRounded } from '@mui/icons-material'
import { Box, Input } from '@mui/joy'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
const SearchBar = ({
value,
onChange,
onClose,
onFocus,
showKeyboardShortcuts,
inputRef,
}) => {
return (
<Input
slotProps={{ input: { ref: inputRef } }}
placeholder='Search'
value={value}
onFocus={onFocus}
fullWidth
sx={{
mt: 1,
mb: 1,
borderRadius: 24,
height: 24,
borderColor: 'text.disabled',
padding: 1,
}}
onChange={onChange}
startDecorator={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
}
endDecorator={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{value && (
<>
<KeyboardShortcutHint shortcut='X' show={showKeyboardShortcuts} />
<CancelRounded onClick={onClose} />
</>
)}
</Box>
}
/>
)
}
export default SearchBar

View File

@@ -0,0 +1,721 @@
import { useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useArchiveChore, useDeleteChores } from '../../../queries/ChoreQueries'
import { usePauseChore, useStartChore } from '../../../queries/TimeQueries'
import {
ApproveChore,
DeleteChore,
MarkChoreComplete,
NudgeChore,
RejectChore,
SkipChore,
UndoChoreAction,
UpdateChoreAssignee,
UpdateDueDate,
} from '../../../utils/Fetcher'
export const useChoreActions = ({
chores,
filteredChores,
setChores,
setFilteredChores,
userProfile,
impersonatedUser,
showSuccess,
showError,
showWarning,
showUndo,
refetchChores,
setConfirmModelConfig,
openModal,
closeModal,
modalChore,
getSelectedChoresData,
clearSelection,
}) => {
const queryClient = useQueryClient()
const archiveChore = useArchiveChore()
const startChore = useStartChore()
const pauseChore = usePauseChore()
const updateChoreInState = useCallback(
(updatedChore, event) => {
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)
queryClient.invalidateQueries({ queryKey: ['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) {
refetchChores()
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: 'Undo Successful',
message: undoMessages[event],
})
} else {
throw new Error('Failed to undo')
}
} catch (error) {
showError({
title: 'Undo Failed',
message: 'Unable to undo the action. Please try again.',
})
}
},
})
return
}
const notifications = {
rescheduled: {
type: 'success',
title: 'Task Rescheduled',
message: 'The task due date has been updated successfully.',
},
'due-date-removed': {
type: 'success',
title: 'Task Unplanned',
message: 'The task is now unplanned and has no due date.',
},
unarchive: {
type: 'success',
title: 'Task Restored',
message: 'The task has been restored and is now active.',
},
archive: {
type: 'success',
title: 'Task Archived',
message: 'The task has been archived and hidden from the active list.',
},
started: {
type: 'success',
title: 'Task Started',
message: 'The task has been marked as started.',
},
paused: {
type: 'warning',
title: 'Task Paused',
message: 'The task has been paused.',
},
deleted: {
type: 'success',
title: 'Task Deleted',
message: 'The task has been deleted.',
},
}
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, refetchChores],
)
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) {
const data = await response.json()
updateChoreInState(data.res, 'completed')
}
} catch (error) {
if (error?.queued) {
showError({
title: 'Update Failed',
message: 'Request will be reattempt when you are online',
})
} else {
showError({
title: 'Failed to update',
message: error,
})
}
}
break
case 'start':
startChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'started')
},
onError: error => {
showError({
title: 'Failed to start',
message: error.message || 'Unable to start chore',
})
},
})
break
case 'pause':
pauseChore.mutate(chore.id, {
onSuccess: async res => {
const data = await res.json()
const newChore = { ...chore, status: data.res.status }
updateChoreInState(newChore, 'paused')
},
onError: error => {
showError({
title: 'Failed to pause',
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: 'Failed to approve',
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: 'Failed to reject',
message: error.message || 'Unable to reject chore',
})
}
break
case 'delete':
setConfirmModelConfig({
isOpen: true,
title: 'Delete Chore',
confirmText: 'Delete',
cancelText: 'Cancel',
message: 'Are you sure you want to delete this chore?',
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)
showSuccess({
title: 'Task Deleted',
message: 'The task has been deleted successfully.',
})
}
} catch (error) {
showError({
title: 'Failed to delete',
message: error,
})
}
}
setConfirmModelConfig({})
},
})
break
case 'archive':
try {
await new Promise((resolve, reject) => {
archiveChore.mutate(chore.id, {
onSuccess: data => {
updateChoreInState(data, 'archive')
resolve(data)
},
onError: error => {
showError({
title: 'Failed to archive',
message: error.message || 'Unable to archive chore',
})
reject(error)
},
})
})
} catch (error) {
}
break
case 'skip':
try {
const response = await SkipChore(chore.id)
if (response.ok) {
const data = await response.json()
updateChoreInState(data.res, 'skipped')
}
} catch (error) {
showError({
title: 'Failed to skip',
message: error,
})
}
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) {
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 '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,
startChore,
pauseChore,
],
)
const handleChangeDueDate = useCallback(
newDate => {
if (!modalChore) return
UpdateDueDate(modalChore.id, newDate).then(response => {
if (response.ok) {
response.json().then(data => {
const newChore = modalChore
newChore.nextDueDate = newDate
updateChoreInState(newChore, 'rescheduled')
})
}
})
closeModal()
},
[modalChore, updateChoreInState, closeModal],
)
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: 'Nudge Sent!',
message: data.message || 'Nudge sent successfully',
})
} else {
throw new Error('Failed to send nudge')
}
} catch (error) {
showError({
title: 'Failed to Send Nudge',
message: error.message || 'Unable to send nudge at this time',
})
} finally {
closeModal()
}
},
[showSuccess, showError, closeModal],
)
const handleBulkComplete = useCallback(async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Complete Tasks',
confirmText: 'Complete',
cancelText: '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: '✅ Tasks Completed',
message: `Successfully completed ${completedTasks.length} task${completedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be completed.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Complete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, impersonatedUser, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkArchive = useCallback(async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Archive Tasks',
confirmText: 'Archive',
cancelText: '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: '📦 Tasks Archived',
message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be archived.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Archive Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, archiveChore, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkDelete = useCallback(async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Delete Tasks',
confirmText: 'Delete',
cancelText: '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: '🗑️ Tasks Deleted',
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: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Delete Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, chores, filteredChores, setChores, setFilteredChores, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
const handleBulkSkip = useCallback(async () => {
const selectedData = getSelectedChoresData()
if (selectedData.length === 0) return
setConfirmModelConfig({
isOpen: true,
title: 'Skip Tasks',
confirmText: 'Skip',
cancelText: '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: '⏭️ Tasks Skipped',
message: `Successfully skipped ${skippedTasks.length} task${skippedTasks.length > 1 ? 's' : ''}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length > 1 ? 's' : ''} could not be skipped.`,
})
}
refetchChores()
clearSelection()
} catch (error) {
showError({
title: 'Bulk Skip Failed',
message: 'An unexpected error occurred. Please try again.',
})
}
}
setConfirmModelConfig({})
},
})
}, [getSelectedChoresData, showSuccess, showError, refetchChores, clearSelection, setConfirmModelConfig])
return {
handleChoreAction,
handleChangeDueDate,
handleCompleteWithPastDate,
handleAssigneeChange,
handleCompleteWithNote,
handleNudge,
handleBulkComplete,
handleBulkArchive,
handleBulkDelete,
handleBulkSkip,
}
}

View File

@@ -0,0 +1,87 @@
import { useState, useMemo, useCallback } from 'react'
import Fuse from 'fuse.js'
import { filterByProject, ChoreFilters } from '../../../utils/Chores'
export const useChoreFilters = ({
chores,
selectedProject,
impersonatedUser,
userProfile,
}) => {
const [searchTerm, setSearchTerm] = useState('')
const [searchFilter, setSearchFilter] = useState('All')
const [selectedChoreFilter, setSelectedChoreFilter] = useState(
localStorage.getItem('selectedChoreFilter') || 'anyone',
)
const projectFilteredChores = useMemo(() => {
if (!selectedProject) return chores
if (selectedProject.id === 'default') {
return chores.filter(chore => !chore.projectId)
}
return filterByProject(chores, selectedProject.id)
}, [chores, selectedProject])
const searchFilteredChores = useMemo(() => {
let baseChores = projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
}
if (impersonatedUser) {
baseChores = baseChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
return baseChores.filter(
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
)
}, [
searchTerm,
projectFilteredChores,
impersonatedUser,
userProfile?.id,
selectedChoreFilter,
])
const setSelectedChoreFilterWithCache = useCallback(value => {
setSelectedChoreFilter(value)
localStorage.setItem('selectedChoreFilter', value)
}, [])
const clearFilters = useCallback(() => {
setSearchFilter('All')
setSearchTerm('')
}, [])
return {
searchTerm,
searchFilter,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
setSearchTerm,
setSearchFilter,
setSelectedChoreFilter,
setSelectedChoreFilterWithCache,
clearFilters,
}
}

View File

@@ -0,0 +1,27 @@
import { useState, useCallback } from 'react'
export const useChoreModals = () => {
const [activeModal, setActiveModal] = useState(null)
const [modalData, setModalData] = useState({})
const [modalChore, setModalChore] = useState(null)
const openModal = useCallback((modal, chore, data = {}) => {
setActiveModal(modal)
setModalChore(chore)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalChore(null)
setModalData({})
}, [])
return {
activeModal,
modalChore,
modalData,
openModal,
closeModal,
}
}

View File

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react'
export const useKeyboardShortcuts = ({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
choreSections,
openChoreSections,
handlers,
}) => {
const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false)
useEffect(() => {
const handleKeyDown = event => {
if (addTaskModalOpen) return
if (event.ctrlKey || event.metaKey) {
setShowKeyboardShortcuts(true)
}
const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey
if (isHoldingCmdOrCtrl && event.key === 'k') {
event.preventDefault()
handlers.onOpenTaskModal()
return
}
if (addTaskModalOpen) return
if (isHoldingCmdOrCtrl && event.key === 'j') {
event.preventDefault()
handlers.onNavigateToCreate()
return
} else if (isHoldingCmdOrCtrl && event.key === 'f') {
event.preventDefault()
handlers.onFocusSearch()
return
} else if (isHoldingCmdOrCtrl && event.key === 'x') {
event.preventDefault()
if (searchTerm?.length > 0) {
handlers.onCloseSearch()
}
} else if (isHoldingCmdOrCtrl && event.key === 's') {
event.preventDefault()
handlers.onToggleMultiSelect()
return
} else if (
isHoldingCmdOrCtrl &&
!event.shiftKey &&
event.key === 'a' &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
if (!isMultiSelectMode) {
handlers.onEnableMultiSelectAndSelectAll()
} else {
let visibleChores = []
if (searchTerm?.length > 0 || searchFilter !== 'All') {
visibleChores = filteredChores
const allVisibleSelected =
visibleChores.length > 0 &&
visibleChores.every(chore => selectedChores.has(chore.id))
if (allVisibleSelected) {
handlers.onShowMessage({
title: '✅ All Tasks Selected',
message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`,
})
} else {
handlers.onSelectAll()
handlers.onShowMessage({
title: '🎯 Tasks Selected',
message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`,
})
}
} else {
const expandedChores = choreSections
.filter((_section, index) => openChoreSections[index])
.flatMap(section => section.content || [])
const allExpandedSelected =
expandedChores.length > 0 &&
expandedChores.every(chore => selectedChores.has(chore.id))
const allChores = choreSections.flatMap(
section => section.content || [],
)
const allChoresSelected =
allChores.length > 0 &&
allChores.every(chore => selectedChores.has(chore.id))
if (allChoresSelected) {
handlers.onShowMessage({
title: '✅ All Tasks Selected',
message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`,
})
} else if (allExpandedSelected) {
handlers.onSelectAll()
const collapsedCount = allChores.length - expandedChores.length
handlers.onShowMessage({
title: '🎯 All Tasks Selected',
message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`,
})
} else {
handlers.onSelectAll()
handlers.onShowMessage({
title: '🎯 Tasks Selected',
message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`,
})
}
}
}
}
if (isMultiSelectMode) {
if (event.key === 'Escape') {
event.preventDefault()
handlers.onClearSelection()
return
}
if (
isHoldingCmdOrCtrl &&
event.key === 'Enter' &&
selectedChores.size > 0
) {
event.preventDefault()
handlers.onBulkComplete()
return
}
if (
isHoldingCmdOrCtrl &&
event.key === '/' &&
selectedChores.size > 0
) {
event.preventDefault()
handlers.onBulkSkip()
return
}
if (
isHoldingCmdOrCtrl &&
(event.key === 'x' || event.key === 'X') &&
selectedChores.size > 0 &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
handlers.onBulkArchive()
return
}
}
if (
isHoldingCmdOrCtrl &&
(event.key === 'e' || event.key === 'E') &&
selectedChores.size > 0 &&
!['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)
) {
event.preventDefault()
if (isMultiSelectMode && selectedChores.size > 0) {
handlers.onBulkDelete()
}
return
}
if (isHoldingCmdOrCtrl && event.key === 'o') {
event.preventDefault()
handlers.onNavigateToArchived()
return
}
}
const handleKeyUp = event => {
if (!event.ctrlKey && !event.metaKey) {
setShowKeyboardShortcuts(false)
}
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('keyup', handleKeyUp)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('keyup', handleKeyUp)
}
}, [isMultiSelectMode, selectedChores.size, addTaskModalOpen])
return { showKeyboardShortcuts }
}

View File

@@ -0,0 +1,89 @@
import { useState, useCallback } from 'react'
export const useMultiSelect = () => {
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false)
const [selectedChores, setSelectedChores] = useState(new Set())
const toggleMultiSelectMode = useCallback(() => {
const newMode = !isMultiSelectMode
setIsMultiSelectMode(newMode)
if (newMode) {
setSelectedChores(new Set())
}
}, [isMultiSelectMode])
const toggleChoreSelection = useCallback(
choreId => {
const newSelection = new Set(selectedChores)
if (newSelection.has(choreId)) {
newSelection.delete(choreId)
} else {
newSelection.add(choreId)
}
setSelectedChores(newSelection)
},
[selectedChores],
)
const selectAllVisibleChores = useCallback(
(visibleChores, choreSections = [], openChoreSections = {}) => {
let choresToSelect = []
if (visibleChores && visibleChores.length > 0) {
choresToSelect = visibleChores
} else {
const expandedChores = choreSections
.filter((_section, index) => openChoreSections[index])
.flatMap(section => section.content || [])
const allExpandedSelected =
expandedChores.length > 0 &&
expandedChores.every(chore => selectedChores.has(chore.id))
if (allExpandedSelected) {
choresToSelect = choreSections.flatMap(section => section.content || [])
} else {
choresToSelect = expandedChores
}
}
if (choresToSelect.length > 0) {
const allIds = new Set(choresToSelect.map(chore => chore.id))
setSelectedChores(allIds)
}
return choresToSelect.length
},
[selectedChores],
)
const clearSelection = useCallback(() => {
if (selectedChores.size === 0) {
setIsMultiSelectMode(false)
return
}
setSelectedChores(new Set())
}, [selectedChores.size])
const getSelectedChoresData = useCallback(
allChores => {
return Array.from(selectedChores)
.map(id => allChores.find(chore => chore.id === id))
.filter(Boolean)
},
[selectedChores],
)
return {
isMultiSelectMode,
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
selectAllVisibleChores,
clearSelection,
getSelectedChoresData,
setIsMultiSelectMode,
setSelectedChores,
}
}

View File

@@ -0,0 +1,45 @@
import { useState, useMemo, useCallback } from 'react'
export const useProjectFilter = projects => {
const [selectedProject, setSelectedProject] = useState(() => {
const saved = localStorage.getItem('selectedProject')
return saved ? JSON.parse(saved) : null
})
const projectsWithDefault = useMemo(() => {
const defaultProject = {
id: 'default',
name: 'Default Project',
description: 'Your default project workspace',
color: '#1976d2',
icon: 'FolderOpen',
}
const hasDefault = projects.some(
p => p.id === 'default' || p.name === 'Default Project',
)
return hasDefault ? projects : [defaultProject, ...projects]
}, [projects])
const setSelectedProjectWithCache = useCallback(project => {
const finalProject = project || null
setSelectedProject(finalProject)
localStorage.setItem('selectedProject', JSON.stringify(finalProject))
const newUrl = new URL(window.location)
if (finalProject && finalProject.id !== 'default') {
newUrl.searchParams.set('project', encodeURIComponent(finalProject.id))
} else {
newUrl.searchParams.delete('project')
}
window.history.replaceState({}, '', newUrl)
}, [])
return {
selectedProject,
projectsWithDefault,
setSelectedProjectWithCache,
}
}