diff --git a/src/App.jsx b/src/App.jsx index 0a3197f..67d58ff 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,12 +5,11 @@ import { useEffect, useState } from 'react' import { QueryClient, QueryClientProvider } from 'react-query' import { Outlet } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' +import { registerCapacitorListeners } from './CapacitorListener' import { UserContext } from './contexts/UserContext' import { AuthenticationProvider } from './service/AuthenticationService' import { GetUserProfile } from './utils/Fetcher' -import { isTokenValid } from './utils/TokenManager' -import { registerCapacitorListeners } from './CapacitorListener' -import {apiManager} from './utils/TokenManager' +import { apiManager, isTokenValid } from './utils/TokenManager' const add = className => { document.getElementById('root').classList.add(className) @@ -22,9 +21,7 @@ const remove = className => { // TODO: Update the interval to at 60 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes - function App() { - startApiManager() startOpenReplay() const queryClient = new QueryClient() @@ -138,5 +135,5 @@ const startOpenReplay = () => { export default App const startApiManager = () => { - apiManager.init(); -} \ No newline at end of file + apiManager.init() +} diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index c5a80d5..e98e60a 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -195,6 +195,14 @@ const UpdateChoreHistory = (choreId, id, choreHistory) => { }) } +const UpdateChoreStatus = (choreId, status) => { + return Fetch(`/chores/${choreId}/status`, { + method: 'PUT', + headers: HEADERS(), + body: JSON.stringify({ status }), + }) +} + const GetAllCircleMembers = async () => { const resp = await Fetch(`/circles/members`, { method: 'GET', @@ -415,6 +423,14 @@ const UpdateDueDate = (id, dueDate) => { }) } +const RedeemPoints = (userId, points, circleID) => { + return Fetch(`/circles/${circleID}/members/points/redeem`, { + method: 'POST', + headers: HEADERS(), + body: JSON.stringify({ points, userId }), + }) +} + const RefreshToken = () => { const basedURL = apiManager.getApiURL() return fetch(`${basedURL}/auth/refresh`, { @@ -474,6 +490,7 @@ export { LeaveCircle, MarkChoreComplete, PutNotificationTarget, + RedeemPoints, RefreshToken, ResetPassword, SaveChore, @@ -483,6 +500,7 @@ export { UpdateChoreAssignee, UpdateChoreHistory, UpdateChorePriority, + UpdateChoreStatus, UpdateDueDate, UpdateLabel, UpdateNotificationTarget, diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index e5ced06..aa39e8c 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -5,6 +5,7 @@ import { ExpandCircleDown, FilterAlt, PriorityHigh, + Search, Sort, Style, Unarchive, @@ -44,11 +45,13 @@ import ChoreCard from './ChoreCard' import IconButtonWithMenu from './IconButtonWithMenu' import { ChoresGrouper } from '../../utils/Chores' +import TaskInput from '../components/AddTaskModal' import { canScheduleNotification, scheduleChoreNotification, } from './LocalNotificationScheduler' import NotificationAccessSnackbar from './NotificationAccessSnackbar' +import Sidepanel from './Sidepanel' const MyChores = () => { const { userProfile, setUserProfile } = useContext(UserContext) @@ -59,6 +62,10 @@ const MyChores = () => { const [filteredChores, setFilteredChores] = useState([]) const [selectedFilter, setSelectedFilter] = useState('All') const [choreSections, setChoreSections] = useState([]) + const [activeTextField, setActiveTextField] = useState('task') + const [taskInputFocus, setTaskInputFocus] = useState(0) + const searchInputRef = useRef() + const [searchInputFocus, setSearchInputFocus] = useState(0) const [selectedChoreSection, setSelectedChoreSection] = useState('due_date') const [openChoreSections, setOpenChoreSections] = useState({}) const [searchTerm, setSearchTerm] = useState('') @@ -169,6 +176,22 @@ const MyChores = () => { } }, [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]) + const updateChores = newChore => { + const newChores = chores + newChores.push(newChore) + setChores(newChores) + setFilteredChores(newChores) + setChoreSections(ChoresGrouper('due_date', newChores)) + setSelectedFilter('All') + } const handleMenuOutsideClick = event => { if ( anchorEl && @@ -315,408 +338,477 @@ const MyChores = () => { } return ( - - - { - setSearchTerm('') - setFilteredChores(chores) - }} - /> - ) - } - /> - } - title='Filter by Priority' - options={Priorities} - selectedItem={selectedFilter} - onItemSelect={selected => { - handleLabelFiltering({ priority: selected.value }) - }} - mouseClickHandler={handleMenuOutsideClick} - isActive={selectedFilter.startsWith('Priority: ')} - /> - } - // TODO : this need simplification we want to display both user labels and chore labels - // that why we are merging them here. - // we also filter out the labels that user created as those will be part of user labels - title='Filter by Label' - options={[ - ...userLabels, - ...chores - .map(c => c.labelsV2) - .flat() - .filter(l => l.created_by !== userProfile.id) - .map(l => { - // if user created it don't show it: - return { - ...l, - name: l.name + ' (Shared Label)', - } - }), - ]} - selectedItem={selectedFilter} - onItemSelect={selected => { - handleLabelFiltering({ label: selected }) - }} - isActive={selectedFilter.startsWith('Label: ')} - mouseClickHandler={handleMenuOutsideClick} - useChips - /> - - - - - - - {Object.keys(FILTERS).map((filter, index) => ( - { - const filterFunction = FILTERS[filter] - const filteredChores = - filterFunction.length === 2 - ? filterFunction(chores, userProfile.id) - : filterFunction(chores) - setFilteredChores(filteredChores) - setSelectedFilter(filter) - handleFilterMenuClose() - }} - > - {filter} - - {FILTERS[filter].length === 2 - ? FILTERS[filter](chores, userProfile.id).length - : FILTERS[filter](chores).length} - - - ))} - {selectedFilter.startsWith('Label: ') || - (selectedFilter.startsWith('Priority: ') && ( - { - setFilteredChores(chores) - setSelectedFilter('All') - }} - > - Cancel All Filters - - ))} - - - - } - options={[ - { name: 'Due Date', value: 'due_date' }, - { name: 'Priority', value: 'priority' }, - { name: 'Labels', value: 'labels' }, - ]} - selectedItem={selectedChoreSection} - onItemSelect={selected => { - const section = ChoresGrouper(selected.value, chores) - setChoreSections(section) - setSelectedChoreSection(selected.value) - setFilteredChores(chores) - setSelectedFilter('All') - }} - mouseClickHandler={handleMenuOutsideClick} - /> - - {selectedFilter !== 'All' && ( - { - setFilteredChores(chores) - setSelectedFilter('All') - }} - endDecorator={} - onClick={() => { - setFilteredChores(chores) - setSelectedFilter('All') - }} - > - Current Filter: {selectedFilter} - - )} - {filteredChores.length === 0 && ( +
+ - + )} + + {activeTextField == 'search' && ( + 0} + placeholder='Search' + value={searchTerm} + fullWidth + sx={{ + mt: 1, + mb: 1, + borderRadius: 24, + height: 24, + borderColor: 'text.disabled', + padding: 1, + }} + onChange={handleSearchChange} + endDecorator={ + searchTerm && ( + { + setSearchTerm('') + setFilteredChores(chores) + }} + /> + ) + } + /> + )} + {activeTextField != 'task' && ( + + )} + {activeTextField != 'search' && ( + + )} + + } + title='Filter by Priority' + options={Priorities} + selectedItem={selectedFilter} + onItemSelect={selected => { + handleLabelFiltering({ priority: selected.value }) }} + mouseClickHandler={handleMenuOutsideClick} + isActive={selectedFilter.startsWith('Priority: ')} /> - - Nothing scheduled - - {chores.length > 0 && ( - <> + } + // TODO : this need simplification we want to display both user labels and chore labels + // that why we are merging them here. + // we also filter out the labels that user created as those will be part of user labels + title='Filter by Label' + options={[ + ...userLabels, + ...chores + .map(c => c.labelsV2) + .flat() + .filter(l => l.created_by !== userProfile.id) + .map(l => { + // if user created it don't show it: + return { + ...l, + name: l.name + ' (Shared Label)', + } + }), + ]} + selectedItem={selectedFilter} + onItemSelect={selected => { + handleLabelFiltering({ label: selected }) + }} + isActive={selectedFilter.startsWith('Label: ')} + mouseClickHandler={handleMenuOutsideClick} + useChips + /> + + + + + + {Object.keys(FILTERS).map((filter, index) => ( + { + const filterFunction = FILTERS[filter] + const filteredChores = + filterFunction.length === 2 + ? filterFunction(chores, userProfile.id) + : filterFunction(chores) + setFilteredChores(filteredChores) + setSelectedFilter(filter) + handleFilterMenuClose() + }} + > + {filter} + + {FILTERS[filter].length === 2 + ? FILTERS[filter](chores, userProfile.id).length + : FILTERS[filter](chores).length} + + + ))} + {selectedFilter.startsWith('Label: ') || + (selectedFilter.startsWith('Priority: ') && ( + { + setFilteredChores(chores) + setSelectedFilter('All') + }} + > + Cancel All Filters + + ))} + + + + } + options={[ + { name: 'Due Date', value: 'due_date' }, + { name: 'Priority', value: 'priority' }, + { name: 'Labels', value: 'labels' }, + ]} + selectedItem={selectedChoreSection} + onItemSelect={selected => { + const section = ChoresGrouper(selected.value, chores) + setChoreSections(section) + setSelectedChoreSection(selected.value) + setFilteredChores(chores) + setSelectedFilter('All') + }} + mouseClickHandler={handleMenuOutsideClick} + /> + + {selectedFilter !== 'All' && ( + { + setFilteredChores(chores) + setSelectedFilter('All') + }} + endDecorator={} + onClick={() => { + setFilteredChores(chores) + setSelectedFilter('All') + }} + > + Current Filter: {selectedFilter} + + )} + {filteredChores.length === 0 && ( + + + + Nothing scheduled + + {chores.length > 0 && ( + <> + + + )} + + )} + {(searchTerm?.length > 0 || selectedFilter !== 'All') && + filteredChores.map(chore => ( + + ))} + {searchTerm.length === 0 && selectedFilter === 'All' && ( + + {choreSections.map((section, index) => { + if (section.content.length === 0) return null + return ( + + + { + if (openChoreSections[index]) { + const newOpenChoreSections = { + ...openChoreSections, + } + delete newOpenChoreSections[index] + setOpenChoreSections(newOpenChoreSections) + } else { + setOpenChoreSections({ + ...openChoreSections, + [index]: true, + }) + } + }} + endDecorator={ + openChoreSections[index] ? ( + + ) : ( + + ) + } + startDecorator={ + <> + + {section?.content?.length} + + + } + > + {section.name} + + + + {section.content?.map(chore => ( + + ))} + + + ) + })} + + )} + + {archivedChores === null && ( + + + )} + {archivedChores !== null && ( + <> + + + + {archivedChores?.length} + + + } + > + Archived + + + + {archivedChores?.map(chore => ( + + ))} )} - )} - {(searchTerm?.length > 0 || selectedFilter !== 'All') && - filteredChores.map(chore => ( - - ))} - {searchTerm.length === 0 && selectedFilter === 'All' && ( - - {choreSections.map((section, index) => { - if (section.content.length === 0) return null - return ( - - - { - if (openChoreSections[index]) { - const newOpenChoreSections = { ...openChoreSections } - delete newOpenChoreSections[index] - setOpenChoreSections(newOpenChoreSections) - } else { - setOpenChoreSections({ - ...openChoreSections, - [index]: true, - }) - } - }} - endDecorator={ - openChoreSections[index] ? ( - - ) : ( - - ) - } - startDecorator={ - <> - - {section?.content?.length} - - - } - > - {section.name} - - - - {section.content?.map(chore => ( - - ))} - - - ) - })} - - )} - - {archivedChores === null && ( - - - - )} - {archivedChores !== null && ( - <> - - - - {archivedChores?.length} - - - } - > - Archived - - - - {archivedChores?.map(chore => ( - - ))} - - )} - - - { - Navigate(`/chores/create`) + position: 'fixed', + bottom: 0, + left: 10, + p: 2, // padding + display: 'flex', + justifyContent: 'flex-end', + gap: 2, + 'z-index': 1000, }} > - - - - { - setIsSnackbarOpen(false) - }} - autoHideDuration={3000} - variant='soft' - color='success' - size='lg' - invertedColors - > - {snackBarMessage} - - - + { + Navigate(`/chores/create`) + }} + > + + + + { + setIsSnackbarOpen(false) + }} + autoHideDuration={3000} + variant='soft' + color='success' + size='lg' + invertedColors + > + {snackBarMessage} + + + + + +
) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx new file mode 100644 index 0000000..5859ed7 --- /dev/null +++ b/src/views/components/AddTaskModal.jsx @@ -0,0 +1,532 @@ +import { KeyboardReturnOutlined, OpenInFull } from '@mui/icons-material' +import { + Box, + Button, + Chip, + IconButton, + Input, + Modal, + ModalDialog, + Option, + Select, + Textarea, + Typography, +} from '@mui/joy' +import { FormControl } from '@mui/material' +import * as chrono from 'chrono-node' +import moment from 'moment' +import { useContext, useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { CSSTransition } from 'react-transition-group' +import { UserContext } from '../../contexts/UserContext' +import { CreateChore } from '../../utils/Fetcher' +const VALID_DAYS = { + monday: 'Monday', + mon: 'Monday', + tuesday: 'Tuesday', + tue: 'Tuesday', + wednesday: 'Wednesday', + wed: 'Wednesday', + thursday: 'Thursday', + thu: 'Thursday', + friday: 'Friday', + fri: 'Friday', + saturday: 'Saturday', + sat: 'Saturday', + sunday: 'Sunday', + sun: 'Sunday', +} + +const VALID_MONTHS = { + january: 'January', + jan: 'January', + february: 'February', + feb: 'February', + march: 'March', + mar: 'March', + april: 'April', + apr: 'April', + may: 'May', + june: 'June', + jun: 'June', + july: 'July', + jul: 'July', + august: 'August', + aug: 'August', + september: 'September', + sep: 'September', + october: 'October', + oct: 'October', + november: 'November', + nov: 'November', + december: 'December', + dec: 'December', +} + +const ALL_MONTHS = Object.values(VALID_MONTHS).filter( + (v, i, a) => a.indexOf(v) === i, +) + +const TaskInput = ({ autoFocus, onChoreUpdate }) => { + const { userProfile } = useContext(UserContext) + const navigate = useNavigate() + const [taskText, setTaskText] = useState('') + const [taskTitle, setTaskTitle] = useState('') + const [openModal, setOpenModal] = useState(false) + const textareaRef = useRef(null) + const mainInputRef = useRef(null) + const [priority, setPriority] = useState('0') + const [dueDate, setDueDate] = useState(null) + const [description, setDescription] = useState(null) + const [frequency, setFrequency] = useState(null) + const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null) + + useEffect(() => { + if (openModal && textareaRef.current) { + textareaRef.current.focus() + textareaRef.current.selectionStart = textareaRef.current.value?.length + textareaRef.current.selectionEnd = textareaRef.current.value?.length + } + }, [openModal]) + + useEffect(() => { + if (autoFocus > 0 && mainInputRef.current) { + mainInputRef.current.focus() + mainInputRef.current.selectionStart = mainInputRef.current.value?.length + mainInputRef.current.selectionEnd = mainInputRef.current.value?.length + } + }, [autoFocus]) + + const handleEnterPressed = e => { + if (e.key === 'Enter') { + createChore() + handleCloseModal() + setTaskText('') + } + } + + const handleCloseModal = () => { + setOpenModal(false) + setTaskText('') + } + + const handleSubmit = () => { + createChore() + handleCloseModal() + setTaskText('') + } + + const parsePriority = sentence => { + sentence = sentence.toLowerCase() + const priorityMap = { + 1: ['p1', 'priority 1', 'high priority', 'urgent', 'asap', 'important'], + 2: ['p2', 'priority 2', 'medium priority'], + 3: ['p3', 'priority 3', 'low priority'], + 4: ['p4', 'priority 4'], + } + + for (const [priority, terms] of Object.entries(priorityMap)) { + if (terms.some(term => sentence.includes(term))) { + return { + result: priority, + cleanedSentence: terms.reduce((s, t) => s.replace(t, ''), sentence), + } + } + } + return { result: 0, cleanedSentence: sentence } + } + + const parseRepeatV2 = sentence => { + const result = { + frequency: 1, + frequencyType: null, + frequencyMetadata: { + days: [], + months: [], + unit: null, + time: new Date().toISOString(), + }, + } + + const patterns = [ + { + frequencyType: 'day_of_the_month:every', + regex: /(\d+)(?:th|st|nd|rd)? of every month$/i, + name: 'Every {day} of every month', + }, + { + frequencyType: 'daily', + regex: /(every day|daily)$/i, + name: 'Every day', + }, + { + frequencyType: 'weekly', + regex: /(every week|weekly)$/i, + name: 'Every week', + }, + { + frequencyType: 'monthly', + regex: /(every month|monthly)$/i, + name: 'Every month', + }, + { + frequencyType: 'yearly', + regex: /every year$/i, + name: 'Every year', + }, + { + frequencyType: 'monthly', + regex: /every (?:other )?month$/i, + name: 'Bi Monthly', + value: 2, + }, + { + frequencyType: 'interval:2week', + regex: /(bi-?weekly|every other week)/i, + value: 2, + name: 'Bi Weekly', + }, + { + frequencyType: 'interval', + regex: /every (\d+) (days?|weeks?|months?|years?).*$/i, + name: 'Every {frequency} {unit}', + }, + { + frequencyType: 'interval:every_other', + regex: /every other (days?|weeks?|months?|years?)$/i, + name: 'Every other {unit}', + }, + { + frequencyType: 'days_of_the_week', + regex: /every ([\w, ]+(?:day)?(?:, [\w, ]+(?:day)?)*)$/i, + name: 'Every {days} of the week', + }, + { + frequencyType: 'day_of_the_month', + regex: /(\d+)(?:st|nd|rd|th)? of ([\w ]+(?:(?:,| and |\s)[\w ]+)*)/i, + name: 'Every {day} days of {months}', + }, + ] + + for (const pattern of patterns) { + const match = sentence.match(pattern.regex) + if (!match) continue + + result.frequencyType = pattern.frequencyType + const unitMap = { + daily: 'days', + weekly: 'weeks', + monthly: 'months', + yearly: 'years', + } + + switch (pattern.frequencyType) { + case 'daily': + case 'weekly': + case 'monthly': + case 'yearly': + result.frequencyType = 'interval' + result.frequency = pattern.value || 1 + result.frequencyMetadata.unit = unitMap[pattern.frequencyType] + return { + result, + name: pattern.name, + cleanedSentence: sentence.replace(match[0], '').trim(), + } + + case 'interval': + result.frequency = parseInt(match[1], 10) + result.frequencyMetadata.unit = match[2] + return { + result, + name: pattern.name + .replace('{frequency}', result.frequency) + .replace('{unit}', result.frequencyMetadata.unit), + cleanedSentence: sentence.replace(match[0], '').trim(), + } + + case 'days_of_the_week': + result.frequencyMetadata.days = match[1] + .toLowerCase() + .split(/ and |,|\s/) + .map(day => day.trim()) + .filter(day => VALID_DAYS[day]) + .map(day => VALID_DAYS[day]) + if (!result.frequencyMetadata.days.length) + return { result: null, name: null, cleanedSentence: sentence } + return { + result, + name: pattern.name.replace( + '{days}', + result.frequencyMetadata.days.join(', '), + ), + cleanedSentence: sentence.replace(match[0], '').trim(), + } + + case 'day_of_the_month': + result.frequency = parseInt(match[1], 10) + result.frequencyMetadata.months = match[2] + .toLowerCase() + .split(/ and |,|\s/) + .map(month => month.trim()) + .filter(month => VALID_MONTHS[month]) + .map(month => VALID_MONTHS[month]) + result.frequencyMetadata.unit = 'days' + return { + result, + name: pattern.name + .replace('{day}', result.frequency) + .replace('{months}', result.frequencyMetadata.months.join(', ')), + cleanedSentence: sentence.replace(match[0], '').trim(), + } + + case 'interval:every_other': + case 'interval:2week': + result.frequency = 2 + result.frequencyMetadata.unit = 'weeks' + result.frequencyType = 'interval' + return { + result, + name: pattern.name, + cleanedSentence: sentence.replace(match[0], '').trim(), + } + + case 'day_of_the_month:every': + result.frequency = parseInt(match[1], 10) + result.frequencyMetadata.months = ALL_MONTHS + result.frequencyMetadata.unit = 'days' + return { + result, + name: pattern.name + .replace('{day}', result.frequency) + .replace('{months}', result.frequencyMetadata.months.join(', ')), + cleanedSentence: sentence.replace(match[0], '').trim(), + } + } + } + return { result: null, name: null, cleanedSentence: sentence } + } + + const handleTextChange = e => { + if (!e.target.value) { + setTaskText('') + setOpenModal(false) + setDueDate(null) + setFrequency(null) + setFrequencyHumanReadable(null) + setPriority(0) + return + } + + let cleanedSentence = e.target.value + const priority = parsePriority(cleanedSentence) + if (priority.result) setPriority(priority.result) + cleanedSentence = priority.cleanedSentence + + const parsedDueDate = chrono.parse(cleanedSentence, new Date(), { + forwardDate: true, + }) + if (parsedDueDate[0]?.index > -1) { + setDueDate( + moment(parsedDueDate[0].start.date()).format('YYYY-MM-DDTHH:mm:ss'), + ) + cleanedSentence = cleanedSentence.replace(parsedDueDate[0].text, '') + } + + const repeat = parseRepeatV2(cleanedSentence) + if (repeat.result) { + setFrequency(repeat.result) + setFrequencyHumanReadable(repeat.name) + cleanedSentence = repeat.cleanedSentence + } + + if (priority.result || parsedDueDate[0]?.index > -1 || repeat.result) { + setOpenModal(true) + } + + setTaskText(e.target.value) + setTaskTitle(cleanedSentence.trim()) + } + + const createChore = () => { + const chore = { + name: taskTitle, + assignees: [{ userId: userProfile.id }], + dueDate: dueDate ? new Date(dueDate).toISOString() : null, + assignedTo: userProfile.id, + assignStrategy: 'random', + isRolling: false, + description: description || null, + labelsV2: [], + priority: priority || 0, + status: 0, + } + + if (frequency) { + chore.frequencyType = frequency.frequencyType + chore.frequencyMetadata = frequency.frequencyMetadata + chore.frequency = frequency.frequency + } + + CreateChore(chore).then(resp => { + resp.json().then(data => { + onChoreUpdate({ ...chore, id: data.res, nextDueDate: chore.dueDate }) + }) + }) + } + + return ( + <> + {!openModal && ( + + 0} + ref={mainInputRef} + placeholder='Add a task...' + value={taskText} + onChange={handleTextChange} + sx={{ + fontSize: '16px', + mt: 1, + mb: 1, + borderRadius: 24, + height: 24, + borderColor: 'text.disabled', + padding: 1, + width: '100%', + }} + onKeyUp={handleEnterPressed} + endDecorator={ + + + + } + /> + + )} + + + + + + Create new task + + Experimental + + + + + Task in a sentence: + + + + Title: + setTaskTitle(e.target.value)} + placeholder='Type your full text here...' + sx={{ width: '100%', fontSize: '16px' }} + /> + + + Description: +