Files
donetick/src/views/Chores/MyChores.jsx
2026-07-28 02:02:44 -04:00

1549 lines
48 KiB
JavaScript

import {
Add,
Bolt,
CalendarMonth,
EditCalendar,
ExpandCircleDown,
PriorityHigh,
Style,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
Accordion,
AccordionDetails,
AccordionGroup,
Box,
Button,
Chip,
Container,
Divider,
IconButton,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
ChoreFilters,
ChoresGrouper,
ChoreSorter,
filterByProject,
} from '../../utils/Chores'
import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import ChoreModals from './components/ChoreModals'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
import { useCustomFilters } from './hooks/useCustomFilters'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useMultiSelect } from './hooks/useMultiSelect'
import { useProjectFilter } from './hooks/useProjectFilter'
import {
canScheduleNotification,
scheduleChoreNotification,
} from './LocalNotificationScheduler'
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning, showUndo } = useNotification()
const queryClient = useQueryClient()
const { impersonatedUser } = useImpersonateUser()
const Navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
const { data: projects = [], isLoading: projectsLoading } = useProjects()
const {
data: choresData,
isLoading: choresLoading,
isError: choresError,
error: choresErrorDetails,
refetch: refetchChores,
} = useChores(false)
const {
data: membersData,
isLoading: membersLoading,
isError: membersError,
} = useCircleMembers()
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
// 'voice' | 'scan' | null — set by the quick-capture widget deep links
const [addTaskInitialMode, setAddTaskInitialMode] = useState(null)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
const [searchInputFocus, setSearchInputFocus] = useState(0)
const [selectedChoreSection, setSelectedChoreSection] = useState(
localStorage.getItem('selectedChoreSection') || 'due_date',
)
const [openChoreSections, setOpenChoreSections] = useState(() => {
try {
return JSON.parse(localStorage.getItem('openChoreSections')) || {}
} catch {
return {}
}
})
const [anchorEl, setAnchorEl] = useState(null)
const [viewMode, setViewMode] = useState(
localStorage.getItem('choreCardViewMode') || 'default',
)
const [selectedCalendarDate, setSelectedCalendarDate] = useState(new Date())
const menuRef = useRef(null)
const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
useProjectFilter(projects)
const {
searchTerm,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm,
setSelectedChoreFilterWithCache,
} = useChoreFilters({
chores,
selectedProject,
impersonatedUser,
userProfile,
})
const {
isMultiSelectMode,
selectedChores,
toggleMultiSelectMode,
toggleChoreSelection,
selectAllVisibleChores,
clearSelection,
getSelectedChoresData,
} = useMultiSelect()
const { activeModal, modalChore, modalData, openModal, closeModal } =
useChoreModals()
const {
savedFilters,
activeFilter,
activeFilterId,
tempFilter,
tempFilterMeta,
filteredChores: customFilteredChores,
applyCustomFilter,
clearActiveFilter,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
hasFilterApplied,
} = useCustomFilters(
nonProjectFilteredChores,
membersData?.res,
userLabels,
projectsWithDefault,
)
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const quickFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Due Date',
type: 'single-select',
icon: <CalendarMonth />,
options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' },
{ value: 'Due Later', label: 'Due Later' },
{ value: 'No Due Date', label: 'No Due Date' },
{ value: 'Pending Approval', label: 'Pending Approval' },
],
filterFn: (item, value) => {
const now = new Date()
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
switch (value) {
case 'Overdue':
return d !== null && d < now
case 'Due today':
return d !== null && d.toDateString() === now.toDateString()
case 'Due in week':
return (
d !== null &&
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
d > now
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
case 'Pending Approval':
return item.status === 3
default:
return true
}
},
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
],
[userLabels],
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
if (!choresData?.res) {
return []
}
let sortedChores = [...choresData.res].sort(ChoreSorter)
if (impersonatedUser) {
sortedChores = sortedChores.filter(
chore =>
chore.assignedTo === impersonatedUser.userId ||
chore.assignees?.some(a => a.userId === impersonatedUser.userId) ||
chore.isPrivate === false,
)
}
return sortedChores
}, [choresData?.res, impersonatedUser])
const processedSections = useMemo(() => {
if (!chores.length || !userProfile?.id) {
return []
}
// Determine which chores to group based on active filter type
let choresToGroup = chores
if (tempFilter || activeFilterId) {
// Advanced/custom filter active
choresToGroup = customFilteredChores
} else if (hasQuickFilters) {
// Quick filter active (Due date, Priority, Labels)
choresToGroup = quickFilteredChores
} else if (!selectedProject || selectedProject.id === 'default') {
// No project selected or default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
} else {
// Specific project: use the existing filter function
choresToGroup = filterByProject(chores, selectedProject.id)
}
const sections = ChoresGrouper(
selectedChoreSection,
choresToGroup,
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
)
return sections
}, [
chores,
quickFilteredChores,
customFilteredChores,
tempFilter,
activeFilterId,
hasQuickFilters,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
impersonatedUser?.userId,
userProfile?.id,
])
useEffect(() => {
if (
!choresLoading &&
!membersLoading &&
userProfile?.id &&
membersData?.res &&
choresData?.res
) {
const processEffectAsync = async () => {
// Sync local state with query data to ensure updates are reflected
setChores(processedChores)
setFilteredChores(processedChores)
// Don't set choreSections here - let the dedicated effect handle it
// This prevents caching issues when switching between projects
if (await canScheduleNotification()) {
console.log('Scheduling chore notifications...')
scheduleChoreNotification(
choresData.res,
userProfile,
membersData.res,
)
}
}
processEffectAsync()
}
}, [
membersLoading,
choresLoading,
isUserProfileLoading,
choresData?.res,
membersData?.res,
processedChores, // Added to ensure local state syncs when query data updates
userProfile,
impersonatedUser?.userId,
])
// Auto-update sections when processedSections changes
useEffect(() => {
// Always update choreSections to match processedSections, even if empty
setChoreSections(processedSections)
// Auto-open sections if needed - only check localStorage once
if (processedSections.length > 0) {
const storedSections = localStorage.getItem('openChoreSections')
if (storedSections === null) {
const openSections = processedSections.reduce(
(acc, _section, index) => {
acc[index] = true
return acc
},
{},
)
setOpenChoreSections(openSections)
}
}
}, [processedSections])
useEffect(() => {
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [anchorEl])
useEffect(() => {
if (searchInputFocus > 0 && searchInputRef.current) {
searchInputRef.current.focus()
searchInputRef.current.selectionStart =
searchInputRef.current.value?.length
searchInputRef.current.selectionEnd = searchInputRef.current.value?.length
}
}, [searchInputFocus])
// Read and apply project from URL parameters
useEffect(() => {
if (!projects.length) return
const projectIdFromUrl = searchParams.get('project')
if (projectIdFromUrl && projectIdFromUrl !== selectedProject?.id) {
const project = projectsWithDefault.find(p => p.id === projectIdFromUrl)
if (project) {
setSelectedProjectWithCache(project)
}
}
}, [
searchParams,
projects,
projectsWithDefault,
selectedProject,
setSelectedProjectWithCache,
searchParams,
projects,
projectsWithDefault,
selectedProject,
setSelectedProjectWithCache,
])
// Widget deep links (donetick://chores/add[?mode=scan|voice] →
// /chores?add_task=1[&mode=…]): open the quick-add modal once, in the
// requested capture mode, and strip the params so back/refresh doesn't
// re-trigger it.
useEffect(() => {
if (searchParams.get('add_task') === '1') {
const mode = searchParams.get('mode')
setAddTaskInitialMode(mode === 'voice' || mode === 'scan' ? mode : null)
setAddTaskModalOpen(true)
const params = new URLSearchParams(searchParams)
params.delete('add_task')
params.delete('mode')
setSearchParams(params, { replace: true })
}
}, [searchParams, setSearchParams])
// Read and apply filters from URL parameters
useEffect(() => {
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
const rawFilterId =
searchParams.get('filterId') || searchParams.get('filter_id')
// Parse as number since filter IDs from the API are numeric
const filterId = rawFilterId ? Number(rawFilterId) || rawFilterId : null
const oldFilter = searchParams.get('filter')
// Restore smart insight temp filter from URL (e.g. on page reload)
// Insight IDs are strings (e.g. 'overdue'), saved filter IDs are numeric
if (
filterId &&
INSIGHT_FILTER_DEFS[filterId] &&
tempFilterMeta?.id !== filterId
) {
const def = INSIGHT_FILTER_DEFS[filterId]
applyTempFilter(def.filter, { id: filterId, name: def.name })
return
}
// If filterId is no longer in URL but filter is still active in state, clear it
if (!filterId && !oldFilter && activeFilterId) {
clearActiveFilter()
return
}
if (!chores.length || !savedFilters.length) return
// Handle advanced filter parameter
if (filterId && !activeFilterId) {
const filter = savedFilters.find(f => f.id === filterId)
if (filter) {
applyCustomFilter(filterId)
return
}
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && !hasQuickFilters && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
today: 'Due today',
week: 'Due in week',
later: 'Due Later',
pending: 'Pending Approval',
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName) {
setQuickFilter('status', filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
}
}, [
searchParams,
chores,
hasQuickFilters,
activeFilterId,
savedFilters,
applyCustomFilter,
applyTempFilter,
clearActiveFilter,
selectedProject,
projectFilteredChores,
setQuickFilter,
setViewMode,
setSelectedCalendarDate,
])
// Sync tempFilterMeta (smart insight) → URL using filterId param
// Insight IDs are strings (e.g. 'overdue') so they don't conflict with numeric saved filter IDs
useEffect(() => {
const insightId = tempFilterMeta?.id
const params = new URLSearchParams(searchParams)
const currentFilterId = params.get('filterId')
if (insightId && currentFilterId !== insightId) {
params.delete('filter_id')
params.delete('filter')
params.set('filterId', insightId)
Navigate(
{ pathname: '/chores', search: params.toString() },
{ replace: true },
)
} else if (
!insightId &&
currentFilterId &&
INSIGHT_FILTER_DEFS[currentFilterId]
) {
// Only clear filterId if it was set by an insight (not a numeric saved filter)
params.delete('filterId')
Navigate(
{ pathname: '/chores', search: params.toString() },
{ replace: true },
)
}
}, [tempFilterMeta?.id, searchParams])
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 getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: 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).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchTerm,
])
const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
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(null, choreSections, openChoreSections)
}, 0)
},
onSelectAll: () =>
selectAllVisibleChores(null, choreSections, openChoreSections),
onClearSelection: clearSelection,
onBulkComplete: handleBulkComplete,
onBulkSkip: handleBulkSkip,
onBulkArchive: handleBulkArchive,
onBulkDelete: handleBulkDelete,
onNavigateToArchived: () => Navigate('/archived'),
onShowMessage: showSuccess,
},
})
const handleMenuOutsideClick = event => {
if (
anchorEl &&
!anchorEl.contains(event.target) &&
!menuRef.current.contains(event.target)
) {
handleFilterMenuClose()
}
}
const handleFilterMenuOpen = event => {
event.preventDefault()
setAnchorEl(event.currentTarget)
}
const handleFilterMenuClose = () => {
setAnchorEl(null)
}
const handleLabelFiltering = chipClicked => {
clearActiveFilter()
if (chipClicked.label) {
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
setQuickFilter('priority', [chipClicked.priority])
}
setSelectedCalendarDate(null)
}
// Helper to update URL with filter parameters
const updateFilterUrl = (filterType, filterValue) => {
const params = new URLSearchParams(searchParams)
// Clear existing filter params
params.delete('filter')
params.delete('filterId')
params.delete('filter_id')
// Set new filter param (use filterId for advanced filters)
if (filterType && filterValue) {
params.set(filterType, filterValue)
}
// Always navigate with params (preserves project param)
Navigate(
{ pathname: '/chores', search: params.toString() },
{ replace: true },
)
}
const searchOptions = useMemo(
() => ({
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
}),
[],
)
const processedChoresForSearch = useMemo(
() =>
chores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
})),
[chores],
)
const fuse = useMemo(
() => new Fuse(processedChoresForSearch, searchOptions),
[processedChoresForSearch, searchOptions],
)
const handleSearchChange = e => {
clearActiveFilter()
if (hasQuickFilters) {
clearQuickFilters()
}
const search = e.target.value
if (search === '') {
setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchTerm('')
setSelectedCalendarDate(null)
return
}
const term = search.toLowerCase()
setSearchTerm(term)
// Use project-filtered chores as base for search
const baseChores = selectedProject ? projectFilteredChores : chores
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,
})
setFilteredChores(fuse.search(term).map(result => result.item))
// Clear selected calendar date when search changes
setSelectedCalendarDate(null)
}
const handleSearchClose = () => {
setSearchTerm('')
setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchInputFocus(0)
setSelectedCalendarDate(null)
}
const setSelectedChoreSectionWithCache = value => {
setSelectedChoreSection(value)
localStorage.setItem('selectedChoreSection', value)
}
const setOpenChoreSectionsWithCache = value => {
setOpenChoreSections(value)
localStorage.setItem('openChoreSections', JSON.stringify(value))
}
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
}
// const renderChoreCard = (chore, key) => {
// const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
// return (
// <CardComponent
// key={key || chore.id}
// chore={chore}
// performers={membersData?.res}
// userLabels={userLabels}
// onChipClick={handleLabelFiltering}
// onAction={handleChoreAction}
// isMultiSelectMode={isMultiSelectMode}
// isSelected={selectedChores.has(chore.id)}
// onSelectionToggle={() => toggleChoreSelection(chore.id)}
// />
// )
// }
// const renderChores = chores => {
// return (
// <SwipeableList>
// {chores.map(chore => (
// <SwipeableListItem
// key={chore.id}
// trailingActions={
// <TrailingActions>
// <SwipeAction>
// <Button
// variant='solid'
// color='primary'
// size='sm'
// startIcon={<Add />}
// onClick={() => setAddTaskModalOpen(true)}
// >
// Add Task
// </Button>
// </SwipeAction>
// </TrailingActions>
// }
// >
// {/* <Button
// variant='outlined'
// color='neutral'
// size='sm'
// startIcon={<EditCalendar />}
// onClick={() => setViewMode('calendar')}
// >
// View Calendar
// </Button> */}
// {renderChoreCard(chore)}
// {/* {chores.map(chore => renderChoreCard(chore))} */}
// </SwipeableListItem>
// ))}
// </SwipeableList>
// )
// }
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,
)
}
setChores(newChores)
setFilteredChores(newChores)
clearQuickFilters()
}
// Show error state when API is unreachable
if (choresError || membersError) {
return (
<Container maxWidth='md'>
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '70vh',
gap: 2,
}}
>
<Box sx={{ mb: 2, opacity: 0.7 }}>
<Logo />
</Box>
<Typography level='h4' color='danger'>
Unable to communicate with server
</Typography>
<Typography
level='body-md'
sx={{ textAlign: 'center', maxWidth: 400 }}
>
{choresErrorDetails?.message ||
'The server is currently unavailable. Please check your connection and try again.'}
</Typography>
<Button
variant='solid'
color='primary'
onClick={() => {
refetchChores()
queryClient.invalidateQueries(['circleMembers'])
}}
>
Retry Connection
</Button>
</Box>
</Container>
)
}
if (
isUserProfileLoading ||
userProfile === null ||
userLabelsLoading ||
membersLoading ||
choresLoading ||
projectsLoading
) {
return (
<>
<LoadingComponent />
</>
)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'row',
}}
>
<Container maxWidth='md'>
<MyChoreHeader
activeFilterId={activeFilterId}
activeFilter={activeFilter}
selectedProject={selectedProject}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<ChoreToolbar
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',
message: `"${name}" has been saved`,
})
}
onClearAllFilters={() => {
clearQuickFilters()
clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(
projectsWithDefault.find(p => p.id === 'default') || null,
)
updateFilterUrl(null, null)
}}
resultCount={
hasQuickFilters || hasFilterApplied
? getFilteredChores.length
: undefined
}
totalCount={
hasQuickFilters || hasFilterApplied
? projectFilteredChores.length
: undefined
}
selectedProject={selectedProject}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
selectedAssigneeFilter={selectedChoreFilter}
onAssigneeFilterChange={filter => {
setSelectedChoreFilterWithCache(filter)
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
savedFilters={savedFilters}
activeFilterId={activeFilterId}
onSavedFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
if (selectedChoreFilter !== 'anyone') {
setSelectedChoreFilterWithCache('anyone')
}
const filter = savedFilters.find(f => f.id === filterId)
if (filter?.conditions?.some(c => c.type === 'project')) {
setSelectedProjectWithCache(null)
}
applyCustomFilter(filterId)
updateFilterUrl('filterId', filterId)
}
}}
onSavedFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter}
selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value)
setFilteredChores(chores)
clearQuickFilters()
}}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={toggleMultiSelectMode}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
onSearchClose={handleSearchClose}
searchInputRef={searchInputRef}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
<MultiSelectToolbar
isVisible={isMultiSelectMode}
selectedCount={selectedChores.size}
onSelectAll={selectAllVisibleChores}
onClear={clearSelection}
onComplete={handleBulkComplete}
onSkip={handleBulkSkip}
onArchive={handleBulkArchive}
onDelete={handleBulkDelete}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || hasQuickFilters
? selectedChores.size === getFilteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
/>
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
viewMode !== 'calendar' && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<EditCalendar
sx={{
fontSize: '4rem',
// color: 'text.disabled',
mb: 1,
}}
/>
<Typography level='title-md' gutterBottom>
Nothing scheduled
</Typography>
{chores.length > 0 && (
<>
<Button
onClick={() => {
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
variant='outlined'
color='neutral'
>
Reset filters
</Button>
</>
)}
</Box>
)}
{searchTerm?.length > 0 && viewMode !== 'calendar' && (
<ChoreListView
chores={getFilteredChores}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
)}
{viewMode === 'calendar' && (
<>
{/* Summary Chips when no date selected */}
{/* <Box
sx={{
mt: 1,
mb: 1,
display: 'flex',
gap: 1.5,
justifyContent: 'start',
flexWrap: 'wrap',
}}
>
{FILTERS['Overdue'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
color='danger'
size='lg'
onClick={() => {
// Update state directly for immediate smooth transition
const overdueChores = FILTERS['Overdue'](getFilteredChores)
setFilteredChores(overdueChores)
setSearchFilter('Overdue')
setViewMode('default')
setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'overdue')
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip size='md' variant='solid' color='danger'>
{FILTERS['Overdue'](getFilteredChores).length}
</Chip>
}
>
Overdue
</Chip>
)}
{FILTERS['No Due Date'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
color='neutral'
size='lg'
onClick={() => {
// Update state directly for immediate smooth transition
const unplannedChores =
FILTERS['No Due Date'](getFilteredChores)
setFilteredChores(unplannedChores)
setSearchFilter('No Due Date')
setViewMode('default')
setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'unplanned')
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip size='md' variant='solid' color='neutral'>
{FILTERS['No Due Date'](getFilteredChores).length}
</Chip>
}
>
Unplanned
</Chip>
)}
{FILTERS['Pending Approval'](getFilteredChores).length > 0 && (
<Chip
variant='soft'
size='lg'
onClick={() => {
// Update state directly for immediate smooth transition
const pendingApprovalChores =
FILTERS['Pending Approval'](getFilteredChores)
setFilteredChores(pendingApprovalChores)
setSearchFilter('Pending Approval')
setViewMode('default')
setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'pending')
}}
sx={{
cursor: 'pointer',
transition: 'all 0.2s ease',
px: 1,
py: 0.5,
}}
startDecorator={
<Chip
size='md'
variant='solid'
sx={{
bgcolor: TASK_COLOR.PENDING_REVIEW,
color: 'white',
}}
>
{FILTERS['Pending Approval'](getFilteredChores).length}
</Chip>
}
>
Pending Approval
</Chip>
)}
</Box> */}
{/* Calendar Monthly View */}
<Box sx={{ mb: 2 }}>
{isLargeScreen ? (
<CalendarDual
chores={getFilteredChores}
onDateChange={date => {
setSelectedCalendarDate(date)
}}
/>
) : (
<div className='calendar-dual'>
<CalendarMonthly
chores={getFilteredChores}
onDateChange={date => {
setSelectedCalendarDate(date)
}}
/>
</div>
)}
</Box>
{/* Selected Date Tasks */}
{selectedCalendarDate && (
<Box sx={{ mt: 2 }}>
<Typography level='title-md' gutterBottom>
Tasks for {selectedCalendarDate.toLocaleDateString()}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
maxHeight: '400px',
overflowY: 'auto',
}}
>
{getChoresForDate(selectedCalendarDate).length === 0 ? (
<Typography
level='body-sm'
sx={{
textAlign: 'center',
py: 2,
color: 'text.tertiary',
}}
>
No tasks scheduled for this date
</Typography>
) : (
<ChoreListView
chores={getChoresForDate(selectedCalendarDate)}
viewMode={'compact'}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
)}
</Box>
</Box>
)}
</>
)}
{searchTerm.length === 0 && viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
key={section.name + index}
sx={{
my: 0,
px: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
delete newOpenChoreSections[index]
setOpenChoreSectionsWithCache(newOpenChoreSections)
} else {
setOpenChoreSectionsWithCache({
...openChoreSections,
[index]: true,
})
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
['& > *']: {
// px: 0.5,
px: 0.5,
// pr: 0,
},
}}
>
<ChoreListView
chores={section.content}
viewMode={viewMode}
membersData={membersData}
userLabels={userLabels}
handleLabelFiltering={handleLabelFiltering}
handleChoreAction={handleChoreAction}
isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection}
/>
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
<Box
sx={{
// center the button
justifyContent: 'center',
mt: 2,
}}
></Box>
<Box
// variant='outlined'
sx={{
position: 'fixed',
bottom: getSafeBottom(10, 10),
left: 10,
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 100,
}}
>
<IconButton
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
zIndex: 101,
position: 'relative',
}}
onClick={() => {
Navigate(`/chores/create`)
}}
title='Create new chore (Cmd+C)'
>
<Add />
<KeyboardShortcutHint
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
show={showKeyboardShortcuts}
shortcut='J'
/>
</IconButton>
<IconButton
data-testid='open-add-task-modal'
color='primary'
variant='soft'
sx={{
borderRadius: '50%',
width: 25,
height: 25,
position: 'relative',
left: -25,
top: 22,
}}
onClick={() => {
setAddTaskModalOpen(true)
}}
>
<Bolt
style={{
rotate: '20deg',
}}
/>
</IconButton>
<KeyboardShortcutHint
sx={{ position: 'relative', left: -40, top: 30 }}
show={showKeyboardShortcuts}
shortcut='K'
/>
</Box>
<NotificationAccessSnackbar />
{addTaskModalOpen && (
<TaskInput
autoFocus={taskInputFocus}
onChoreUpdate={updateChores}
isModalOpen={addTaskModalOpen}
initialMode={addTaskInitialMode}
onClose={forceRefresh => {
setAddTaskModalOpen(false)
setAddTaskInitialMode(null)
if (forceRefresh) {
refetchChores()
}
}}
/>
)}
</Container>
<Sidepanel
chores={customFilteredChores}
allChores={chores}
performers={membersData?.res || []}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
tempFilter={tempFilter}
/>
{/* Multi-select Help - only show when in multi-select mode */}
{/* <MultiSelectHelp isVisible={isMultiSelectMode} /> */}
{/* Confirmation Modal for bulk operations */}
{confirmModelConfig?.isOpen && (
<ConfirmationModal config={confirmModelConfig} />
)}
<ChoreModals
activeModal={activeModal}
modalChore={modalChore}
membersData={membersData}
onChangeDueDate={handleChangeDueDate}
onCompleteWithPastDate={handleCompleteWithPastDate}
onAssigneeChange={handleAssigneeChange}
onCompleteWithNote={handleCompleteWithNote}
onNudge={handleNudge}
onClose={closeModal}
/>
{/* Advanced Filter Builder */}
<AdvancedFilterBuilder
isOpen={showAdvancedFilterBuilder}
onClose={() => {
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
}}
onSave={filter => {
if (filter.id) {
// Update existing filter
updateFilter(filter.id, {
name: filter.name,
description: filter.description,
color: filter.color,
conditions: filter.conditions,
operator: filter.operator,
})
showSuccess({
title: 'Filter Updated',
message: `"${filter.name}" has been updated successfully`,
})
} else {
// Create new filter
saveFilter(filter)
showSuccess({
title: 'Advanced Filter Created',
message: `"${filter.name}" has been created successfully`,
})
}
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
}}
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
allChores={searchFilteredChores}
userProfile={userProfile}
editingFilter={editingFilter}
/>
</div>
)
}
export default MyChores