diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 141cd36..8896241 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -1,20 +1,14 @@ import { Add, - Archive, Bolt, CalendarMonth, CancelRounded, CheckBox, CheckBoxOutlineBlank, - Close, - Delete, - Done, EditCalendar, ExpandCircleDown, Grain, PriorityHigh, - SelectAll, - SkipNext, Sort, Style, ViewAgenda, @@ -30,7 +24,6 @@ import { Container, Divider, IconButton, - Input, List, Menu, MenuItem, @@ -39,23 +32,13 @@ import { import Fuse from 'fuse.js' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' -import { - useArchiveChore, - useChores, - useDeleteChores, - useUnArchiveChore, -} from '../../queries/ChoreQueries' +import { useChores } from '../../queries/ChoreQueries' import { useNotification } from '../../service/NotificationProvider' import { TASK_COLOR } from '../../utils/Colors' import Priorities from '../../utils/Priorities' import LoadingComponent from '../components/Loading' import { useLabels } from '../Labels/LabelQueries' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' -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' import ChoreCard from './ChoreCard' import CompactChoreCard from './CompactChoreCard' import IconButtonWithMenu from './IconButtonWithMenu' @@ -64,7 +47,6 @@ import { useMediaQuery } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' -import { usePauseChore, useStartChore } from '../../queries/TimeQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { ChoreFilters, @@ -72,17 +54,6 @@ import { ChoreSorter, filterByProject, } from '../../utils/Chores' -import { - ApproveChore, - DeleteChore, - MarkChoreComplete, - NudgeChore, - RejectChore, - SkipChore, - UndoChoreAction, - UpdateChoreAssignee, - UpdateDueDate, -} from '../../utils/Fetcher' import { getSafeBottom } from '../../utils/SafeAreaUtils.js' import TaskInput from '../components/AddTaskModal' import CalendarDual from '../components/CalendarDual' @@ -96,6 +67,15 @@ import { import NotificationAccessSnackbar from './NotificationAccessSnackbar' import Sidepanel from './Sidepanel' import SortAndGrouping from './SortAndGrouping' +import ChoreModals from './components/ChoreModals' +import MultiSelectToolbar from './components/MultiSelectToolbar' +import SearchBar from './components/SearchBar' +import { useChoreActions } from './hooks/useChoreActions' +import { useChoreFilters } from './hooks/useChoreFilters' +import { useChoreModals } from './hooks/useChoreModals' +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts' +import { useMultiSelect } from './hooks/useMultiSelect' +import { useProjectFilter } from './hooks/useProjectFilter' const MyChores = () => { const { data: userProfile, isLoading: isUserProfileLoading } = @@ -104,21 +84,20 @@ const MyChores = () => { const { showSuccess, showError, showWarning, showUndo } = useNotification() const queryClient = useQueryClient() const { impersonatedUser } = useImpersonateUser() - const archiveChore = useArchiveChore() - const unarchiveChore = useUnArchiveChore() - const deleteChores = useDeleteChores() - const startChore = useStartChore() - const pauseChore = usePauseChore() + const Navigate = useNavigate() + const [searchParams] = useSearchParams() + const { data: userLabels, isLoading: userLabelsLoading } = useLabels() + const { data: projects = [], isLoading: projectsLoading } = useProjects() + const { + data: choresData, + isLoading: choresLoading, + refetch: refetchChores, + } = useChores(false) + const { data: membersData, isLoading: membersLoading } = useCircleMembers() + const [chores, setChores] = useState([]) const [filteredChores, setFilteredChores] = useState([]) - const [searchFilter, setSearchFilter] = useState('All') - const [selectedProject, setSelectedProject] = useState(() => { - // Get saved project from localStorage, default to null - const saved = localStorage.getItem('selectedProject') - return saved ? JSON.parse(saved) : null - }) const [choreSections, setChoreSections] = useState([]) - const [showSearchFilter, setShowSearchFilter] = useState(false) const [addTaskModalOpen, setAddTaskModalOpen] = useState(false) const [taskInputFocus, setTaskInputFocus] = useState(0) @@ -134,56 +113,47 @@ const MyChores = () => { return {} } }) - const [selectedChoreFilter, setSelectedChoreFilter] = useState( - localStorage.getItem('selectedChoreFilter') || 'anyone', - ) - const [searchTerm, setSearchTerm] = useState('') - const [anchorEl, setAnchorEl] = useState(null) const [viewMode, setViewMode] = useState( localStorage.getItem('choreCardViewMode') || 'default', ) const [selectedCalendarDate, setSelectedCalendarDate] = useState(new Date()) const menuRef = useRef(null) - const Navigate = useNavigate() - const [searchParams] = useSearchParams() - const { data: userLabels, isLoading: userLabelsLoading } = useLabels() - const { data: projects = [], isLoading: projectsLoading } = useProjects() - - // Create a projects list that includes the default project for the ProjectSelector - const projectsWithDefault = useMemo(() => { - const defaultProject = { - id: 'default', - name: 'Default Project', - description: 'Your default project workspace', - color: '#1976d2', - icon: 'FolderOpen', - } - - // Check if default project already exists in the list - const hasDefault = projects.some( - p => p.id === 'default' || p.name === 'Default Project', - ) - - return hasDefault ? projects : [defaultProject, ...projects] - }, [projects]) - const { - data: choresData, - isLoading: choresLoading, - refetch: refetchChores, - } = useChores(false) - const { data: membersData, isLoading: membersLoading } = useCircleMembers() - - // Multi-select state - const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) - const [selectedChores, setSelectedChores] = useState(new Set()) const [confirmModelConfig, setConfirmModelConfig] = useState({}) - // Centralized modal state - const [activeModal, setActiveModal] = useState(null) - const [modalData, setModalData] = useState({}) - const [modalChore, setModalChore] = useState(null) - const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) + const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } = + useProjectFilter(projects) + + const { + searchTerm, + searchFilter, + selectedChoreFilter, + projectFilteredChores, + searchFilteredChores, + setSearchTerm, + setSearchFilter, + setSelectedChoreFilterWithCache, + clearFilters, + } = useChoreFilters({ + chores, + selectedProject, + impersonatedUser, + userProfile, + }) + + const { + isMultiSelectMode, + selectedChores, + toggleMultiSelectMode, + toggleChoreSelection, + selectAllVisibleChores, + clearSelection, + getSelectedChoresData, + } = useMultiSelect() + + const { activeModal, modalChore, modalData, openModal, closeModal } = + useChoreModals() + const processedChores = useMemo(() => { if (!choresData?.res) { return [] @@ -328,954 +298,73 @@ const MyChores = () => { } }, [searchInputFocus]) - // Keyboard shortcuts for multi-select and other actions - useEffect(() => { - const handleKeyDown = event => { - // if the modal open we don't want anything here to trigger - if (addTaskModalOpen) return - // if Ctrl/Cmd + / then show keyboard shortcuts modal - if (event.ctrlKey || event.metaKey) { - setShowKeyboardShortcuts(true) - } - const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey - // Ctrl/Cmd + K to open task modal - if (isHoldingCmdOrCtrl && event.key === 'k') { - event.preventDefault() - setAddTaskModalOpen(true) - return - } - - if (addTaskModalOpen) { - // we want to ignore anything in here until the modal close - return - } - - // Ctrl/Cmd + J to navigate to create chore page - if (isHoldingCmdOrCtrl && event.key === 'j') { - event.preventDefault() - Navigate(`/chores/create`) - return - } - - // Ctrl/Cmd + F to focus search input: - else if (isHoldingCmdOrCtrl && event.key === 'f') { - event.preventDefault() - searchInputRef.current?.focus() - return - // Ctrl/Cmd + X to close search input - } else if (isHoldingCmdOrCtrl && event.key === 'x') { - event.preventDefault() - if (searchTerm?.length > 0) { - handleSearchClose() - } - } - // Ctrl/Cmd + S Toggle Multi-select mode - else if (isHoldingCmdOrCtrl && event.key === 's') { - event.preventDefault() - toggleMultiSelectMode() - return - } - - // Ctrl/Cmd + A to select all - works both in and out of multi-select mode - else if ( - isHoldingCmdOrCtrl && - !event.shiftKey && - event.key === 'a' && - !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) - ) { - event.preventDefault() - if (!isMultiSelectMode) { - // Enable multi-select mode and select all visible tasks - setIsMultiSelectMode(true) - setTimeout(() => { - selectAllVisibleChores() - }, 0) - // showSuccess({ - // title: '🎯 Multi-select Mode Active', - // message: 'Selected all visible tasks. Press Esc to exit.', - // }) - } else { - // Already in multi-select mode, check if all visible tasks are already selected - let visibleChores = [] - - if (searchTerm?.length > 0 || searchFilter !== 'All') { - visibleChores = filteredChores - const allVisibleSelected = - visibleChores.length > 0 && - visibleChores.every(chore => selectedChores.has(chore.id)) - - if (allVisibleSelected) { - showSuccess({ - title: '✅ All Tasks Selected', - message: `All ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's are' : ' is'} already selected.`, - }) - } else { - selectAllVisibleChores() - showSuccess({ - title: '🎯 Tasks Selected', - message: `Selected ${visibleChores.length} filtered task${visibleChores.length !== 1 ? 's' : ''}.`, - }) - } - } else { - // Check expanded sections first - const expandedChores = choreSections - .filter((_section, index) => openChoreSections[index]) - .flatMap(section => section.content || []) - - const allExpandedSelected = - expandedChores.length > 0 && - expandedChores.every(chore => selectedChores.has(chore.id)) - - // Get all chores (including collapsed sections) - const allChores = choreSections.flatMap( - section => section.content || [], - ) - const allChoresSelected = - allChores.length > 0 && - allChores.every(chore => selectedChores.has(chore.id)) - - if (allChoresSelected) { - // All chores (including collapsed) are already selected - showSuccess({ - title: '✅ All Tasks Selected', - message: `All ${allChores.length} task${allChores.length !== 1 ? 's are' : ' is'} already selected (including collapsed sections).`, - }) - } else if (allExpandedSelected) { - // All expanded are selected, now select ALL (including collapsed) - selectAllVisibleChores() // This will now select all chores - const collapsedCount = allChores.length - expandedChores.length - showSuccess({ - title: '🎯 All Tasks Selected', - message: `Selected all ${allChores.length} tasks (including ${collapsedCount} from collapsed sections).`, - }) - } else { - // Not all expanded are selected, select expanded only - selectAllVisibleChores() // This will select expanded only - showSuccess({ - title: '🎯 Tasks Selected', - message: `Selected ${expandedChores.length} task${expandedChores.length !== 1 ? 's' : ''} from expanded sections.`, - }) - } - } - } - } - - // Multi-select keyboard shortcuts (only when in multi-select mode) - if (isMultiSelectMode) { - // Escape to clear selection or exit multi-select mode - if (event.key === 'Escape') { - event.preventDefault() - if (selectedChores.size > 0) { - clearSelection() - } else { - setIsMultiSelectMode(false) - } - return - } - - // Enter key for bulk complete - if ( - isHoldingCmdOrCtrl && - event.key === 'Enter' && - selectedChores.size > 0 - ) { - event.preventDefault() - handleBulkComplete() - return - } - - // "/" key for bulk skip - if ( - isHoldingCmdOrCtrl && - event.key === '/' && - selectedChores.size > 0 - ) { - event.preventDefault() - handleBulkSkip() - return - } - - // "x" key for bulk archive (without shift or modifiers) - if ( - isHoldingCmdOrCtrl && - (event.key === 'x' || event.key === 'X') && - selectedChores.size > 0 && - !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) - ) { - event.preventDefault() - handleBulkArchive() - 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) { - handleBulkDelete() - } - return - } - - // Ctrl/Cmd + O to navigate to archived tasks - if (isHoldingCmdOrCtrl && event.key === 'o') { - event.preventDefault() - Navigate('/archived') - 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]) - - // Helper function to update local state and show notifications - const updateChoreInState = (updatedChore, event) => { - let newChores = chores.map(c => - c.id === updatedChore.id ? updatedChore : c, - ) - let newFilteredChores = filteredChores.map(c => - c.id === updatedChore.id ? updatedChore : c, - ) - - // Remove from lists if archived or completed (once/trigger types) - 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) - setChoreSections( - ChoresGrouper( - selectedChoreSection, - newChores, - ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ - selectedChoreFilter - ], - ), - ) - - // Invalidate query to ensure sync with server data - // This prevents data from getting stale after token refresh or background updates - queryClient.invalidateQueries({ queryKey: ['chores'] }) - - // Show notifications - handle undoable actions with undo button (only for single actions) - if (!isMultiSelectMode) { - 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 { - console.log('Failed to undo', undoResponse) - - throw new Error('Failed to undo') - } - } catch (error) { - showError({ - title: 'Undo Failed', - message: 'Unable to undo the action. Please try again.', - }) - console.log('Undo error:', error) - } - }, - }) - return // Exit early for undoable actions - } - } - - // Regular notifications for non-undoable actions - 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 }) - } - } - - // Centralized action handler for all chore operations - const handleChoreAction = 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) { - // Remove from state and show notification - const newChores = chores.filter(c => c.id !== chore.id) - const newFilteredChores = filteredChores.filter( - c => c.id !== chore.id, - ) - setChores(newChores) - setFilteredChores(newFilteredChores) - setChoreSections( - ChoresGrouper( - selectedChoreSection, - newChores, - ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ - selectedChoreFilter - ], - ), - ) - 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) { - // Error already handled in onError callback - } - 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 - - // Quick reschedule actions (with date provided) - case 'changeDueDate': - console.log('Reschedule response data111:', chore, extraData) - - if (extraData && 'date' in extraData) { - // Quick reschedule with specific date (including null for remove) - try { - const response = await UpdateDueDate(chore.id, extraData.date) - if (response.ok) { - const data = await response.json() - console.log('Reschedule response data:', data, chore, extraData) - - 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 { - // Open modal for custom date selection - setModalChore(chore) - setModalData(extraData) - setActiveModal(action) - } - break - - // Modal-based actions - case 'completeWithNote': - case 'completeWithPastDate': - case 'changeAssignee': - case 'writeNFC': - case 'nudge': - setModalChore(chore) - setModalData(extraData) - setActiveModal(action) - break - - default: - console.warn('Unknown action:', action) - } - } - - const closeModal = () => { - setActiveModal(null) - setModalChore(null) - setModalData({}) - } - - const handleChangeDueDate = 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() - } - - const handleCompleteWithPastDate = 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() - } - - const handleAssigneeChange = 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() - } - - const handleCompleteWithNote = 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() - } - - const handleNudge = 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() - } - } - - // Auto-update selected calendar date when day changes (large screen only) - useEffect(() => { - if (!isLargeScreen || viewMode !== 'calendar' || !selectedCalendarDate) { - return - } - - const now = new Date() - const tomorrow = new Date(now) - tomorrow.setDate(tomorrow.getDate() + 1) - tomorrow.setHours(0, 0, 0, 0) // Set to start of tomorrow - - const msUntilTomorrow = tomorrow.getTime() - now.getTime() - - const timeout = setTimeout(() => { - const today = new Date() - const yesterday = new Date(today) - yesterday.setDate(yesterday.getDate() - 1) - - // Check if selected date is now yesterday and update to today - if (selectedCalendarDate.toDateString() === yesterday.toDateString()) { - setSelectedCalendarDate(today) - } - }, msUntilTomorrow) - - return () => clearTimeout(timeout) - }, [isLargeScreen, viewMode, selectedCalendarDate]) - - // Handle URL parameters for navigation from calendar - useEffect(() => { - const filter = searchParams.get('filter') - const view = searchParams.get('view') - const projectId = searchParams.get('project') - - if (view === 'calendar') { - setViewMode('calendar') - setSearchFilter('All') - setSelectedCalendarDate(null) - return - } - - if (projectId && chores.length > 0 && projectsWithDefault.length > 0) { - const decodedProjectId = decodeURIComponent(projectId) - let project = null - - // Try to find project by ID first, then by name for backward compatibility - if (decodedProjectId === 'default') { - project = { id: 'default', name: 'Default Project' } - } else { - project = projectsWithDefault.find( - p => p.id === decodedProjectId || p.id === Number(decodedProjectId), - ) - } - - if (project) { - setSelectedProjectWithCache(project) - } - return - } - - if (filter && chores.length > 0) { - let filterKey = '' - let filteredChores = [] - - switch (filter) { - case 'overdue': - filterKey = 'Overdue' - filteredChores = FILTERS['Overdue'](chores) - break - case 'unplanned': - filterKey = 'No Due Date' - filteredChores = FILTERS['No Due Date'](chores) - break - case 'pending-approval': - filterKey = 'Pending Approval' - filteredChores = FILTERS['Pending Approval'](chores) - break - default: - return - } - - setFilteredChores(filteredChores) - setSearchFilter(filterKey) - setViewMode('default') - } - }, [searchParams, chores, projectsWithDefault]) - const setSelectedChoreSectionWithCache = value => { - setSelectedChoreSection(value) - localStorage.setItem('selectedChoreSection', value) - } - const setOpenChoreSectionsWithCache = value => { - setOpenChoreSections(value) - localStorage.setItem('openChoreSections', JSON.stringify(value)) - } - const setSelectedChoreFilterWithCache = value => { - setSelectedChoreFilter(value) - localStorage.setItem('selectedChoreFilter', value) - // Clear selected calendar date when filters change - setSelectedCalendarDate(null) - } - - const setSelectedProjectWithCache = project => { - // Keep the project as-is, including the default project object - const finalProject = project || null - - setSelectedProject(finalProject) - console.log('final project', finalProject) - - localStorage.setItem('selectedProject', JSON.stringify(finalProject)) - setViewMode('default') - setSelectedCalendarDate(null) - // Clear other filters when project changes - setSearchFilter('All') - - // Don't manually set filteredChores - let the memo handle it - // This ensures consistency between filteredChores and choreSections - - // Update URL to reflect project selection - 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) - } - - const toggleViewMode = () => { - const modes = ['default', 'compact', 'calendar'] - const currentIndex = modes.indexOf(viewMode) - const nextIndex = (currentIndex + 1) % modes.length - const newMode = modes[nextIndex] - setViewMode(newMode) - localStorage.setItem('choreCardViewMode', newMode) - - // Clear selected calendar date when switching away from calendar view - if (newMode !== 'calendar') { - setSelectedCalendarDate(null) - } - } - - // Helper function to render the appropriate card component - const renderChoreCard = (chore, key) => { - performance.mark(`chore-render-start-${chore.id}`) - const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard - const result = ( - toggleChoreSelection(chore.id)} - /> - ) - performance.mark(`chore-render-end-${chore.id}`) - performance.measure( - `chore-render-${chore.id}`, - `chore-render-start-${chore.id}`, - `chore-render-end-${chore.id}`, - ) - return result - } - - // First layer: Apply project filter to get base chores - // IMPORTANT: Use local 'chores' state instead of 'processedChores' to ensure - // updates via updateChoreInState are reflected in filtered results - const projectFilteredChores = useMemo(() => { - if (!selectedProject) { - return chores - } - - // Special case: Default project shows only tasks without a projectId - if (selectedProject.id === 'default') { - return chores.filter(chore => !chore.projectId) - } - - // Other projects: use the existing filter function - return filterByProject(chores, selectedProject.id) - }, [chores, selectedProject]) - - // Second layer: Apply additional filters on top of project-filtered chores - const getFilteredChores = useMemo(() => { - let result = [] - let baseChores = projectFilteredChores // Start with project-filtered chores - - if (searchTerm?.length > 0 || searchFilter !== 'All') { - // Apply search/label/priority filters to project-filtered chores - if (searchTerm?.length > 0) { - // For search, use fuse search on project-filtered chores - const projectFilteredForSearch = baseChores.map(c => ({ - ...c, - raw_label: c.labelsV2?.map(l => l.name).join(' '), - })) - const fuse = new Fuse(projectFilteredForSearch, { - keys: ['name', 'raw_label'], - includeScore: true, - isCaseSensitive: false, - findAllMatches: true, - }) - result = fuse - .search(searchTerm.toLowerCase()) - .map(result => result.item) - } else { - result = filteredChores.filter(chore => { - if (!selectedProject) return true - - // Default project: only show tasks without projectId - if (selectedProject.id === 'default') { - return !chore.projectId - } - - // Other projects: use existing filter - return filterByProject([chore], selectedProject.id).length > 0 - }) - } - } else { - let choresToFilter = baseChores - - if (impersonatedUser) { - choresToFilter = choresToFilter.filter( - chore => chore.assignedTo === impersonatedUser.userId, - ) - } - - result = choresToFilter.filter( - ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ - selectedChoreFilter - ], - ) - } - - return result - }, [ + const { + handleChoreAction, + handleChangeDueDate, + handleCompleteWithPastDate, + handleAssigneeChange, + handleCompleteWithNote, + handleNudge, + handleBulkComplete, + handleBulkArchive, + handleBulkDelete, + handleBulkSkip, + } = useChoreActions({ + chores, + filteredChores, + setChores, + setFilteredChores, + userProfile, + impersonatedUser, + showSuccess, + showError, + showWarning, + showUndo, + refetchChores, + setConfirmModelConfig, + openModal, + closeModal, + modalChore, + getSelectedChoresData, + clearSelection, + }) + + const { showKeyboardShortcuts } = useKeyboardShortcuts({ + isMultiSelectMode, + selectedChores, + addTaskModalOpen, searchTerm, searchFilter, filteredChores, - projectFilteredChores, - selectedProject, - impersonatedUser, - userProfile?.id, - selectedChoreFilter, - ]) - - const getChoresForDate = useCallback( - date => { - const filteredChoresData = getFilteredChores - const result = filteredChoresData.filter(chore => { - if (!chore.nextDueDate) return false - const choreDate = new Date(chore.nextDueDate).toLocaleDateString() - const selectedDate = date.toLocaleDateString() - return choreDate === selectedDate - }) - - return result + choreSections, + openChoreSections, + handlers: { + onOpenTaskModal: () => setAddTaskModalOpen(true), + onNavigateToCreate: () => Navigate('/chores/create'), + onFocusSearch: () => searchInputRef.current?.focus(), + onCloseSearch: () => { + setSearchTerm('') + setFilteredChores(chores) + setSearchInputFocus(0) + }, + onToggleMultiSelect: toggleMultiSelectMode, + onEnableMultiSelectAndSelectAll: () => { + toggleMultiSelectMode() + setTimeout(() => { + selectAllVisibleChores() + }, 0) + }, + onSelectAll: () => selectAllVisibleChores(), + onClearSelection: clearSelection, + onBulkComplete: handleBulkComplete, + onBulkSkip: handleBulkSkip, + onBulkArchive: handleBulkArchive, + onBulkDelete: handleBulkDelete, + onNavigateToArchived: () => Navigate('/archived'), + onShowMessage: showSuccess, }, - [getFilteredChores], - ) + }) - const updateChores = newChore => { - let newChores = [...chores, newChore] - - // Filter chores based on impersonated user - if (impersonatedUser) { - newChores = newChores.filter( - chore => chore.assignedTo === impersonatedUser.userId, - ) - } - - setChores(newChores) - setFilteredChores(newChores) - setChoreSections( - ChoresGrouper( - selectedChoreSection, - newChores, - ChoreFilters(impersonatedUser?.userId || userProfile?.id)[ - selectedChoreFilter - ], - ), - ) - setSearchFilter('All') - } const handleMenuOutsideClick = event => { if ( anchorEl && @@ -1380,312 +469,99 @@ const MyChores = () => { const handleSearchClose = () => { setSearchTerm('') setFilteredChores(selectedProject ? projectFilteredChores : chores) - // remove the focus from the search input: setSearchInputFocus(0) - // Clear selected calendar date when search closes setSelectedCalendarDate(null) } - // Multi-select helper functions - const toggleMultiSelectMode = () => { - const newMode = !isMultiSelectMode - setIsMultiSelectMode(newMode) + const setSelectedChoreSectionWithCache = value => { + setSelectedChoreSection(value) + localStorage.setItem('selectedChoreSection', value) + } - if (newMode) { - setSelectedChores(new Set()) // Clear selection when exiting multi-select + const setOpenChoreSectionsWithCache = value => { + setOpenChoreSections(value) + localStorage.setItem('openChoreSections', JSON.stringify(value)) + } + + const toggleViewMode = () => { + const modes = ['default', 'compact', 'calendar'] + const currentIndex = modes.indexOf(viewMode) + const nextIndex = (currentIndex + 1) % modes.length + const newMode = modes[nextIndex] + setViewMode(newMode) + localStorage.setItem('choreCardViewMode', newMode) + + if (newMode !== 'calendar') { + setSelectedCalendarDate(null) } } - const toggleChoreSelection = choreId => { - const newSelection = new Set(selectedChores) - if (newSelection.has(choreId)) { - newSelection.delete(choreId) - } else { - newSelection.add(choreId) - } - setSelectedChores(newSelection) + const renderChoreCard = (chore, key) => { + const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard + return ( + toggleChoreSelection(chore.id)} + /> + ) } - const selectAllVisibleChores = () => { - let visibleChores = [] + const getFilteredChores = useMemo(() => { + let baseChores = projectFilteredChores if (searchTerm?.length > 0 || searchFilter !== 'All') { - // If there's a search term or filter, all filtered chores are visible - visibleChores = filteredChores - } else { - // First, get chores from expanded sections only - const expandedChores = choreSections - .filter((_section, index) => openChoreSections[index]) // Only expanded sections - .flatMap(section => section.content || []) // Get all chores from expanded sections - - // Check if all expanded chores are already selected - const allExpandedSelected = - expandedChores.length > 0 && - expandedChores.every(chore => selectedChores.has(chore.id)) - - if (allExpandedSelected) { - // If all expanded chores are already selected, select ALL chores (including collapsed sections) - visibleChores = choreSections.flatMap(section => section.content || []) - } else { - // Otherwise, just select expanded chores - visibleChores = expandedChores + if (searchTerm?.length > 0) { + const projectFilteredForSearch = baseChores.map(c => ({ + ...c, + raw_label: c.labelsV2?.map(l => l.name).join(' '), + })) + const fuse = new Fuse(projectFilteredForSearch, { + keys: ['name', 'raw_label'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + }) + return fuse.search(searchTerm).map(result => result.item) + } else if (searchFilter !== 'All') { + return filteredChores } } - if (visibleChores.length > 0) { - const allIds = new Set(visibleChores.map(chore => chore.id)) - setSelectedChores(allIds) + return baseChores + }, [projectFilteredChores, searchTerm, searchFilter, filteredChores]) + + const getChoresForDate = useCallback( + date => { + const filteredChoresData = getFilteredChores + return filteredChoresData.filter(chore => { + if (!chore.nextDueDate) return false + const choreDate = new Date(chore.nextDueDate).toLocaleDateString() + const selectedDate = date.toLocaleDateString() + return choreDate === selectedDate + }) + }, + [getFilteredChores], + ) + + const updateChores = newChore => { + let newChores = [...chores, newChore] + + if (impersonatedUser) { + newChores = newChores.filter( + chore => chore.assignedTo === impersonatedUser.userId, + ) } - } - const clearSelection = () => { - // if already empty, just exit multi-select mode: - if (selectedChores.size === 0) { - setIsMultiSelectMode(false) - return - } - setSelectedChores(new Set()) - } - - const getSelectedChoresData = () => { - return Array.from(selectedChores) - .map(id => chores.find(chore => chore.id === id)) - .filter(Boolean) - } - - // Bulk operations with improved UX and confirmation modal - const handleBulkComplete = 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({}) - }, - }) - } - const handleBulkArchive = 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) - // Remove from chores and filteredChores - 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) { - // Error already handled in onError callback - } - } - 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({}) - }, - }) - } - const handleBulkDelete = 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({}) - }, - }) - } - - const handleBulkSkip = 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} task${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({}) - }, - }) + setChores(newChores) + setFilteredChores(newChores) + setSearchFilter('All') } if ( @@ -1720,39 +596,13 @@ const MyChores = () => { gap: 0.5, }} > - { - setShowSearchFilter(true) - }} - fullWidth - sx={{ - mt: 1, - mb: 1, - borderRadius: 24, - height: 24, - borderColor: 'text.disabled', - padding: 1, - }} onChange={handleSearchChange} - startDecorator={ - - } - endDecorator={ - - {searchTerm && ( - <> - - - - )} - - } + onClose={handleSearchClose} + onFocus={() => setShowSearchFilter(true)} + showKeyboardShortcuts={showKeyboardShortcuts} + inputRef={searchInputRef} /> { - {/* Multi-select Toolbar with animation */} - - - {/* Selection Info and Controls */} - - - - - {selectedChores.size} task - {selectedChores.size !== 1 ? 's' : ''} selected - - - - - - - - - - - - {/* Action Buttons */} - - - - - - - - {/* - - - - - */} - - - + 0 || searchFilter !== 'All' + ? selectedChores.size === filteredChores.length + : selectedChores.size === + choreSections.flatMap(s => s.content || []).length + } + /> {/* Additional Filters Display */} {searchFilter !== 'All' && ( @@ -2711,71 +1291,17 @@ const MyChores = () => { )} - {/* Centralized Modals */} - {activeModal === 'changeDueDate' && modalChore && ( - - )} - - {activeModal === 'completeWithPastDate' && modalChore && ( - - )} - - {activeModal === 'changeAssignee' && modalChore && ( - handleAssigneeChange(selected.id)} - /> - )} - - {activeModal === 'completeWithNote' && modalChore && ( - - )} - - {activeModal === 'writeNFC' && modalChore && ( - - )} - - {activeModal === 'nudge' && modalChore && ( - - )} + ) } diff --git a/src/views/Chores/components/ChoreModals.jsx b/src/views/Chores/components/ChoreModals.jsx new file mode 100644 index 0000000..2a2a6e8 --- /dev/null +++ b/src/views/Chores/components/ChoreModals.jsx @@ -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 && ( + + )} + + {activeModal === 'completeWithPastDate' && modalChore && ( + + )} + + {activeModal === 'changeAssignee' && modalChore && ( + onAssigneeChange(selected.id)} + /> + )} + + {activeModal === 'completeWithNote' && modalChore && ( + + )} + + {activeModal === 'writeNFC' && modalChore && ( + + )} + + {activeModal === 'nudge' && modalChore && ( + + )} + + ) +} + +export default ChoreModals diff --git a/src/views/Chores/components/MultiSelectToolbar.jsx b/src/views/Chores/components/MultiSelectToolbar.jsx new file mode 100644 index 0000000..975c828 --- /dev/null +++ b/src/views/Chores/components/MultiSelectToolbar.jsx @@ -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 ( + + + + + + + {selectedCount} task{selectedCount !== 1 ? 's' : ''} selected + + + + + + + + + + + + + + + + + + + + + ) +} + +export default MultiSelectToolbar diff --git a/src/views/Chores/components/SearchBar.jsx b/src/views/Chores/components/SearchBar.jsx new file mode 100644 index 0000000..afced4d --- /dev/null +++ b/src/views/Chores/components/SearchBar.jsx @@ -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 ( + + } + endDecorator={ + + {value && ( + <> + + + + )} + + } + /> + ) +} + +export default SearchBar diff --git a/src/views/Chores/hooks/useChoreActions.js b/src/views/Chores/hooks/useChoreActions.js new file mode 100644 index 0000000..4a55622 --- /dev/null +++ b/src/views/Chores/hooks/useChoreActions.js @@ -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, + } +} diff --git a/src/views/Chores/hooks/useChoreFilters.js b/src/views/Chores/hooks/useChoreFilters.js new file mode 100644 index 0000000..2c6dc8a --- /dev/null +++ b/src/views/Chores/hooks/useChoreFilters.js @@ -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, + } +} diff --git a/src/views/Chores/hooks/useChoreModals.js b/src/views/Chores/hooks/useChoreModals.js new file mode 100644 index 0000000..46286e9 --- /dev/null +++ b/src/views/Chores/hooks/useChoreModals.js @@ -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, + } +} diff --git a/src/views/Chores/hooks/useKeyboardShortcuts.js b/src/views/Chores/hooks/useKeyboardShortcuts.js new file mode 100644 index 0000000..7e4de87 --- /dev/null +++ b/src/views/Chores/hooks/useKeyboardShortcuts.js @@ -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 } +} diff --git a/src/views/Chores/hooks/useMultiSelect.js b/src/views/Chores/hooks/useMultiSelect.js new file mode 100644 index 0000000..10d04c7 --- /dev/null +++ b/src/views/Chores/hooks/useMultiSelect.js @@ -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, + } +} diff --git a/src/views/Chores/hooks/useProjectFilter.js b/src/views/Chores/hooks/useProjectFilter.js new file mode 100644 index 0000000..428531d --- /dev/null +++ b/src/views/Chores/hooks/useProjectFilter.js @@ -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, + } +}