- Support nest sub tasks

- Support filters in ChoreGrouper
- completion window only available if due date selected
- Add SortAndGrouping Component : support Filter by Assignee
- update Notification Switch to align left of the text
- Support caching filters
This commit is contained in:
Mo Tarbin 2025-03-05 19:43:32 -05:00
parent bbea27d380
commit a16309c69a
9 changed files with 679 additions and 224 deletions

View file

@ -3,7 +3,11 @@ import { TASK_COLOR } from './Colors.jsx'
const priorityOrder = [1, 2, 3, 4, 0] const priorityOrder = [1, 2, 3, 4, 0]
export const ChoresGrouper = (groupBy, chores) => { export const ChoresGrouper = (groupBy, chores, filter) => {
if (filter) {
chores = chores.filter(chore => filter(chore))
}
// sort by priority then due date: // sort by priority then due date:
chores.sort(ChoreSorter) chores.sort(ChoreSorter)
var groups = [] var groups = []
@ -172,3 +176,12 @@ export const notInCompletionWindow = chore => {
moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate) moment().add(chore.completionWindow, 'hours') < moment(chore.nextDueDate)
) )
} }
export const ChoreFilters = userProfile => ({
anyone: () => true,
assigned_to_me: chore => {
return chore.assignedTo && chore.assignedTo === userProfile.id
},
assigned_to_others: chore => {
return chore.assignedTo && chore.assignedTo !== userProfile.id
},
})

View file

@ -208,7 +208,9 @@ const ChoreEdit = () => {
notificationMetadata: notificationMetadata, notificationMetadata: notificationMetadata,
thingTrigger: thingTrigger, thingTrigger: thingTrigger,
points: points < 0 ? null : points, points: points < 0 ? null : points,
completionWindow: completionWindow < 0 ? null : completionWindow, completionWindow:
// if completionWindow is -1 then set it to null or dueDate is null
completionWindow < 0 || dueDate === null ? null : completionWindow,
priority: priority, priority: priority,
} }
let SaveFunction = CreateChore let SaveFunction = CreateChore
@ -596,7 +598,8 @@ const ChoreEdit = () => {
<FormHelperText>{errors.dueDate}</FormHelperText> <FormHelperText>{errors.dueDate}</FormHelperText>
</FormControl> </FormControl>
)} )}
{dueDate && (
<>
<FormControl orientation='horizontal'> <FormControl orientation='horizontal'>
<Switch <Switch
checked={completionWindow != -1} checked={completionWindow != -1}
@ -653,6 +656,8 @@ const ChoreEdit = () => {
</Box> </Box>
</Card> </Card>
)} )}
</>
)}
</Box> </Box>
{!['once', 'no_repeat'].includes(frequencyType) && ( {!['once', 'no_repeat'].includes(frequencyType) && (
<Box mt={2}> <Box mt={2}>
@ -975,7 +980,12 @@ const ChoreEdit = () => {
p: 1, p: 1,
}} }}
> >
<SubTasks editMode={true} tasks={subTasks} setTasks={setSubTasks} /> <SubTasks
editMode={true}
tasks={subTasks}
setTasks={setSubTasks}
choreId={choreId}
/>
</Card> </Card>
)} )}
<FormControl sx={{ mt: 1 }}> <FormControl sx={{ mt: 1 }}>

View file

