diff options
author | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
---|---|---|
committer | Mo Tarbin <mhed.t91@gmail.com> | 2024-06-30 18:55:39 -0400 |
commit | 2657469964e24ffbeb905024532120395f6e797c (patch) | |
tree | 2fe9db8a4ecfa92d854ca94f7586d81163c8bd25 /src/views/Chores | |
download | donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.gz donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.tar.bz2 donetick-frontend-2657469964e24ffbeb905024532120395f6e797c.zip |
move to Donetick Org, First commit frontend
Diffstat (limited to 'src/views/Chores')
-rw-r--r-- | src/views/Chores/ChoreCard.jsx | 578 | ||||
-rw-r--r-- | src/views/Chores/MyChores.jsx | 384 |
2 files changed, 962 insertions, 0 deletions
diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx new file mode 100644 index 0000000..0b2a408 --- /dev/null +++ b/src/views/Chores/ChoreCard.jsx @@ -0,0 +1,578 @@ +import { + Check, + Delete, + Edit, + HowToReg, + KeyboardDoubleArrowUp, + LocalOffer, + ManageSearch, + MoreTime, + MoreVert, + NoteAdd, + RecordVoiceOver, + Repeat, + Report, + SwitchAccessShortcut, + TimesOneMobiledata, + Update, + Webhook, +} from '@mui/icons-material' +import { + Avatar, + Box, + Card, + Chip, + CircularProgress, + Divider, + Grid, + IconButton, + Menu, + MenuItem, + Typography, +} from '@mui/joy' +import moment from 'moment' +import React, { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { API_URL } from '../../Config' +import { Fetch } from '../../utils/TokenManager' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' +import DateModal from '../Modals/Inputs/DateModal' +import SelectModal from '../Modals/Inputs/SelectModal' +import TextModal from '../Modals/Inputs/TextModal' +const ChoreCard = ({ chore, performers, onChoreUpdate, onChoreRemove, sx }) => { + const [activeUserId, setActiveUserId] = React.useState(0) + const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] = + React.useState(false) + const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] = + React.useState(false) + const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] = + React.useState(false) + const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] = + React.useState(false) + const [confirmModelConfig, setConfirmModelConfig] = React.useState({}) + const [anchorEl, setAnchorEl] = React.useState(null) + const menuRef = React.useRef(null) + const navigate = useNavigate() + const [isDisabled, setIsDisabled] = React.useState(false) + + // useEffect(() => { + // GetAllUsers() + // .then(response => response.json()) + // .then(data => { + // setPerformers(data.res) + // }) + // }, []) + + useEffect(() => { + document.addEventListener('mousedown', handleMenuOutsideClick) + return () => { + document.removeEventListener('mousedown', handleMenuOutsideClick) + } + }, [anchorEl]) + + const handleMenuOpen = event => { + setAnchorEl(event.currentTarget) + } + + const handleMenuClose = () => { + setAnchorEl(null) + } + + const handleMenuOutsideClick = event => { + if ( + anchorEl && + !anchorEl.contains(event.target) && + !menuRef.current.contains(event.target) + ) { + handleMenuClose() + } + } + const handleEdit = () => { + navigate(`/chores/${chore.id}/edit`) + } + const handleDelete = () => { + setConfirmModelConfig({ + isOpen: true, + title: 'Delete Chore', + confirmText: 'Delete', + cancelText: 'Cancel', + message: 'Are you sure you want to delete this chore?', + onClose: isConfirmed => { + console.log('isConfirmed', isConfirmed) + if (isConfirmed === true) { + Fetch(`${API_URL}/chores/${chore.id}`, { + method: 'DELETE', + }).then(response => { + if (response.ok) { + onChoreRemove(chore) + } + }) + } + setConfirmModelConfig({}) + }, + }) + } + + const handleCompleteChore = () => { + Fetch(`${API_URL}/chores/${chore.id}/do`, { + method: 'POST', + }).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = data.res + onChoreUpdate(newChore, 'completed') + }) + } + }) + setIsDisabled(true) + setTimeout(() => setIsDisabled(false), 5000) // Re-enable the button after 5 seconds + } + const handleChangeDueDate = newDate => { + if (activeUserId === null) { + alert('Please select a performer') + return + } + Fetch(`${API_URL}/chores/${chore.id}/dueDate`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + dueDate: newDate ? new Date(newDate).toISOString() : null, + UpdatedBy: activeUserId, + }), + }).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = data.res + onChoreUpdate(newChore, 'rescheduled') + }) + } + }) + } + + const handleCompleteWithPastDate = newDate => { + if (activeUserId === null) { + alert('Please select a performer') + return + } + Fetch( + `${API_URL}/chores/${chore.id}/do?completedDate=${new Date( + newDate, + ).toISOString()}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }, + ).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = data.res + onChoreUpdate(newChore, 'completed') + }) + } + }) + } + const handleAssigneChange = assigneeId => { + // TODO: Implement assignee change + } + const handleCompleteWithNote = note => { + Fetch(`${API_URL}/chores/${chore.id}/do`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + note: note, + }), + }).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = data.res + onChoreUpdate(newChore, 'completed') + }) + } + }) + } + const getDueDateChipText = nextDueDate => { + if (chore.nextDueDate === null) return 'No Due Date' + // if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM + const diff = moment(nextDueDate).diff(moment(), 'hours') + if (diff < 48 && diff > 0) { + return moment(nextDueDate).calendar().replace(' at', '') + } + return 'Due ' + moment(nextDueDate).fromNow() + } + const getDueDateChipColor = nextDueDate => { + if (chore.nextDueDate === null) return 'neutral' + const diff = moment(nextDueDate).diff(moment(), 'hours') + if (diff < 48 && diff > 0) { + return 'warning' + } + if (diff < 0) { + return 'danger' + } + + return 'neutral' + } + + const getIconForLabel = label => { + if (!label || label.trim() === '') return <></> + switch (String(label).toLowerCase()) { + case 'high': + return <KeyboardDoubleArrowUp /> + case 'important': + return <Report /> + default: + return <LocalOffer /> + } + } + + const getRecurrentChipText = chore => { + const dayOfMonthSuffix = n => { + if (n >= 11 && n <= 13) { + return 'th' + } + switch (n % 10) { + case 1: + return 'st' + case 2: + return 'nd' + case 3: + return 'rd' + default: + return 'th' + } + } + if (chore.frequencyType === 'once') { + return 'Once' + } else if (chore.frequencyType === 'trigger') { + return 'Trigger' + } else if (chore.frequencyType === 'daily') { + return 'Daily' + } else if (chore.frequencyType === 'weekly') { + return 'Weekly' + } else if (chore.frequencyType === 'monthly') { + return 'Monthly' + } else if (chore.frequencyType === 'yearly') { + return 'Yearly' + } else if (chore.frequencyType === 'days_of_the_week') { + let days = JSON.parse(chore.frequencyMetadata).days + days = days.map(d => moment().day(d).format('ddd')) + return days.join(', ') + } else if (chore.frequencyType === 'day_of_the_month') { + let freqData = JSON.parse(chore.frequencyMetadata) + const months = freqData.months.map(m => moment().month(m).format('MMM')) + return `${chore.frequency}${dayOfMonthSuffix( + chore.frequency, + )} of ${months.join(', ')}` + } else if (chore.frequencyType === 'interval') { + return `Every ${chore.frequency} ${ + JSON.parse(chore.frequencyMetadata).unit + }` + } else { + return chore.frequencyType + } + } + + const getFrequencyIcon = chore => { + if (['once', 'no_repeat'].includes(chore.frequencyType)) { + return <TimesOneMobiledata /> + } else if (chore.frequencyType === 'trigger') { + return <Webhook /> + } else { + return <Repeat /> + } + } + + return ( + <> + <Chip + variant='soft' + sx={{ + position: 'relative', + top: 10, + zIndex: 1, + left: 10, + }} + color={getDueDateChipColor(chore.nextDueDate)} + > + {getDueDateChipText(chore.nextDueDate)} + </Chip> + + <Chip + variant='soft' + sx={{ + position: 'relative', + top: 10, + zIndex: 1, + ml: 0.4, + left: 10, + }} + > + <div + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }} + > + {getFrequencyIcon(chore)} + + {getRecurrentChipText(chore)} + </div> + </Chip> + + <Card + variant='plain' + sx={{ + ...sx, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + p: 2, + // backgroundColor: 'white', + boxShadow: 'sm', + borderRadius: 20, + + // mb: 2, + }} + > + <Grid container> + <Grid item xs={9}> + {/* Box in top right with Chip showing next due date */} + <Box display='flex' justifyContent='start' alignItems='center'> + <Avatar sx={{ mr: 1, fontSize: 22 }}> + {chore.name.charAt(0).toUpperCase()} + </Avatar> + <Box display='flex' flexDirection='column'> + <Typography level='title-md'>{chore.name}</Typography> + <Typography level='body-md' color='text.disabled'> + Assigned to{' '} + <Chip variant='outlined'> + { + performers.find(p => p.id === chore.assignedTo) + ?.displayName + } + </Chip> + </Typography> + <Box> + {chore.labels?.split(',').map(label => ( + <Chip + variant='solid' + key={label} + color='primary' + sx={{ + position: 'relative', + ml: 0.5, + top: 10, + zIndex: 1, + left: 10, + }} + startDecorator={getIconForLabel(label)} + > + {label} + </Chip> + ))} + </Box> + </Box> + </Box> + {/* <Box display='flex' justifyContent='space-between' alignItems='center'> + <Chip variant='outlined'> + {chore.nextDueDate === null + ? '--' + : 'Due ' + moment(chore.nextDueDate).fromNow()} + </Chip> + </Box> */} + </Grid> + <Grid + item + xs={3} + sx={{ + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }} + > + <Box display='flex' justifyContent='flex-end' alignItems='flex-end'> + {/* <ButtonGroup> */} + <IconButton + variant='solid' + color='success' + onClick={handleCompleteChore} + disabled={isDisabled} + sx={{ + borderRadius: '50%', + width: 50, + height: 50, + zIndex: 1, + }} + > + <div className='relative grid place-items-center'> + <Check /> + {isDisabled && ( + <CircularProgress + variant='solid' + color='success' + size='md' + sx={{ + color: 'success.main', + position: 'absolute', + zIndex: 0, + }} + /> + )} + </div> + </IconButton> + <IconButton + // sx={{ width: 15 }} + variant='soft' + color='success' + onClick={handleMenuOpen} + sx={{ + borderRadius: '50%', + width: 25, + height: 25, + position: 'relative', + left: -10, + }} + > + <MoreVert /> + </IconButton> + {/* </ButtonGroup> */} + <Menu + size='md' + ref={menuRef} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleMenuClose} + > + <MenuItem + onClick={() => { + setIsCompleteWithNoteModalOpen(true) + }} + > + <NoteAdd /> + Complete with note + </MenuItem> + <MenuItem + onClick={() => { + setIsCompleteWithPastDateModalOpen(true) + }} + > + <Update /> + Complete in past + </MenuItem> + <MenuItem + onClick={() => { + Fetch(`${API_URL}/chores/${chore.id}/skip`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = data.res + onChoreUpdate(newChore, 'skipped') + handleMenuClose() + }) + } + }) + }} + > + <SwitchAccessShortcut /> + Skip to next due date + </MenuItem> + <MenuItem + onClick={() => { + setIsChangeAssigneeModalOpen(true) + }} + > + <RecordVoiceOver /> + Delegate to someone else + </MenuItem> + <MenuItem> + <HowToReg /> + Complete as someone else + </MenuItem> + <Divider /> + <MenuItem + onClick={() => { + navigate(`/chores/${chore.id}/history`) + }} + > + <ManageSearch /> + History + </MenuItem> + <Divider /> + <MenuItem + onClick={() => { + setIsChangeDueDateModalOpen(true) + }} + > + <MoreTime /> + Change due date + </MenuItem> + <MenuItem onClick={handleEdit}> + <Edit /> + Edit + </MenuItem> + <MenuItem onClick={handleDelete} color='danger'> + <Delete /> + Delete + </MenuItem> + </Menu> + </Box> + </Grid> + </Grid> + <DateModal + isOpen={isChangeDueDateModalOpen} + key={'changeDueDate' + chore.id} + current={chore.nextDueDate} + title={`Change due date`} + onClose={() => { + setIsChangeDueDateModalOpen(false) + }} + onSave={handleChangeDueDate} + /> + <DateModal + isOpen={isCompleteWithPastDateModalOpen} + key={'completedInPast' + chore.id} + current={chore.nextDueDate} + title={`Save Chore that you completed in the past`} + onClose={() => { + setIsCompleteWithPastDateModalOpen(false) + }} + onSave={handleCompleteWithPastDate} + /> + <SelectModal + isOpen={isChangeAssigneeModalOpen} + options={performers} + displayKey='displayName' + title={`Delegate to someone else`} + onClose={() => { + setIsChangeAssigneeModalOpen(false) + }} + onSave={handleAssigneChange} + /> + <ConfirmationModal config={confirmModelConfig} /> + <TextModal + isOpen={isCompleteWithNoteModalOpen} + title='Add note to attach to this completion:' + onClose={() => { + setIsCompleteWithNoteModalOpen(false) + }} + okText={'Complete'} + onSave={handleCompleteWithNote} + /> + </Card> + </> + ) +} + +export default ChoreCard diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx new file mode 100644 index 0000000..98fd443 --- /dev/null +++ b/src/views/Chores/MyChores.jsx @@ -0,0 +1,384 @@ +import { Add, EditCalendar } from '@mui/icons-material' +import { + Badge, + Box, + Checkbox, + CircularProgress, + Container, + IconButton, + List, + ListItem, + Menu, + MenuItem, + Snackbar, + Typography, +} from '@mui/joy' +import { useContext, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { UserContext } from '../../contexts/UserContext' +import Logo from '../../Logo' +import { GetAllUsers, GetChores, GetUserProfile } from '../../utils/Fetcher' +import ChoreCard from './ChoreCard' + +const MyChores = () => { + const { userProfile, setUserProfile } = useContext(UserContext) + const [isSnackbarOpen, setIsSnackbarOpen] = useState(false) + const [snackBarMessage, setSnackBarMessage] = useState(null) + const [chores, setChores] = useState([]) + const [filteredChores, setFilteredChores] = useState([]) + const [selectedFilter, setSelectedFilter] = useState('All') + const [activeUserId, setActiveUserId] = useState(0) + const [performers, setPerformers] = useState([]) + const [anchorEl, setAnchorEl] = useState(null) + const menuRef = useRef(null) + const Navigate = useNavigate() + const choreSorter = (a, b) => { + // 1. Handle null due dates (always last): + if (!a.nextDueDate && !b.nextDueDate) return 0 // Both null, no order + if (!a.nextDueDate) return 1 // a is null, comes later + if (!b.nextDueDate) return -1 // b is null, comes earlier + + const aDueDate = new Date(a.nextDueDate) + const bDueDate = new Date(b.nextDueDate) + const now = new Date() + + const oneDayInMs = 24 * 60 * 60 * 1000 + + // 2. Prioritize tasks due today +- 1 day: + const aTodayOrNear = Math.abs(aDueDate - now) <= oneDayInMs + const bTodayOrNear = Math.abs(bDueDate - now) <= oneDayInMs + if (aTodayOrNear && !bTodayOrNear) return -1 // a is closer + if (!aTodayOrNear && bTodayOrNear) return 1 // b is closer + + // 3. Handle overdue tasks (excluding today +- 1): + const aOverdue = aDueDate < now && !aTodayOrNear + const bOverdue = bDueDate < now && !bTodayOrNear + if (aOverdue && !bOverdue) return -1 // a is overdue, comes earlier + if (!aOverdue && bOverdue) return 1 // b is overdue, comes earlier + + // 4. Sort future tasks by due date: + return aDueDate - bDueDate // Sort ascending by due date + } + + const handleSelectedFilter = selected => { + setFilteredChores(FILTERS[selected](chores)) + + setSelectedFilter(selected) + } + + useEffect(() => { + if (userProfile === null) { + GetUserProfile() + .then(response => response.json()) + .then(data => { + setUserProfile(data.res) + }) + } + GetChores() + .then(response => response.json()) + .then(data => { + data.res.sort(choreSorter) + setChores(data.res) + + setFilteredChores(data.res) + }) + + GetAllUsers() + .then(response => response.json()) + .then(data => { + setPerformers(data.res) + }) + + const currentUser = JSON.parse(localStorage.getItem('user')) + if (currentUser !== null) { + setActiveUserId(currentUser.id) + } + }, []) + useEffect(() => { + document.addEventListener('mousedown', handleMenuOutsideClick) + return () => { + document.removeEventListener('mousedown', handleMenuOutsideClick) + } + }, [anchorEl]) + 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 handleChoreUpdated = (updatedChore, event) => { + const newChores = chores.map(chore => { + if (chore.id === updatedChore.id) { + return updatedChore + } + return chore + }) + + const newFilteredChores = filteredChores.map(chore => { + if (chore.id === updatedChore.id) { + return updatedChore + } + return chore + }) + setChores(newChores) + setFilteredChores(newFilteredChores) + switch (event) { + case 'completed': + setSnackBarMessage('Completed') + break + case 'skipped': + setSnackBarMessage('Skipped') + break + case 'rescheduled': + setSnackBarMessage('Rescheduled') + break + default: + setSnackBarMessage('Updated') + } + setIsSnackbarOpen(true) + } + + const handleChoreDeleted = deletedChore => { + const newChores = chores.filter(chore => chore.id !== deletedChore.id) + const newFilteredChores = filteredChores.filter( + chore => chore.id !== deletedChore.id, + ) + setChores(newChores) + setFilteredChores(newFilteredChores) + } + + if (userProfile === null) { + return ( + <Container className='flex h-full items-center justify-center'> + <Box className='flex flex-col items-center justify-center'> + <CircularProgress + color='success' + sx={{ '--CircularProgress-size': '200px' }} + > + <Logo /> + </CircularProgress> + </Box> + </Container> + ) + } + + return ( + <Container maxWidth='md'> + {/* <Typography level='h3' mb={1.5}> + My Chores + </Typography> */} + {/* <Sheet> */} + <List + orientation='horizontal' + wrap + sx={{ + '--List-gap': '8px', + '--ListItem-radius': '20px', + '--ListItem-minHeight': '32px', + '--ListItem-gap': '4px', + mt: 0.2, + }} + > + {['All', 'Overdue', 'Due today', 'Due in week'].map(filter => ( + <Badge + key={filter} + anchorOrigin={{ + vertical: 'top', + horizontal: 'right', + }} + variant='outlined' + color={selectedFilter === filter ? 'primary' : 'neutral'} + badgeContent={FILTERS[filter](chores).length} + badgeInset={'5px'} + > + <ListItem key={filter}> + <Checkbox + key={'checkbox' + filter} + label={filter} + onClick={() => handleSelectedFilter(filter)} + checked={filter === selectedFilter} + disableIcon + overlay + size='sm' + /> + </ListItem> + </Badge> + ))} + + <ListItem onClick={handleFilterMenuOpen}> + <Checkbox key='checkboxAll' label='⋮' disableIcon overlay size='lg' /> + </ListItem> + <Menu + ref={menuRef} + anchorEl={anchorEl} + open={Boolean(anchorEl)} + onClose={handleFilterMenuClose} + > + <MenuItem + onClick={() => { + setFilteredChores( + FILTERS['Assigned To Me'](chores, userProfile.id), + ) + setSelectedFilter('Assigned To Me') + handleFilterMenuClose() + }} + > + Assigned to me + </MenuItem> + <MenuItem + onClick={() => { + setFilteredChores( + FILTERS['Created By Me'](chores, userProfile.id), + ) + setSelectedFilter('Created By Me') + handleFilterMenuClose() + }} + > + Created by me + </MenuItem> + <MenuItem + onClick={() => { + setFilteredChores(FILTERS['No Due Date'](chores, userProfile.id)) + setSelectedFilter('No Due Date') + handleFilterMenuClose() + }} + > + No Due Date + </MenuItem> + </Menu> + </List> + {/* </Sheet> */} + {filteredChores.length === 0 && ( + <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> + </Box> + )} + + {filteredChores.map(chore => ( + <ChoreCard + key={chore.id} + chore={chore} + onChoreUpdate={handleChoreUpdated} + onChoreRemove={handleChoreDeleted} + performers={performers} + /> + ))} + + <Box + // variant='outlined' + sx={{ + position: 'fixed', + bottom: 0, + left: 10, + p: 2, // padding + display: 'flex', + justifyContent: 'flex-end', + gap: 2, + 'z-index': 1000, + }} + > + <IconButton + color='primary' + variant='solid' + sx={{ + borderRadius: '50%', + width: 50, + height: 50, + }} + // startDecorator={<Add />} + onClick={() => { + Navigate(`/chores/create`) + }} + > + <Add /> + </IconButton> + </Box> + <Snackbar + open={isSnackbarOpen} + onClose={() => { + setIsSnackbarOpen(false) + }} + autoHideDuration={3000} + variant='soft' + color='success' + size='lg' + invertedColors + > + <Typography level='title-md'>{snackBarMessage}</Typography> + </Snackbar> + </Container> + ) +} + +const FILTERS = { + All: function (chores) { + return chores + }, + Overdue: function (chores) { + return chores.filter(chore => { + if (chore.nextDueDate === null) return false + return new Date(chore.nextDueDate) < new Date() + }) + }, + 'Due today': function (chores) { + return chores.filter(chore => { + return ( + new Date(chore.nextDueDate).toDateString() === new Date().toDateString() + ) + }) + }, + 'Due in week': function (chores) { + return chores.filter(chore => { + return ( + new Date(chore.nextDueDate) < + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) && + new Date(chore.nextDueDate) > new Date() + ) + }) + }, + 'Created By Me': function (chores, userID) { + return chores.filter(chore => { + return chore.createdBy === userID + }) + }, + 'Assigned To Me': function (chores, userID) { + return chores.filter(chore => { + return chore.assignedTo === userID + }) + }, + 'No Due Date': function (chores, userID) { + return chores.filter(chore => { + return chore.nextDueDate === null + }) + }, +} + +export default MyChores |