aboutsummaryrefslogtreecommitdiffstats
path: root/src/views/ChoreEdit
diff options
context:
space:
mode:
authorLibravatar Mo Tarbin <mhed.t91@gmail.com>2024-06-30 18:55:39 -0400
committerLibravatar Mo Tarbin <mhed.t91@gmail.com>2024-06-30 18:55:39 -0400
commit2657469964e24ffbeb905024532120395f6e797c (patch)
tree2fe9db8a4ecfa92d854ca94f7586d81163c8bd25 /src/views/ChoreEdit
downloaddonetick-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/ChoreEdit')
-rw-r--r--src/views/ChoreEdit/ChoreEdit.jsx744
-rw-r--r--src/views/ChoreEdit/RepeatSection.jsx496
-rw-r--r--src/views/ChoreEdit/ThingTriggerSection.jsx230
3 files changed, 1470 insertions, 0 deletions
diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx
new file mode 100644
index 0000000..e8eb17d
--- /dev/null
+++ b/src/views/ChoreEdit/ChoreEdit.jsx
@@ -0,0 +1,744 @@
+import {
+ Box,
+ Button,
+ Card,
+ Checkbox,
+ Chip,
+ Container,
+ Divider,
+ FormControl,
+ FormHelperText,
+ Input,
+ List,
+ ListItem,
+ Option,
+ Radio,
+ RadioGroup,
+ Select,
+ Sheet,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import { useContext, useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { UserContext } from '../../contexts/UserContext'
+import {
+ CreateChore,
+ DeleteChore,
+ GetAllCircleMembers,
+ GetChoreByID,
+ GetChoreHistory,
+ GetThings,
+ SaveChore,
+} from '../../utils/Fetcher'
+import { isPlusAccount } from '../../utils/Helpers'
+import FreeSoloCreateOption from '../components/AutocompleteSelect'
+import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
+import RepeatSection from './RepeatSection'
+const ASSIGN_STRATEGIES = [
+ 'random',
+ 'least_assigned',
+ 'least_completed',
+ 'keep_last_assigned',
+]
+const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
+
+const NO_DUE_DATE_REQUIRED_TYPE = ['no_repeat', 'once']
+const NO_DUE_DATE_ALLOWED_TYPE = ['trigger']
+const ChoreEdit = () => {
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ const [chore, setChore] = useState([])
+ const [choresHistory, setChoresHistory] = useState([])
+ const [userHistory, setUserHistory] = useState({})
+ const { choreId } = useParams()
+ const [name, setName] = useState('')
+ const [confirmModelConfig, setConfirmModelConfig] = useState({})
+ const [assignees, setAssignees] = useState([])
+ const [performers, setPerformers] = useState([])
+ const [assignStrategy, setAssignStrategy] = useState(ASSIGN_STRATEGIES[2])
+ const [dueDate, setDueDate] = useState(null)
+ const [completed, setCompleted] = useState(false)
+ const [completedDate, setCompletedDate] = useState('')
+ const [assignedTo, setAssignedTo] = useState(-1)
+ const [frequencyType, setFrequencyType] = useState('once')
+ const [frequency, setFrequency] = useState(1)
+ const [frequencyMetadata, setFrequencyMetadata] = useState({})
+ const [labels, setLabels] = useState([])
+ const [allUserThings, setAllUserThings] = useState([])
+ const [thingTrigger, setThingTrigger] = useState({})
+ const [isThingValid, setIsThingValid] = useState(false)
+
+ const [notificationMetadata, setNotificationMetadata] = useState({})
+
+ const [isRolling, setIsRolling] = useState(false)
+ const [isNotificable, setIsNotificable] = useState(false)
+ const [isActive, setIsActive] = useState(true)
+ const [updatedBy, setUpdatedBy] = useState(0)
+ const [createdBy, setCreatedBy] = useState(0)
+ const [errors, setErrors] = useState({})
+ const [attemptToSave, setAttemptToSave] = useState(false)
+
+ const Navigate = useNavigate()
+
+ const HandleValidateChore = () => {
+ const errors = {}
+
+ if (name.trim() === '') {
+ errors.name = 'Name is required'
+ }
+ if (assignees.length === 0) {
+ errors.assignees = 'At least 1 assignees is required'
+ }
+ if (assignedTo < 0) {
+ errors.assignedTo = 'Assigned to is required'
+ }
+ if (frequencyType === 'interval' && frequency < 1) {
+ errors.frequency = 'Frequency is required'
+ }
+ if (
+ frequencyType === 'days_of_the_week' &&
+ frequencyMetadata['days']?.length === 0
+ ) {
+ errors.frequency = 'At least 1 day is required'
+ }
+ if (
+ frequencyType === 'day_of_the_month' &&
+ frequencyMetadata['months']?.length === 0
+ ) {
+ errors.frequency = 'At least 1 month is required'
+ }
+ if (
+ dueDate === null &&
+ !NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) &&
+ !NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)
+ ) {
+ if (REPEAT_ON_TYPE.includes(frequencyType)) {
+ errors.dueDate = 'Start date is required'
+ } else {
+ errors.dueDate = 'Due date is required'
+ }
+ }
+ if (frequencyType === 'trigger') {
+ if (!isThingValid) {
+ errors.thingTrigger = 'Thing trigger is invalid'
+ }
+ }
+
+ // if there is any error then return false:
+ setErrors(errors)
+ if (Object.keys(errors).length > 0) {
+ return false
+ }
+ return true
+ }
+
+ const HandleSaveChore = () => {
+ setAttemptToSave(true)
+ if (!HandleValidateChore()) {
+ console.log('validation failed')
+ console.log(errors)
+ return
+ }
+ const chore = {
+ id: Number(choreId),
+ name: name,
+ assignees: assignees,
+ dueDate: dueDate ? new Date(dueDate).toISOString() : null,
+ frequencyType: frequencyType,
+ frequency: Number(frequency),
+ frequencyMetadata: frequencyMetadata,
+ assignedTo: assignedTo,
+ assignStrategy: assignStrategy,
+ isRolling: isRolling,
+ isActive: isActive,
+ notification: isNotificable,
+ labels: labels,
+ notificationMetadata: notificationMetadata,
+ thingTrigger: thingTrigger,
+ }
+ let SaveFunction = CreateChore
+ if (choreId > 0) {
+ SaveFunction = SaveChore
+ }
+
+ SaveFunction(chore).then(response => {
+ if (response.status === 200) {
+ Navigate(`/my/chores`)
+ } else {
+ alert('Failed to save chore')
+ }
+ })
+ }
+ useEffect(() => {
+ //fetch performers:
+ GetAllCircleMembers()
+ .then(response => response.json())
+ .then(data => {
+ setPerformers(data.res)
+ })
+ GetThings().then(response => {
+ response.json().then(data => {
+ setAllUserThings(data.res)
+ })
+ })
+ // fetch chores:
+ if (choreId > 0) {
+ GetChoreByID(choreId)
+ .then(response => {
+ if (response.status !== 200) {
+ alert('You are not authorized to view this chore.')
+ Navigate('/my/chores')
+ return null
+ } else {
+ return response.json()
+ }
+ })
+ .then(data => {
+ setChore(data.res)
+ setName(data.res.name ? data.res.name : '')
+ setAssignees(data.res.assignees ? data.res.assignees : [])
+ setAssignedTo(data.res.assignedTo)
+ setFrequencyType(
+ data.res.frequencyType ? data.res.frequencyType : 'once',
+ )
+
+ setFrequencyMetadata(JSON.parse(data.res.frequencyMetadata))
+ setFrequency(data.res.frequency)
+
+ setNotificationMetadata(JSON.parse(data.res.notificationMetadata))
+ setLabels(data.res.labels ? data.res.labels.split(',') : [])
+
+ setAssignStrategy(
+ data.res.assignStrategy
+ ? data.res.assignStrategy
+ : ASSIGN_STRATEGIES[2],
+ )
+ setIsRolling(data.res.isRolling)
+ setIsActive(data.res.isActive)
+ // parse the due date to a string from this format "2021-10-10T00:00:00.000Z"
+ // use moment.js or date-fns to format the date for to be usable in the input field:
+ setDueDate(
+ data.res.nextDueDate
+ ? moment(data.res.nextDueDate).format('YYYY-MM-DDTHH:mm:ss')
+ : null,
+ )
+ setUpdatedBy(data.res.updatedBy)
+ setCreatedBy(data.res.createdBy)
+ setIsNotificable(data.res.notification)
+ setThingTrigger(data.res.thingChore)
+ // setDueDate(data.res.dueDate)
+ // setCompleted(data.res.completed)
+ // setCompletedDate(data.res.completedDate)
+ })
+
+ // fetch chores history:
+ GetChoreHistory(choreId)
+ .then(response => response.json())
+ .then(data => {
+ setChoresHistory(data.res)
+ const newUserChoreHistory = {}
+ data.res.forEach(choreHistory => {
+ if (newUserChoreHistory[choreHistory.completedBy]) {
+ newUserChoreHistory[choreHistory.completedBy] += 1
+ } else {
+ newUserChoreHistory[choreHistory.completedBy] = 1
+ }
+ })
+
+ setUserHistory(newUserChoreHistory)
+ })
+ }
+ // set focus on the first input field:
+ else {
+ // new task/ chore set focus on the first input field:
+ document.querySelector('input').focus()
+ }
+ }, [])
+
+ useEffect(() => {
+ // if frequancy type change to somthing need a due date then set it to the current date:
+ if (!NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && !dueDate) {
+ setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
+ }
+ if (NO_DUE_DATE_ALLOWED_TYPE.includes(frequencyType)) {
+ setDueDate(null)
+ }
+ }, [frequencyType])
+
+ useEffect(() => {
+ if (assignees.length === 1) {
+ setAssignedTo(assignees[0].userId)
+ }
+ }, [assignees])
+
+ useEffect(() => {
+ if (performers.length > 0 && assignees.length === 0) {
+ setAssignees([
+ {
+ userId: userProfile.id,
+ },
+ ])
+ }
+ }, [performers])
+
+ // if user resolve the error trigger validation to remove the error message from the respective field
+ useEffect(() => {
+ if (attemptToSave) {
+ HandleValidateChore()
+ }
+ }, [assignees, name, frequencyMetadata, attemptToSave, dueDate])
+
+ const handleDelete = () => {
+ setConfirmModelConfig({
+ isOpen: true,
+ title: 'Delete Chore',
+ confirmText: 'Delete',
+ cancelText: 'Cancel',
+ message: 'Are you sure you want to delete this chore?',
+ onClose: isConfirmed => {
+ if (isConfirmed === true) {
+ DeleteChore(choreId).then(response => {
+ if (response.status === 200) {
+ Navigate('/my/chores')
+ } else {
+ alert('Failed to delete chore')
+ }
+ })
+ }
+ setConfirmModelConfig({})
+ },
+ })
+ }
+ return (
+ <Container maxWidth='md'>
+ {/* <Typography level='h3' mb={1.5}>
+ Edit Chore
+ </Typography> */}
+ <Box>
+ <FormControl error={errors.name}>
+ <Typography level='h4'>Descritpion :</Typography>
+ <Typography level='h5'>What is this chore about?</Typography>
+ <Input value={name} onChange={e => setName(e.target.value)} />
+ <FormHelperText error>{errors.name}</FormHelperText>
+ </FormControl>
+ </Box>
+ <Box mt={2}>
+ <Typography level='h4'>Assignees :</Typography>
+ <Typography level='h5'>Who can do this chore?</Typography>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {performers?.map((item, index) => (
+ <ListItem key={item.id}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={assignees.find(a => a.userId == item.id) != null}
+ onClick={() => {
+ if (assignees.find(a => a.userId == item.id)) {
+ setAssignees(assignees.filter(i => i.userId !== item.id))
+ } else {
+ setAssignees([...assignees, { userId: item.id }])
+ }
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.displayName}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ <FormControl error={Boolean(errors.assignee)}>
+ <FormHelperText error>{Boolean(errors.assignee)}</FormHelperText>
+ </FormControl>
+ </Box>
+ {assignees.length > 1 && (
+ // this wrap the details that needed if we have more than one assingee
+ // we need to pick the next assignedTo and also the strategy to pick the next assignee.
+ // if we have only one then no need to display this section
+ <>
+ <Box mt={2}>
+ <Typography level='h4'>Assigned :</Typography>
+ <Typography level='h5'>
+ Who is assigned the next due chore?
+ </Typography>
+
+ <Select
+ placeholder={
+ assignees.length === 0
+ ? 'No Assignees yet can perform this chore'
+ : 'Select an assignee for this chore'
+ }
+ disabled={assignees.length === 0}
+ value={assignedTo > -1 ? assignedTo : null}
+ >
+ {performers
+ ?.filter(p => assignees.find(a => a.userId == p.userId))
+ .map((item, index) => (
+ <Option
+ value={item.id}
+ key={item.displayName}
+ onClick={() => {
+ setAssignedTo(item.id)
+ }}
+ >
+ {item.displayName}
+ {/* <Chip size='sm' color='neutral' variant='soft'>
+ </Chip> */}
+ </Option>
+ ))}
+ </Select>
+ </Box>
+ <Box mt={2}>
+ <Typography level='h4'>Picking Mode :</Typography>
+ <Typography level='h5'>
+ How to pick the next assignee for the following chore?
+ </Typography>
+
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {ASSIGN_STRATEGIES.map((item, idx) => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={assignStrategy === item}
+ onClick={() => setAssignStrategy(item)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item
+ .split('_')
+ .map(x => x.charAt(0).toUpperCase() + x.slice(1))
+ .join(' ')}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Box>
+ </>
+ )}
+ <RepeatSection
+ frequency={frequency}
+ onFrequencyUpdate={setFrequency}
+ frequencyType={frequencyType}
+ onFrequencyTypeUpdate={setFrequencyType}
+ frequencyMetadata={frequencyMetadata}
+ onFrequencyMetadataUpdate={setFrequencyMetadata}
+ frequencyError={errors?.frequency}
+ allUserThings={allUserThings}
+ onTriggerUpdate={thingUpdate => {
+ if (thingUpdate === null) {
+ setThingTrigger(null)
+ return
+ }
+ setThingTrigger({
+ triggerState: thingUpdate.triggerState,
+ condition: thingUpdate.condition,
+ thingID: thingUpdate.thing.id,
+ })
+ }}
+ OnTriggerValidate={setIsThingValid}
+ isAttemptToSave={attemptToSave}
+ selectedThing={thingTrigger}
+ />
+
+ <Box mt={2}>
+ <Typography level='h4'>
+ {REPEAT_ON_TYPE.includes(frequencyType) ? 'Start date' : 'Due date'} :
+ </Typography>
+ {frequencyType === 'trigger' && !dueDate && (
+ <Typography level='body-sm'>
+ Due Date will be set when the trigger of the thing is met
+ </Typography>
+ )}
+
+ {NO_DUE_DATE_REQUIRED_TYPE.includes(frequencyType) && (
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ if (e.target.checked) {
+ setDueDate(moment(new Date()).format('YYYY-MM-DDTHH:mm:00'))
+ } else {
+ setDueDate(null)
+ }
+ }}
+ defaultChecked={dueDate !== null}
+ checked={dueDate !== null}
+ value={dueDate !== null}
+ overlay
+ label='Give this task a due date'
+ />
+ <FormHelperText>
+ task needs to be completed by a specific time.
+ </FormHelperText>
+ </FormControl>
+ )}
+ {dueDate && (
+ <FormControl error={Boolean(errors.dueDate)}>
+ <Typography level='h5'>
+ {REPEAT_ON_TYPE.includes(frequencyType)
+ ? 'When does this chore start?'
+ : 'When is the next first time this chore is due?'}
+ </Typography>
+ <Input
+ type='datetime-local'
+ value={dueDate}
+ onChange={e => {
+ setDueDate(e.target.value)
+ }}
+ />
+ <FormHelperText>{errors.dueDate}</FormHelperText>
+ </FormControl>
+ )}
+ </Box>
+ {!['once', 'no_repeat'].includes(frequencyType) && (
+ <Box mt={2}>
+ <Typography level='h4'>Scheduling Preferences: </Typography>
+ <Typography level='h5'>
+ How to reschedule the next due date?
+ </Typography>
+
+ <RadioGroup name='tiers' sx={{ gap: 1, '& > div': { p: 1 } }}>
+ <FormControl>
+ <Radio
+ overlay
+ checked={!isRolling}
+ onClick={() => setIsRolling(false)}
+ label='Reschedule from due date'
+ />
+ <FormHelperText>
+ the next task will be scheduled from the original due date, even
+ if the previous task was completed late
+ </FormHelperText>
+ </FormControl>
+ <FormControl>
+ <Radio
+ overlay
+ checked={isRolling}
+ onClick={() => setIsRolling(true)}
+ label='Reschedule from completion date'
+ />
+ <FormHelperText>
+ the next task will be scheduled from the actual completion date
+ of the previous task
+ </FormHelperText>
+ </FormControl>
+ </RadioGroup>
+ </Box>
+ )}
+ <Box mt={2}>
+ <Typography level='h4'>Notifications : </Typography>
+ <Typography level='h5'>
+ Get Reminders when this task is due or completed
+ {!isPlusAccount(userProfile) && (
+ <Chip variant='soft' color='warning'>
+ Not available in Basic Plan
+ </Chip>
+ )}
+ </Typography>
+
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ setIsNotificable(e.target.checked)
+ }}
+ defaultChecked={isNotificable}
+ checked={isNotificable}
+ value={isNotificable}
+ disabled={!isPlusAccount(userProfile)}
+ overlay
+ label='Notify for this task'
+ />
+ <FormHelperText
+ sx={{
+ opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
+ }}
+ >
+ Receive notifications for this task
+ </FormHelperText>
+ </FormControl>
+ </Box>
+ {isNotificable && (
+ <Box
+ ml={4}
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 2,
+
+ '& > div': { p: 2, borderRadius: 'md', display: 'flex' },
+ }}
+ >
+ <Card variant='outlined'>
+ <Typography level='h5'>
+ What things should trigger the notification?
+ </Typography>
+ {[
+ {
+ title: 'Due Date/Time',
+ description: 'A simple reminder that a task is due',
+ id: 'dueDate',
+ },
+ // {
+ // title: 'Upon Completion',
+ // description: 'A notification when a task is completed',
+ // id: 'completion',
+ // },
+ {
+ title: 'Predued',
+ description: 'before a task is due in few hours',
+ id: 'predue',
+ },
+ {
+ title: 'Overdue',
+ description: 'A notification when a task is overdue',
+ },
+ {
+ title: 'Nagging',
+ description: 'Daily reminders until the task is completed',
+ id: 'nagging',
+ },
+ ].map(item => (
+ <FormControl sx={{ mb: 1 }} key={item.id}>
+ <Checkbox
+ overlay
+ onClick={() => {
+ setNotificationMetadata({
+ ...notificationMetadata,
+ [item.id]: !notificationMetadata[item.id],
+ })
+ }}
+ checked={
+ notificationMetadata ? notificationMetadata[item.id] : false
+ }
+ label={item.title}
+ key={item.title}
+ />
+ <FormHelperText>{item.description}</FormHelperText>
+ </FormControl>
+ ))}
+ </Card>
+ </Box>
+ )}
+ <Box mt={2}>
+ <Typography level='h4'>Labels :</Typography>
+ <Typography level='h5'>
+ Things to remember about this chore or to tag it
+ </Typography>
+ <FreeSoloCreateOption
+ options={labels}
+ onSelectChange={changes => {
+ const newLabels = []
+ changes.map(change => {
+ // if type is string :
+ if (typeof change === 'string') {
+ // add the change to the labels array:
+ newLabels.push(change)
+ } else {
+ newLabels.push(change.inputValue)
+ }
+ })
+ setLabels(newLabels)
+ }}
+ />
+ </Box>
+ {choreId > 0 && (
+ <Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.5 }}>
+ <Sheet
+ sx={{
+ p: 2,
+ borderRadius: 'md',
+ boxShadow: 'sm',
+ }}
+ >
+ <Typography level='body1'>
+ Created by{' '}
+ <Chip variant='solid'>
+ {performers.find(f => f.id === createdBy)?.displayName}
+ </Chip>{' '}
+ {moment(chore.createdAt).fromNow()}
+ </Typography>
+ {(chore.updatedAt && updatedBy > 0 && (
+ <>
+ <Divider sx={{ my: 1 }} />
+
+ <Typography level='body1'>
+ Updated by{' '}
+ <Chip variant='solid'>
+ {performers.find(f => f.id === updatedBy)?.displayName}
+ </Chip>{' '}
+ {moment(chore.updatedAt).fromNow()}
+ </Typography>
+ </>
+ )) || <></>}
+ </Sheet>
+ </Box>
+ )}
+ <Divider sx={{ mb: 9 }} />
+
+ {/* <Box mt={2} alignSelf={'flex-start'} display='flex' gap={2}>
+ <Button onClick={SaveChore}>Save</Button>
+ </Box> */}
+ <Sheet
+ variant='outlined'
+ sx={{
+ position: 'fixed',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ p: 2, // padding
+ display: 'flex',
+ justifyContent: 'flex-end',
+ gap: 2,
+ 'z-index': 1000,
+ bgcolor: 'background.body',
+ boxShadow: 'md', // Add a subtle shadow
+ }}
+ >
+ {choreId > 0 && (
+ <Button
+ color='danger'
+ variant='solid'
+ onClick={() => {
+ // confirm before deleting:
+ handleDelete()
+ }}
+ >
+ Delete
+ </Button>
+ )}
+ <Button
+ color='neutral'
+ variant='outlined'
+ onClick={() => {
+ window.history.back()
+ }}
+ >
+ Cancel
+ </Button>
+ <Button color='primary' variant='solid' onClick={HandleSaveChore}>
+ {choreId > 0 ? 'Save' : 'Create'}
+ </Button>
+ </Sheet>
+ <ConfirmationModal config={confirmModelConfig} />
+ {/* <ChoreHistory ChoreHistory={choresHistory} UsersData={performers} /> */}
+ </Container>
+ )
+}
+
+export default ChoreEdit
diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx
new file mode 100644
index 0000000..cb680eb
--- /dev/null
+++ b/src/views/ChoreEdit/RepeatSection.jsx
@@ -0,0 +1,496 @@
+import {
+ Box,
+ Card,
+ Checkbox,
+ Chip,
+ FormControl,
+ FormHelperText,
+ Grid,
+ Input,
+ List,
+ ListItem,
+ Option,
+ Radio,
+ RadioGroup,
+ Select,
+ Typography,
+} from '@mui/joy'
+import { useContext, useState } from 'react'
+import { UserContext } from '../../contexts/UserContext'
+import { isPlusAccount } from '../../utils/Helpers'
+import ThingTriggerSection from './ThingTriggerSection'
+
+const FREQUANCY_TYPES_RADIOS = [
+ 'daily',
+ 'weekly',
+ 'monthly',
+ 'yearly',
+ 'adaptive',
+ 'custom',
+]
+
+const FREQUENCY_TYPE_MESSAGE = {
+ adaptive:
+ 'This chore will be scheduled dynamically based on previous completion dates.',
+ custom: 'This chore will be scheduled based on a custom frequency.',
+}
+const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
+const FREQUANCY_TYPES = [
+ 'once',
+ 'daily',
+ 'weekly',
+ 'monthly',
+ 'yearly',
+ 'adaptive',
+ ...REPEAT_ON_TYPE,
+]
+const MONTH_WITH_NO_31_DAYS = [
+ // TODO: Handle these months if day is 31
+ 'february',
+ 'april',
+ 'june',
+ 'september',
+ 'november',
+]
+const RepeatOnSections = ({
+ frequencyType,
+ frequency,
+ onFrequencyUpdate,
+ onFrequencyTypeUpdate,
+ frequencyMetadata,
+ onFrequencyMetadataUpdate,
+ things,
+}) => {
+ const [months, setMonths] = useState({})
+ // const [dayOftheMonth, setDayOftheMonth] = useState(1)
+ const [daysOfTheWeek, setDaysOfTheWeek] = useState({})
+ const [monthsOfTheYear, setMonthsOfTheYear] = useState({})
+ const [intervalUnit, setIntervalUnit] = useState('days')
+
+ switch (frequencyType) {
+ case 'interval':
+ return (
+ <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
+ <Typography level='h5'>Every: </Typography>
+ <Input
+ type='number'
+ value={frequency}
+ onChange={e => {
+ if (e.target.value < 1) {
+ e.target.value = 1
+ }
+ onFrequencyUpdate(e.target.value)
+ }}
+ />
+ <Select placeholder='Unit' value={intervalUnit}>
+ {['hours', 'days', 'weeks', 'months', 'years'].map(item => (
+ <Option
+ key={item}
+ value={item}
+ onClick={() => {
+ setIntervalUnit(item)
+ onFrequencyMetadataUpdate({
+ unit: item,
+ })
+ }}
+ >
+ {item.charAt(0).toUpperCase() + item.slice(1)}
+ </Option>
+ ))}
+ </Select>
+ </Grid>
+ )
+ case 'days_of_the_week':
+ return (
+ <Grid item sm={12} sx={{ display: 'flex', alignItems: 'center' }}>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {[
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+ 'sunday',
+ ].map(item => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+
+ checked={frequencyMetadata?.days?.includes(item) || false}
+ onClick={() => {
+ const newDaysOfTheWeek = frequencyMetadata['days'] || []
+ if (newDaysOfTheWeek.includes(item)) {
+ newDaysOfTheWeek.splice(
+ newDaysOfTheWeek.indexOf(item),
+ 1,
+ )
+ } else {
+ newDaysOfTheWeek.push(item)
+ }
+
+ onFrequencyMetadataUpdate({
+ days: newDaysOfTheWeek.sort(),
+ })
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.charAt(0).toUpperCase() + item.slice(1)}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Grid>
+ )
+ case 'day_of_the_month':
+ return (
+ <Grid
+ item
+ sm={12}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ }}
+ >
+ <Box
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ mb: 1.5,
+ }}
+ >
+ <Typography>on the </Typography>
+ <Input
+ sx={{ width: '80px' }}
+ type='number'
+ value={frequency}
+ onChange={e => {
+ if (e.target.value < 1) {
+ e.target.value = 1
+ } else if (e.target.value > 31) {
+ e.target.value = 31
+ }
+ // setDayOftheMonth(e.target.value)
+
+ onFrequencyUpdate(e.target.value)
+ }}
+ />
+ <Typography>of the following month/s: </Typography>
+ </Box>
+ <Card>
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {[
+ 'january',
+ 'february',
+ 'march',
+ 'april',
+ 'may',
+ 'june',
+ 'july',
+ 'august',
+ 'september',
+ 'october',
+ 'november',
+ 'december',
+ ].map(item => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={frequencyMetadata?.months?.includes(item)}
+ // checked={months[item] || false}
+ // onClick={() => {
+ // const newMonthsOfTheYear = {
+ // ...monthsOfTheYear,
+ // }
+ // newMonthsOfTheYear[item] = !newMonthsOfTheYear[item]
+ // onFrequencyMetadataUpdate({
+ // months: newMonthsOfTheYear,
+ // })
+ // setMonthsOfTheYear(newMonthsOfTheYear)
+ // }}
+ onClick={() => {
+ const newMonthsOfTheYear =
+ frequencyMetadata['months'] || []
+ if (newMonthsOfTheYear.includes(item)) {
+ newMonthsOfTheYear.splice(
+ newMonthsOfTheYear.indexOf(item),
+ 1,
+ )
+ } else {
+ newMonthsOfTheYear.push(item)
+ }
+
+ onFrequencyMetadataUpdate({
+ months: newMonthsOfTheYear.sort(),
+ })
+ console.log('newMonthsOfTheYear', newMonthsOfTheYear)
+ // setDaysOfTheWeek(newDaysOfTheWeek)
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={item.charAt(0).toUpperCase() + item.slice(1)}
+ />
+ </ListItem>
+ ))}
+ </List>
+ </Card>
+ </Grid>
+ )
+
+ default:
+ return <></>
+ }
+}
+
+const RepeatSection = ({
+ frequencyType,
+ frequency,
+ onFrequencyUpdate,
+ onFrequencyTypeUpdate,
+ frequencyMetadata,
+ onFrequencyMetadataUpdate,
+ frequencyError,
+ allUserThings,
+ onTriggerUpdate,
+ OnTriggerValidate,
+ isAttemptToSave,
+ selectedThing,
+}) => {
+ const [repeatOn, setRepeatOn] = useState('interval')
+ const { userProfile, setUserProfile } = useContext(UserContext)
+ return (
+ <Box mt={2}>
+ <Typography level='h4'>Repeat :</Typography>
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ onFrequencyTypeUpdate(e.target.checked ? 'daily' : 'once')
+ if (e.target.checked) {
+ onTriggerUpdate(null)
+ }
+ }}
+ defaultChecked={!['once', 'trigger'].includes(frequencyType)}
+ checked={!['once', 'trigger'].includes(frequencyType)}
+ value={!['once', 'trigger'].includes(frequencyType)}
+ overlay
+ label='Repeat this task'
+ />
+ <FormHelperText>
+ Is this something needed to be done regularly?
+ </FormHelperText>
+ </FormControl>
+ {!['once', 'trigger'].includes(frequencyType) && (
+ <>
+ <Card sx={{ mt: 1 }}>
+ <Typography level='h5'>How often should it be repeated?</Typography>
+
+ <List
+ orientation='horizontal'
+ wrap
+ sx={{
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+ }}
+ >
+ {FREQUANCY_TYPES_RADIOS.map((item, index) => (
+ <ListItem key={item}>
+ <Checkbox
+ // disabled={index === 0}
+ checked={
+ item === frequencyType ||
+ (item === 'custom' &&
+ REPEAT_ON_TYPE.includes(frequencyType))
+ }
+ // defaultChecked={item === frequencyType}
+ onClick={() => {
+ if (item === 'custom') {
+ onFrequencyTypeUpdate(REPEAT_ON_TYPE[0])
+ onFrequencyUpdate(1)
+ onFrequencyMetadataUpdate({
+ unit: 'days',
+ })
+ return
+ }
+ onFrequencyTypeUpdate(item)
+ }}
+ overlay
+ disableIcon
+ variant='soft'
+ label={
+ item.charAt(0).toUpperCase() +
+ item.slice(1).replace('_', ' ')
+ }
+ />
+ </ListItem>
+ ))}
+ </List>
+ <Typography>{FREQUENCY_TYPE_MESSAGE[frequencyType]}</Typography>
+ {frequencyType === 'custom' ||
+ (REPEAT_ON_TYPE.includes(frequencyType) && (
+ <>
+ <Grid container spacing={1} mt={2}>
+ <Grid item>
+ <Typography>Repeat on:</Typography>
+ <Box
+ sx={{ display: 'flex', alignItems: 'center', gap: 2 }}
+ >
+ <RadioGroup
+ orientation='horizontal'
+ aria-labelledby='segmented-controls-example'
+ name='justify'
+ // value={justify}
+ // onChange={event => setJustify(event.target.value)}
+ sx={{
+ minHeight: 48,
+ padding: '4px',
+ borderRadius: '12px',
+ bgcolor: 'neutral.softBg',
+ '--RadioGroup-gap': '4px',
+ '--Radio-actionRadius': '8px',
+ mb: 1,
+ }}
+ >
+ {REPEAT_ON_TYPE.map(item => (
+ <Radio
+ key={item}
+ color='neutral'
+ checked={item === frequencyType}
+ onClick={() => {
+ if (
+ item === 'day_of_the_month' ||
+ item === 'interval'
+ ) {
+ onFrequencyUpdate(1)
+ }
+ onFrequencyTypeUpdate(item)
+ if (item === 'days_of_the_week') {
+ onFrequencyMetadataUpdate({ days: [] })
+ } else if (item === 'day_of_the_month') {
+ onFrequencyMetadataUpdate({ months: [] })
+ } else if (item === 'interval') {
+ onFrequencyMetadataUpdate({ unit: 'days' })
+ }
+ // setRepeatOn(item)
+ }}
+ value={item}
+ disableIcon
+ label={item
+ .split('_')
+ .map((i, idx) => {
+ // first or last word
+ if (
+ idx === 0 ||
+ idx === item.split('_').length - 1
+ ) {
+ return (
+ i.charAt(0).toUpperCase() + i.slice(1)
+ )
+ }
+ return i
+ })
+ .join(' ')}
+ variant='plain'
+ sx={{
+ px: 2,
+ alignItems: 'center',
+ }}
+ slotProps={{
+ action: ({ checked }) => ({
+ sx: {
+ ...(checked && {
+ bgcolor: 'background.surface',
+ boxShadow: 'sm',
+ '&:hover': {
+ bgcolor: 'background.surface',
+ },
+ }),
+ },
+ }),
+ }}
+ />
+ ))}
+ </RadioGroup>
+ </Box>
+ </Grid>
+
+ <RepeatOnSections
+ frequency={frequency}
+ onFrequencyUpdate={onFrequencyUpdate}
+ frequencyType={frequencyType}
+ onFrequencyTypeUpdate={onFrequencyTypeUpdate}
+ frequencyMetadata={frequencyMetadata || {}}
+ onFrequencyMetadataUpdate={onFrequencyMetadataUpdate}
+ things={allUserThings}
+ />
+ </Grid>
+ </>
+ ))}
+ <FormControl error={Boolean(frequencyError)}>
+ <FormHelperText error>{frequencyError}</FormHelperText>
+ </FormControl>
+ </Card>
+ </>
+ )}
+ <FormControl sx={{ mt: 1 }}>
+ <Checkbox
+ onChange={e => {
+ onFrequencyTypeUpdate(e.target.checked ? 'trigger' : 'once')
+ // if unchecked, set selectedThing to null:
+ if (!e.target.checked) {
+ onTriggerUpdate(null)
+ }
+ }}
+ defaultChecked={frequencyType === 'trigger'}
+ checked={frequencyType === 'trigger'}
+ value={frequencyType === 'trigger'}
+ disabled={!isPlusAccount(userProfile)}
+ overlay
+ label='Trigger this task based on a thing state'
+ />
+ <FormHelperText
+ sx={{
+ opacity: !isPlusAccount(userProfile) ? 0.5 : 1,
+ }}
+ >
+ Is this something that should be done when a thing state changes?{' '}
+ {!isPlusAccount(userProfile) && (
+ <Chip variant='soft' color='warning'>
+ Not available in Basic Plan
+ </Chip>
+ )}
+ </FormHelperText>
+ </FormControl>
+ {frequencyType === 'trigger' && (
+ <ThingTriggerSection
+ things={allUserThings}
+ onTriggerUpdate={onTriggerUpdate}
+ onValidate={OnTriggerValidate}
+ isAttemptToSave={isAttemptToSave}
+ selected={selectedThing}
+ />
+ )}
+ </Box>
+ )
+}
+
+export default RepeatSection
diff --git a/src/views/ChoreEdit/ThingTriggerSection.jsx b/src/views/ChoreEdit/ThingTriggerSection.jsx
new file mode 100644
index 0000000..7a040ad
--- /dev/null
+++ b/src/views/ChoreEdit/ThingTriggerSection.jsx
@@ -0,0 +1,230 @@
+import { Widgets } from '@mui/icons-material'
+import {
+ Autocomplete,
+ Box,
+ Button,
+ Card,
+ Chip,
+ FormControl,
+ FormLabel,
+ Input,
+ ListItem,
+ ListItemContent,
+ ListItemDecorator,
+ Option,
+ Select,
+ TextField,
+ Typography,
+} from '@mui/joy'
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+const isValidTrigger = (thing, condition, triggerState) => {
+ const newErrors = {}
+ if (!thing || !triggerState) {
+ newErrors.thing = 'Please select a thing and trigger state'
+ return false
+ }
+ if (thing.type === 'boolean') {
+ if (['true', 'false'].includes(triggerState)) {
+ return true
+ } else {
+ newErrors.type = 'Boolean type does not require a condition'
+ return false
+ }
+ }
+ if (thing.type === 'number') {
+ if (isNaN(triggerState)) {
+ newErrors.triggerState = 'Trigger state must be a number'
+ return false
+ }
+ if (['eq', 'neq', 'gt', 'gte', 'lt', 'lte'].includes(condition)) {
+ return true
+ }
+ }
+ if (thing.type === 'text') {
+ if (typeof triggerState === 'string') {
+ return true
+ }
+ }
+ newErrors.triggerState = 'Trigger state must be a number'
+
+ return false
+}
+
+const ThingTriggerSection = ({
+ things,
+ onTriggerUpdate,
+ onValidate,
+ selected,
+ isAttepmtingToSave,
+}) => {
+ const [selectedThing, setSelectedThing] = useState(null)
+ const [condition, setCondition] = useState(null)
+ const [triggerState, setTriggerState] = useState(null)
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (selected) {
+ setSelectedThing(things?.find(t => t.id === selected.thingId))
+ setCondition(selected.condition)
+ setTriggerState(selected.triggerState)
+ }
+ }, [things])
+
+ useEffect(() => {
+ if (selectedThing && triggerState) {
+ onTriggerUpdate({
+ thing: selectedThing,
+ condition: condition,
+ triggerState: triggerState,
+ })
+ }
+ if (isValidTrigger(selectedThing, condition, triggerState)) {
+ onValidate(true)
+ } else {
+ onValidate(false)
+ }
+ }, [selectedThing, condition, triggerState])
+
+ return (
+ <Card sx={{ mt: 1 }}>
+ <Typography level='h5'>
+ Trigger a task when a thing state changes to a desired state
+ </Typography>
+ {things.length !== 0 && (
+ <Typography level='body-sm'>
+ it's look like you don't have any things yet, create a thing to
+ trigger a task when the state changes.
+ <Button
+ startDecorator={<Widgets />}
+ size='sm'
+ onClick={() => {
+ navigate('/things')
+ }}
+ >
+ Go to Things
+ </Button>{' '}
+ to create a thing
+ </Typography>
+ )}
+ <FormControl error={isAttepmtingToSave && !selectedThing}>
+ <Autocomplete
+ options={things}
+ value={selectedThing}
+ onChange={(e, newValue) => setSelectedThing(newValue)}
+ getOptionLabel={option => option.name}
+ renderOption={(props, option) => (
+ <ListItem {...props}>
+ <Box
+ sx={{
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ p: 1,
+ }}
+ >
+ <ListItemDecorator sx={{ alignSelf: 'flex-start' }}>
+ <Typography level='body-lg' textColor='primary'>
+ {option.name}
+ </Typography>
+ </ListItemDecorator>
+ <ListItemContent>
+ <Typography level='body2' textColor='text.secondary'>
+ <Chip>type: {option.type}</Chip>{' '}
+ <Chip>state: {option.state}</Chip>
+ </Typography>
+ </ListItemContent>
+ </Box>
+ </ListItem>
+ )}
+ renderInput={params => (
+ <TextField {...params} label='Select a thing' />
+ )}
+ />
+ </FormControl>
+ <Typography level='body-sm'>
+ Create a condition to trigger a task when the thing state changes to
+ desired state
+ </Typography>
+ {selectedThing?.type == 'boolean' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+ <Select
+ value={triggerState}
+ onChange={e => {
+ if (e?.target.value === 'true' || e?.target.value === 'false')
+ setTriggerState(e.target.value)
+ else setTriggerState('false')
+ }}
+ >
+ {['true', 'false'].map(state => (
+ <Option
+ key={state}
+ value={state}
+ onClick={() => setTriggerState(state)}
+ >
+ {state.charAt(0).toUpperCase() + state.slice(1)}
+ </Option>
+ ))}
+ </Select>
+ </Box>
+ )}
+ {selectedThing?.type == 'number' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+
+ <Box sx={{ display: 'flex', gap: 1, direction: 'row' }}>
+ <Typography level='body-sm'>State is</Typography>
+ <Select value={condition} sx={{ width: '50%' }}>
+ {[
+ { name: 'Equal', value: 'eq' },
+ { name: 'Not equal', value: 'neq' },
+ { name: 'Greater than', value: 'gt' },
+ { name: 'Greater than or equal', value: 'gte' },
+ { name: 'Less than', value: 'lt' },
+ { name: 'Less than or equal', value: 'lte' },
+ ].map(condition => (
+ <Option
+ key={condition.value}
+ value={condition.value}
+ onClick={() => setCondition(condition.value)}
+ >
+ {condition.name}
+ </Option>
+ ))}
+ </Select>
+ <Input
+ type='number'
+ value={triggerState}
+ onChange={e => setTriggerState(e.target.value)}
+ sx={{ width: '50%' }}
+ />
+ </Box>
+ </Box>
+ )}
+ {selectedThing?.type == 'text' && (
+ <Box>
+ <Typography level='body-sm'>
+ When the state of {selectedThing.name} changes as specified below,
+ the task will become due.
+ </Typography>
+
+ <Input
+ value={triggerState}
+ onChange={e => setTriggerState(e.target.value)}
+ label='Enter the text to trigger the task'
+ />
+ </Box>
+ )}
+ </Card>
+ )
+}
+
+export default ThingTriggerSection