@ -44,7 +44,7 @@ import { useLabels } from '../Labels/LabelQueries'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu' import IconButtonWithMenu from './IconButtonWithMenu'
import { ChoresGrouper, ChoreSorter } from '../../utils/Chores' import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
import TaskInput from '../components/AddTaskModal' import TaskInput from '../components/AddTaskModal'
import { import {
canScheduleNotification, canScheduleNotification,
@ -52,6 +52,7 @@ import {
} from './LocalNotificationScheduler' } from './LocalNotificationScheduler'
import NotificationAccessSnackbar from './NotificationAccessSnackbar' import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel' import Sidepanel from './Sidepanel'
import SortAndGrouping from './SortAndGrouping'
const MyChores = () => { const MyChores = () => {
const { userProfile, setUserProfile } = useContext(UserContext) const { userProfile, setUserProfile } = useContext(UserContext)
@ -60,7 +61,7 @@ const MyChores = () => {
const [chores, setChores] = useState([]) const [chores, setChores] = useState([])
const [archivedChores, setArchivedChores] = useState(null) const [archivedChores, setArchivedChores] = useState(null)
const [filteredChores, setFilteredChores] = useState([]) const [filteredChores, setFilteredChores] = useState([])
const [selectedFilter, setSelectedFilter] = useState('All') const [searchFilter, setSearchFilter] = useState('All')
const [choreSections, setChoreSections] = useState([]) const [choreSections, setChoreSections] = useState([])
const [activeTextField, setActiveTextField] = useState('task') const [activeTextField, setActiveTextField] = useState('task')
const [taskInputFocus, setTaskInputFocus] = useState(0) const [taskInputFocus, setTaskInputFocus] = useState(0)
@ -72,6 +73,9 @@ const MyChores = () => {
const [openChoreSections, setOpenChoreSections] = useState( const [openChoreSections, setOpenChoreSections] = useState(
JSON.parse(localStorage.getItem('openChoreSections')) || {}, JSON.parse(localStorage.getItem('openChoreSections')) || {},
) )
const [selectedChoreFilter, setSelectedChoreFilter] = useState(
JSON.parse(localStorage.getItem('selectedChoreFilter')) || 'anyone',
)
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([]) const [performers, setPerformers] = useState([])
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
@ -172,6 +176,10 @@ const MyChores = () => {
setOpenChoreSections(value) setOpenChoreSections(value)
localStorage.setItem('openChoreSections', JSON.stringify(value)) localStorage.setItem('openChoreSections', JSON.stringify(value))
} }
const setSelectedChoreFilterWithCache = value => {
setSelectedChoreFilter(value)
localStorage.setItem('selectedChoreFilter', JSON.stringify(value))
}
const updateChores = newChore => { const updateChores = newChore => {
const newChores = chores const newChores = chores
@ -179,7 +187,7 @@ const MyChores = () => {
setChores(newChores) setChores(newChores)
setFilteredChores(newChores) setFilteredChores(newChores)
setChoreSections(ChoresGrouper(selectedChoreSection, newChores)) setChoreSections(ChoresGrouper(selectedChoreSection, newChores))
setSelectedFilter('All') setSearchFilter('All')
} }
const handleMenuOutsideClick = event => { const handleMenuOutsideClick = event => {
if ( if (
@ -208,14 +216,14 @@ const MyChores = () => {
), ),
) )
setFilteredChores(labelFiltered) setFilteredChores(labelFiltered)
setSelectedFilter('Label: ' + label.name) setSearchFilter('Label: ' + label.name)
} else if (chipClicked.priority) { } else if (chipClicked.priority) {
const priority = chipClicked.priority const priority = chipClicked.priority
const priorityFiltered = chores.filter( const priorityFiltered = chores.filter(
chore => chore.priority === priority, chore => chore.priority === priority,
) )
setFilteredChores(priorityFiltered) setFilteredChores(priorityFiltered)
setSelectedFilter('Priority: ' + priority) setSearchFilter('Priority: ' + priority)
} }
} }
@ -305,8 +313,8 @@ const MyChores = () => {
) )
const handleSearchChange = e => { const handleSearchChange = e => {
if (selectedFilter !== 'All') { if (searchFilter !== 'All') {
setSelectedFilter('All') setSearchFilter('All')
} }
const search = e.target.value const search = e.target.value
if (search === '') { if (search === '') {
@ -418,17 +426,28 @@ const MyChores = () => {
<Search /> <Search />
</IconButton> </IconButton>
)} )}
<SortAndGrouping
<IconButtonWithMenu
title='Group by' title='Group by'
k={'icon-menu-group-by'} k={'icon-menu-group-by'}
icon={<Sort />} icon={<Sort />}
options={[
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
]}
selectedItem={selectedChoreSection} selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
setSelectedChoreFilterWithCache(filter)
const section = ChoresGrouper(
selectedChoreSection,
chores,
ChoreFilters(userProfile)[filter],
)
setChoreSections(section)
setOpenChoreSectionsWithCache(
// open all sections by default
Object.keys(section).reduce((acc, key) => {
acc[key] = true
return acc
}, {}),
)
}}
onItemSelect={selected => { onItemSelect={selected => {
const section = ChoresGrouper(selected.value, chores) const section = ChoresGrouper(selected.value, chores)
setChoreSections(section) setChoreSections(section)
@ -441,7 +460,7 @@ const MyChores = () => {
}, {}), }, {}),
) )
setFilteredChores(chores) setFilteredChores(chores)
setSelectedFilter('All') setSearchFilter('All')
}} }}
mouseClickHandler={handleMenuOutsideClick} mouseClickHandler={handleMenuOutsideClick}
/> />
@ -454,12 +473,12 @@ const MyChores = () => {
k={'icon-menu-priority-filter'} k={'icon-menu-priority-filter'}
icon={<PriorityHigh />} icon={<PriorityHigh />}
options={Priorities} options={Priorities}
selectedItem={selectedFilter} selectedItem={searchFilter}
onItemSelect={selected => { onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value }) handleLabelFiltering({ priority: selected.value })
}} }}
mouseClickHandler={handleMenuOutsideClick} mouseClickHandler={handleMenuOutsideClick}
isActive={selectedFilter.startsWith('Priority: ')} isActive={searchFilter.startsWith('Priority: ')}
/> />
<IconButtonWithMenu <IconButtonWithMenu
@ -467,11 +486,11 @@ const MyChores = () => {
label={' Labels'} label={' Labels'}
icon={<Style />} icon={<Style />}
options={userLabels} options={userLabels}
selectedItem={selectedFilter} selectedItem={searchFilter}
onItemSelect={selected => { onItemSelect={selected => {
handleLabelFiltering({ label: selected }) handleLabelFiltering({ label: selected })
}} }}
isActive={selectedFilter.startsWith('Label: ')} isActive={searchFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick} mouseClickHandler={handleMenuOutsideClick}
useChips useChips
/> />
@ -481,9 +500,7 @@ const MyChores = () => {
variant='outlined' variant='outlined'
startDecorator={<Grain />} startDecorator={<Grain />}
color={ color={
selectedFilter && searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
FILTERS[selectedFilter] &&
selectedFilter != 'All'
? 'primary' ? 'primary'
: 'neutral' : 'neutral'
} }
@ -519,15 +536,13 @@ const MyChores = () => {
? filterFunction(chores, userProfile.id) ? filterFunction(chores, userProfile.id)
: filterFunction(chores) : filterFunction(chores)
setFilteredChores(filteredChores) setFilteredChores(filteredChores)
setSelectedFilter(filter) setSearchFilter(filter)
handleFilterMenuClose() handleFilterMenuClose()
}} }}
> >
{filter} {filter}
<Chip <Chip
color={ color={searchFilter === filter ? 'primary' : 'neutral'}
selectedFilter === filter ? 'primary' : 'neutral'
}
> >
{FILTERS[filter].length === 2 {FILTERS[filter].length === 2
? FILTERS[filter](chores, userProfile.id).length ? FILTERS[filter](chores, userProfile.id).length
@ -536,13 +551,13 @@ const MyChores = () => {
</MenuItem> </MenuItem>
))} ))}
{selectedFilter.startsWith('Label: ') || {searchFilter.startsWith('Label: ') ||
(selectedFilter.startsWith('Priority: ') && ( (searchFilter.startsWith('Priority: ') && (
<MenuItem <MenuItem
key={`filter-list-cancel-all-filters`} key={`filter-list-cancel-all-filters`}
onClick={() => { onClick={() => {
setFilteredChores(chores) setFilteredChores(chores)
setSelectedFilter('All') setSearchFilter('All')
}} }}
> >
Cancel All Filters Cancel All Filters
@ -553,23 +568,23 @@ const MyChores = () => {
</div> </div>
</div> </div>
)} )}
{selectedFilter !== 'All' && ( {searchFilter !== 'All' && (
<Chip <Chip
level='title-md' level='title-md'
gutterBottom gutterBottom
color='warning' color='warning'
label={selectedFilter} label={searchFilter}
onDelete={() => { onDelete={() => {
setFilteredChores(chores) setFilteredChores(chores)
setSelectedFilter('All') setSearchFilter('All')
}} }}
endDecorator={<CancelRounded />} endDecorator={<CancelRounded />}
onClick={() => { onClick={() => {
setFilteredChores(chores) setFilteredChores(chores)
setSelectedFilter('All') setSearchFilter('All')
}} }}
> >
Current Filter: {selectedFilter} Current Filter: {searchFilter}
</Chip> </Chip>
)} )}
{filteredChores.length === 0 && ( {filteredChores.length === 0 && (
@ -608,7 +623,7 @@ const MyChores = () => {
)} )}
</Box> </Box>
)} )}
{(searchTerm?.length > 0 || selectedFilter !== 'All') && {(searchTerm?.length > 0 || searchFilter !== 'All') &&
filteredChores.map(chore => ( filteredChores.map(chore => (
<ChoreCard <ChoreCard
key={`filtered-${chore.id} `} key={`filtered-${chore.id} `}
@ -620,7 +635,7 @@ const MyChores = () => {
onChipClick={handleLabelFiltering} onChipClick={handleLabelFiltering}
/> />
))} ))}
{searchTerm.length === 0 && selectedFilter === 'All' && ( {searchTerm.length === 0 && searchFilter === 'All' && (
<AccordionGroup transition='0.2s ease' disableDivider> <AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => { {choreSections.map((section, index) => {
if (section.content.length === 0) return null if (section.content.length === 0) return null

View file

@ -13,7 +13,7 @@ const Sidepanel = ({ chores }) => {
}, []) }, [])
const generateChoreDuePieChartData = chores => { const generateChoreDuePieChartData = chores => {
const groups = ChoresGrouper('due_date', chores) const groups = ChoresGrouper('due_date', chores, null)
return groups return groups
.map(group => { .map(group => {
return { return {

View file

@ -0,0 +1,194 @@
import {
Button,
Chip,
Divider,
Menu,
MenuItem,
Radio,
Typography,
} from '@mui/joy'
import IconButton from '@mui/joy/IconButton'
import React, { useEffect, useRef, useState } from 'react'
import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx'
const SortAndGrouping = ({
label,
k,
icon,
onItemSelect,
selectedItem,
setSelectedItem,
selectedFilter,
setFilter,
isActive,
useChips,
title,
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
const handleMenuOpen = event => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
useEffect(() => {
const handleMenuOutsideClick = event => {
if (menuRef.current && !menuRef.current.contains(event.target)) {
handleMenuClose()
}
}
document.addEventListener('mousedown', handleMenuOutsideClick)
return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick)
}
}, [])
return (
<>
{!label && (
<IconButton
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{icon}
{label ? label : null}
</IconButton>
)}
{label && (
<Button
onClick={handleMenuOpen}
variant='outlined'
color={isActive ? 'primary' : 'neutral'}
size='sm'
startDecorator={icon}
sx={{
height: 24,
borderRadius: 24,
}}
>
{label}
</Button>
)}
<Menu
key={k}
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
sx={{
'& .MuiMenuItem-root': {
padding: '8px 16px', // Consistent padding for menu items
},
}}
>
<MenuItem key={`${k}-title`} disabled>
<Typography level='body-sm' fontWeight='lg'>
Group By
</Typography>
</MenuItem>
{[
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
].map(item => (
<MenuItem
key={`${k}-${item?.value}`}
onClick={() => {
onItemSelect(item)
setSelectedItem?.(item.name)
handleMenuClose()
}}
>
{useChips ? (
<Chip
size='sm'
sx={{
backgroundColor: item.color ? item.color : null,
color: getTextColorFromBackgroundColor(item.color),
fontWeight: 'md',
}}
>
{item.name}
</Chip>
) : (
<>
{item?.icon}
<Typography level='body-sm' sx={{ ml: 1 }}>
{item.name}
</Typography>
</>
)}
</MenuItem>
))}
<Divider />
<MenuItem key={`${k}-quick-filter`} disabled>
<Typography level='body-sm' fontWeight='lg'>
Quick Filters
</Typography>
</MenuItem>
<MenuItem key={`${k}-assignee-title`} disabled>
<Typography level='body-xs' fontWeight='md'>
Assigned to:
</Typography>
</MenuItem>
<MenuItem
key={`${k}-assignee-anyone`}
onClick={() => {
setFilter('anyone')
handleMenuClose()
}}
>
<Radio checked={selectedFilter === 'anyone'} variant='outlined' />
<Typography level='body-sm'>Anyone</Typography>
</MenuItem>
<MenuItem
key={`${k}-assignee-assigned-to-me`}
onClick={() => {
setFilter('assigned_to_me')
handleMenuClose()
}}
>
<Radio
checked={selectedFilter === 'assigned_to_me'}
variant='outlined'
/>
<Typography level='body-sm'>Assigned to me</Typography>
</MenuItem>
<MenuItem
key={`${k}-assignee-assigned-to-others`}
onClick={() => {
setFilter('assigned_to_others')
handleMenuClose()
}}
>
<Radio
checked={selectedFilter === 'assigned_to_others'}
variant='outlined'
/>
<Typography level='body-sm'>Assigned to others</Typography>
</MenuItem>
</Menu>
</>
)
}
export default SortAndGrouping

View file

@ -173,7 +173,14 @@ const NotificationSetting = () => {
}} }}
color={deviceNotification ? 'success' : 'neutral'} color={deviceNotification ? 'success' : 'neutral'}
variant={deviceNotification ? 'solid' : 'outlined'} variant={deviceNotification ? 'solid' : 'outlined'}
sx={{ mr: 1 }} slotProps={{
endDecorator: {
sx: {
minWidth: 24,
},
},
}}
sx={{ mr: 2 }}
/> />
<div> <div>
<FormLabel>Device Notification</FormLabel> <FormLabel>Device Notification</FormLabel>
@ -321,7 +328,6 @@ const NotificationSetting = () => {
<FormControl orientation='horizontal'> <FormControl orientation='horizontal'>
<Switch <Switch
sx={{ mr: 1 }}
checked={chatID !== 0} checked={chatID !== 0}
onClick={event => { onClick={event => {
event.preventDefault() event.preventDefault()
@ -342,12 +348,14 @@ const NotificationSetting = () => {
}} }}
color={chatID !== 0 ? 'success' : 'neutral'} color={chatID !== 0 ? 'success' : 'neutral'}
variant={chatID !== 0 ? 'solid' : 'outlined'} variant={chatID !== 0 ? 'solid' : 'outlined'}
// endDecorator={chatID !== 0 ? 'On' : 'Off'}
slotProps={{ slotProps={{
endDecorator: { endDecorator: {
sx: {}, sx: {
minWidth: 24,
},
}, },
}} }}
sx={{ mr: 2 }}
/> />
<div> <div>
<FormLabel>Custom Notification</FormLabel> <FormLabel>Custom Notification</FormLabel>
@ -372,7 +380,7 @@ const NotificationSetting = () => {
<Option value='0'>None</Option> <Option value='0'>None</Option>
<Option value='1'>Telegram</Option> <Option value='1'>Telegram</Option>
<Option value='2'>Pushover</Option> <Option value='2'>Pushover</Option>
<Option value='3'>Webhook</Option> <Option value='3'>Webhooks</Option>
</Select> </Select>
{notificationTarget === '1' && ( {notificationTarget === '1' && (
<> <>

View file

@ -232,7 +232,7 @@ const UserActivites = () => {
} }
const generateChoreDuePieChartData = chores => { const generateChoreDuePieChartData = chores => {
const groups = ChoresGrouper('due_date', chores) const groups = ChoresGrouper('due_date', chores, null)
return groups return groups
.map(group => { .map(group => {
return { return {
@ -245,7 +245,7 @@ const UserActivites = () => {
.filter(item => item.value > 0) .filter(item => item.value > 0)
} }
const generateChorePriorityPieChartData = chores => { const generateChorePriorityPieChartData = chores => {
const groups = ChoresGrouper('priority', chores) const groups = ChoresGrouper('priority', chores, null)
return groups return groups
.map(group => { .map(group => {
return { return {

View file

@ -76,12 +76,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
const { userProfile } = useContext(UserContext) const { userProfile } = useContext(UserContext)
const navigate = useNavigate() const navigate = useNavigate()
const [taskText, setTaskText] = useState('') const [taskText, setTaskText] = useState('')
const debounceParsing = useDebounce(taskText, 300) const debounceParsing = useDebounce(taskText, 30)
const [taskTitle, setTaskTitle] = useState('') const [taskTitle, setTaskTitle] = useState('')
const [openModal, setOpenModal] = useState(false) const [openModal, setOpenModal] = useState(false)
const textareaRef = useRef(null) const textareaRef = useRef(null)
const mainInputRef = useRef(null) const mainInputRef = useRef(null)
const [priority, setPriority] = useState('0') const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null) const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null) const [description, setDescription] = useState(null)
const [frequency, setFrequency] = useState(null) const [frequency, setFrequency] = useState(null)
@ -436,7 +436,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
priority: priority || 0, priority: priority || 0,
status: 0, status: 0,
frequencyType: 'once', frequencyType: 'once',
notificationMetadata: {},
} }
if (frequency) { if (frequency) {
@ -451,7 +450,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate }) => {
CreateChore(chore).then(resp => { CreateChore(chore).then(resp => {
resp.json().then(data => { resp.json().then(data => {
if (resp.status !== 200) {
console.error('Error creating chore:', data)
return
} else {
onChoreUpdate({ ...chore, id: data.res, nextDueDate: chore.dueDate }) onChoreUpdate({ ...chore, id: data.res, nextDueDate: chore.dueDate })
}
}) })
}) })
} }

View file

@ -6,7 +6,15 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities' import { CSS } from '@dnd-kit/utilities'
import { Add, Delete, DragIndicator, Edit } from '@mui/icons-material' import {
ChevronRight,
Delete,
DragIndicator,
Edit,
ExpandMore,
KeyboardReturn,
PlaylistAdd,
} from '@mui/icons-material'
import { import {
Box, Box,
Checkbox, Checkbox,
@ -19,23 +27,41 @@ import {
import React, { useState } from 'react' import React, { useState } from 'react'
import { CompleteSubTask } from '../../utils/Fetcher' import { CompleteSubTask } from '../../utils/Fetcher'
function SortableItem({ task, index, handleToggle, handleDelete, editMode }) { function SortableItem({
task,
index,
handleToggle,
handleDelete,
handleAddSubtask,
allTasks,
setTasks,
level = 0,
editMode,
}) {
const { attributes, listeners, setNodeRef, transform, transition } = const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: task.id }) useSortable({ id: task.id })
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
const [expanded, setExpanded] = useState(false)
const [showAddSubtask, setShowAddSubtask] = useState(false)
const [newSubtask, setNewSubtask] = useState('')
// Find child tasks
const childTasks = allTasks.filter(t => t.parentId === task.id)
const hasChildren = childTasks.length > 0
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: '0.5rem', gap: '0.5rem',
flexDirection: { xs: 'column', sm: 'row' }, // Responsive style flexDirection: { xs: 'column', sm: 'row' },
touchAction: 'none', touchAction: 'none',
paddingLeft: `${level * 24}px`,
} }
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
const handleEdit = () => { const handleEdit = () => {
setIsEditing(true) setIsEditing(true)
} }
@ -43,15 +69,59 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
const handleSave = () => { const handleSave = () => {
setIsEditing(false) setIsEditing(false)
task.name = editedText task.name = editedText
// Update the task in the parent component
setTasks(prevTasks =>
prevTasks.map(t => (t.id === task.id ? { ...t, name: editedText } : t)),
)
}
const handleExpandClick = () => {
setExpanded(!expanded)
}
const handleAddSubtaskClick = () => {
setShowAddSubtask(!showAddSubtask)
}
const submitNewSubtask = () => {
if (!newSubtask.trim()) return
handleAddSubtask(task.id, newSubtask)
setNewSubtask('')
setShowAddSubtask(false)
setExpanded(true) // Auto-expand to show the new subtask
}
const handleKeyPress = event => {
if (event.key === 'Enter') {
submitNewSubtask()
}
} }
return ( return (
<>
<ListItem ref={setNodeRef} style={style} {...attributes}> <ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && ( {editMode && (
<IconButton {...listeners} {...attributes}> <IconButton {...listeners} {...attributes} size='sm'>
<DragIndicator /> <DragIndicator />
</IconButton> </IconButton>
)} )}
{hasChildren && (
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleExpandClick}
>
{expanded ? <ExpandMore /> : <ChevronRight />}
</IconButton>
)}
{!hasChildren && level > 0 && (
<Box sx={{ width: 28 }} /> // Spacer for alignment not sure of better way for now it's good
)}
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
@ -62,9 +132,8 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
> >
{!editMode && ( {!editMode && (
<Checkbox <Checkbox
checked={task.completedAt} checked={!!task.completedAt}
onChange={() => handleToggle(task.id)} onChange={() => handleToggle(task.id)}
overlay={!editMode}
/> />
)} )}
<Box <Box
@ -75,6 +144,11 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
}} }}
onClick={() => {
if (!editMode) {
handleToggle(task.id)
}
}}
> >
{isEditing ? ( {isEditing ? (
<Input <Input
@ -101,8 +175,9 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
{task.completedAt && ( {task.completedAt && (
<Typography <Typography
sx={{ sx={{
display: { xs: 'block', sm: 'inline' }, // Responsive style display: { xs: 'block', sm: 'inline' },
color: 'text.secondary', color: 'text.secondary',
fontSize: 'sm',
}} }}
> >
{new Date(task.completedAt).toLocaleString()} {new Date(task.completedAt).toLocaleString()}
@ -110,79 +185,216 @@ function SortableItem({ task, index, handleToggle, handleDelete, editMode }) {
)} )}
</Box> </Box>
</Box> </Box>
{editMode && (
<Box sx={{ display: 'flex', gap: 1 }}> <Box sx={{ display: 'flex', gap: 1 }}>
<IconButton variant='soft' onClick={handleEdit}> {editMode && (
<>
<IconButton
variant='soft'
color='primary'
size='sm'
onClick={handleAddSubtaskClick}
title='Add subtask'
>
<PlaylistAdd />
</IconButton>
<IconButton variant='soft' size='sm' onClick={handleEdit}>
<Edit /> <Edit />
</IconButton> </IconButton>
<IconButton <IconButton
variant='soft' variant='soft'
color='danger' color='danger'
size='sm'
onClick={() => handleDelete(task.id)} onClick={() => handleDelete(task.id)}
> >
<Delete /> <Delete />
</IconButton> </IconButton>
</>
)}
</Box>
</ListItem>
{/* Add subtask input field */}
{showAddSubtask && (
<ListItem
sx={{
paddingLeft: `${(level + 1) * 24}px`,
paddingTop: 0,
paddingBottom: 1,
}}
>
<Box sx={{ display: 'flex', width: '100%', gap: 1 }}>
<Input
placeholder='Add new subtask...'
value={newSubtask}
onChange={e => setNewSubtask(e.target.value)}
onKeyPress={handleKeyPress}
sx={{ flex: 1 }}
autoFocus
/>
<IconButton onClick={submitNewSubtask} size='sm'>
<KeyboardReturn />
</IconButton>
</Box>
</ListItem>
)}
{/* Child tasks */}
{hasChildren && expanded && (
<Box sx={{ paddingLeft: `${level * 24}px` }}>
{childTasks
.sort((a, b) => a.orderId - b.orderId)
.map((childTask, childIndex) => (
<SortableItem
key={childTask.id}
task={childTask}
index={childIndex}
handleToggle={handleToggle}
handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={allTasks}
setTasks={setTasks}
level={level + 1}
editMode={editMode}
/>
))}
</Box> </Box>
)} )}
</ListItem> </>
) )
} }
const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => { const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => {
const [newTask, setNewTask] = useState('') const [newTask, setNewTask] = useState('')
const topLevelTasks = tasks.filter(task => task.parentId === null)
const handleToggle = taskId => { const handleToggle = taskId => {
const updatedTask = tasks.find(task => task.id === taskId) const updatedTask = tasks.find(task => task.id === taskId)
updatedTask.completedAt = updatedTask.completedAt const newCompletedAt = updatedTask.completedAt
? null ? null
: new Date().toISOString() : new Date().toISOString()
// Update the task
const updatedTasks = tasks.map(task => const updatedTasks = tasks.map(task =>
task.id === taskId ? updatedTask : task, task.id === taskId ? { ...task, completedAt: newCompletedAt } : task,
) )
CompleteSubTask(
taskId, // If completing a task, also complete all child tasks
Number(choreId), if (newCompletedAt) {
updatedTask.completedAt ? new Date().toISOString() : null, const completeChildren = parentId => {
).then(res => { const children = updatedTasks.filter(t => t.parentId === parentId)
children.forEach(child => {
const index = updatedTasks.findIndex(t => t.id === child.id)
if (index !== -1) {
updatedTasks[index] = {
...updatedTasks[index],
completedAt: newCompletedAt,
}
completeChildren(child.id) // Recursively complete grandchildren
}
})
}
completeChildren(taskId)
}
CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => {
if (res.status !== 200) { if (res.status !== 200) {
console.log('Error updating task') console.log('Error updating task')
return return
} }
}) })
setTasks(updatedTasks) setTasks(updatedTasks)
} }
const handleDelete = taskId => { const handleDelete = taskId => {
setTasks(tasks.filter(task => task.id !== taskId)) // Find all descendant tasks to delete
const findDescendants = id => {
const descendants = []
const children = tasks.filter(t => t.parentId === id)
children.forEach(child => {
descendants.push(child.id)
descendants.push(...findDescendants(child.id))
})
return descendants
}
const descendantIds = findDescendants(taskId)
const idsToDelete = [taskId, ...descendantIds]
// Filter out the task and all its descendants
const updatedTasks = tasks
.filter(task => !idsToDelete.includes(task.id))
.map((task, index) => ({
...task,
orderId: task.parentId === null ? index : task.orderId,
}))
setTasks(updatedTasks)
} }
const handleAdd = () => { const handleAdd = () => {
if (!newTask.trim()) return if (!newTask.trim()) return
setTasks([
...tasks, const newTaskObj = {
{
name: newTask, name: newTask,
completedAt: null, completedAt: null,
orderId: tasks.length, orderId: topLevelTasks.length,
}, parentId: null,
]) id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newTaskObj])
setNewTask('') setNewTask('')
} }
const handleAddSubtask = (parentId, name) => {
if (!name.trim()) return
// Find siblings to determine orderId
const siblings = tasks.filter(t => t.parentId === parentId)
const newSubtask = {
name,
completedAt: null,
orderId: siblings.length,
parentId,
id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newSubtask])
}
const onDragEnd = event => { const onDragEnd = event => {
const { active, over } = event const { active, over } = event
if (active.id !== over.id) { if (!over || active.id === over.id) return
setTasks(items => { setTasks(items => {
const oldIndex = items.findIndex(item => item.id === active.id) const oldIndex = items.findIndex(item => item.id === active.id)
const newIndex = items.findIndex(item => item.id === over.id) const newIndex = items.findIndex(item => item.id === over.id)
if (oldIndex === -1 || newIndex === -1) return items
const activeItem = items[oldIndex]
const overItem = items[newIndex]
const reorderedItems = arrayMove(items, oldIndex, newIndex) const reorderedItems = arrayMove(items, oldIndex, newIndex)
return reorderedItems.map((item, index) => ({
...item, const parentId = overItem.parentId
orderId: index, const siblings = reorderedItems.filter(item => item.parentId === parentId)
}))
}) return reorderedItems.map(item => {
if (item.id === activeItem.id) {
return { ...item, parentId, orderId: siblings.indexOf(item) }
} }
return item.parentId === parentId
? { ...item, orderId: siblings.indexOf(item) }
: item
})
})
} }
const handleKeyPress = event => { const handleKeyPress = event => {
@ -191,38 +403,37 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks, setTasks }) => {
} }
} }
// Sort tasks by orderId before rendering
const sortedTasks = [...tasks].sort((a, b) => a.orderId - b.orderId)
return ( return (
<> <>
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}> <DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext <SortableContext items={tasks} strategy={verticalListSortingStrategy}>
items={sortedTasks}
strategy={verticalListSortingStrategy}
>
<List sx={{ padding: 0 }}> <List sx={{ padding: 0 }}>
{sortedTasks.map((task, index) => ( {topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map((task, index) => (
<SortableItem <SortableItem
key={task.id} key={task.id}
task={task} task={task}
index={index} index={index}
handleToggle={handleToggle} handleToggle={handleToggle}
handleDelete={handleDelete} handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={tasks}
setTasks={setTasks}
editMode={editMode} editMode={editMode}
/> />
))} ))}
{editMode && ( {editMode && (
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Input <Input
placeholder='Add new...' placeholder='Add new task...'
value={newTask} value={newTask}
onChange={e => setNewTask(e.target.value)} onChange={e => setNewTask(e.target.value)}
onKeyPress={handleKeyPress} onKeyPress={handleKeyPress}
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
<IconButton onClick={handleAdd}> <IconButton onClick={handleAdd}>
<Add /> <KeyboardReturn />
</IconButton> </IconButton>
</ListItem> </ListItem>
)} )}