From 13bfe66c9fc2c14c3046d81df676ab9050749037 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Thu, 2 Jan 2025 23:10:36 -0500 Subject: [PATCH 01/11] chore: Update Fetcher and TokenManager to use API v1 --- src/utils/Fetcher.jsx | 10 +++++----- src/utils/TokenManager.jsx | 4 ++-- src/views/Chores/ChoreCard.jsx | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/utils/Fetcher.jsx b/src/utils/Fetcher.jsx index 9fedd62..c5a80d5 100644 --- a/src/utils/Fetcher.jsx +++ b/src/utils/Fetcher.jsx @@ -22,7 +22,8 @@ const signUp = (username, password, displayName, email) => { } const UpdatePassword = newPassword => { - return fetch(`/users/change_password`, { + const baseURL = apiManager.getApiURL() + return fetch(`${baseURL}/users/change_password`, { method: 'PUT', headers: HEADERS(), body: JSON.stringify({ password: newPassword }), @@ -403,21 +404,20 @@ const ResetPassword = email => { } const UpdateDueDate = (id, dueDate) => { - return Fetch(`/chores/${chore.id}/dueDate`, { + return Fetch(`/chores/${id}/dueDate`, { method: 'PUT', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - dueDate: newDate ? new Date(newDate).toISOString() : null, - UpdatedBy: activeUserId, + dueDate: dueDate ? new Date(dueDate).toISOString() : null, }), }) } const RefreshToken = () => { const basedURL = apiManager.getApiURL() - return fetch(basedURL + '/auth/refresh', { + return fetch(`${basedURL}/auth/refresh`, { method: 'GET', headers: HEADERS(), }) diff --git a/src/utils/TokenManager.jsx b/src/utils/TokenManager.jsx index aba09cc..d33467c 100644 --- a/src/utils/TokenManager.jsx +++ b/src/utils/TokenManager.jsx @@ -6,7 +6,7 @@ import { Preferences } from '@capacitor/preferences' class ApiManager { constructor() { - this.customServerURL = API_URL + this.customServerURL = `${API_URL}/api/v1` this.initialized = false } async init() { @@ -17,7 +17,7 @@ class ApiManager { key: 'customServerUrl', }) - this.customServerURL = serverURL || API_URL + this.customServerURL = `${serverURL || API_URL}/api/v1` this.initialized = true } getApiURL() { diff --git a/src/views/Chores/ChoreCard.jsx b/src/views/Chores/ChoreCard.jsx index bbf9503..a41ba69 100644 --- a/src/views/Chores/ChoreCard.jsx +++ b/src/views/Chores/ChoreCard.jsx @@ -204,7 +204,7 @@ const ChoreCard = ({ alert('Please select a performer') return } - UpdateDueDate.then(response => { + UpdateDueDate(chore.id, newDate).then(response => { if (response.ok) { response.json().then(data => { const newChore = data.res From 46f7f51d3aebd7a2fe15932172dbf9568ededaf9 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 10 Jan 2025 00:38:13 -0500 Subject: [PATCH 02/11] Fix the URl for google auth --- src/views/Authorization/LoginView.jsx | 148 +++++++++++++------------- 1 file changed, 76 insertions(+), 72 deletions(-) diff --git a/src/views/Authorization/LoginView.jsx b/src/views/Authorization/LoginView.jsx index b75581f..6a3eb2d 100644 --- a/src/views/Authorization/LoginView.jsx +++ b/src/views/Authorization/LoginView.jsx @@ -1,3 +1,6 @@ +import { Capacitor } from '@capacitor/core' +import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth' +import { Settings } from '@mui/icons-material' import GoogleIcon from '@mui/icons-material/Google' import { Avatar, @@ -15,15 +18,11 @@ import Cookies from 'js-cookie' import React from 'react' import { useNavigate } from 'react-router-dom' import { LoginSocialGoogle } from 'reactjs-social-login' -import { API_URL, GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config' +import { GOOGLE_CLIENT_ID, REDIRECT_URL } from '../../Config' import { UserContext } from '../../contexts/UserContext' import Logo from '../../Logo' import { GetUserProfile, login } from '../../utils/Fetcher' -import { GoogleAuth } from '@codetrix-studio/capacitor-google-auth'; -import { Capacitor } from '@capacitor/core' -import { Settings } from '@mui/icons-material' - - +import { apiManager } from '../../utils/TokenManager' const LoginView = () => { const { userProfile, setUserProfile } = React.useContext(UserContext) @@ -34,7 +33,6 @@ const LoginView = () => { const handleSubmit = async e => { e.preventDefault() login(username, password) - .then(response => { if (response.status === 200) { return response.json().then(data => { @@ -63,7 +61,8 @@ const LoginView = () => { } const loggedWithProvider = function (provider, data) { - return fetch(API_URL + `/auth/${provider}/callback`, { + const baseURL = apiManager.getApiURL() + return fetch(`${baseURL}/auth/${provider}/callback`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -141,20 +140,21 @@ const LoginView = () => { boxShadow: 'md', }} > - { - Navigate('/login/settings') - - } - } - > + { + Navigate('/login/settings') + }} + > + {' '} + + {/* logo { )} or {!Capacitor.isNativePlatform() && ( - - { - loggedWithProvider(provider, data) - }} - onReject={err => { - setError("Couldn't log in with Google, please try again") - }} - > + + { + loggedWithProvider(provider, data) + }} + onReject={err => { + setError("Couldn't log in with Google, please try again") + }} + > + + + + )} + + {Capacitor.isNativePlatform() && ( + - - )} - - {Capacitor.isNativePlatform() && ( - - - + )} - + )} { + { + setIsRedeemModalOpen(false) + }, + isOpen: isRedeemModalOpen, + available: circleUsers.find(user => user.userId === selectedUser) + ?.points, + user: circleUsers.find(user => user.userId === selectedUser), + onSave: ({ userId, points }) => { + RedeemPoints(userId, points, userProfile.circleID) + .then(res => { + setIsRedeemModalOpen(false) + handleCircleMembersRefetch() + }) + .catch(err => { + console.log(err) + }) + }, + }} + /> ) } From 0d0855bbf9367e276f65ce54bd8d5ae24a9d2514 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 14 Jan 2025 02:03:56 -0500 Subject: [PATCH 05/11] Add RedeemPointsModal component for redeeming points --- src/views/Modals/RedeemPointsModal.jsx | 89 ++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/views/Modals/RedeemPointsModal.jsx diff --git a/src/views/Modals/RedeemPointsModal.jsx b/src/views/Modals/RedeemPointsModal.jsx new file mode 100644 index 0000000..c9db547 --- /dev/null +++ b/src/views/Modals/RedeemPointsModal.jsx @@ -0,0 +1,89 @@ +import { + Box, + Button, + FormLabel, + IconButton, + Input, + Modal, + ModalDialog, + Typography, +} from '@mui/joy' +import { useEffect, useState } from 'react' + +function RedeemPointsModal({ config }) { + useEffect(() => { + setPoints(0) + }, [config]) + + const [points, setPoints] = useState(0) + + const predefinedPoints = [1, 5, 10, 25] + + return ( + + + + Redeem Points + + + Points to Redeem ({config.available ? config.available : 0} points + available) + + { + if (e.target.value > config.available) { + setPoints(config.available) + return + } + setPoints(e.target.value) + }} + /> + Or select from predefined points: + + {predefinedPoints.map(point => ( + { + const newPoints = points + point + if (newPoints > config.available) { + setPoints(config.available) + return + } + setPoints(newPoints) + }} + > + {point} + + ))} + + + {/* 3 button save , cancel and delete */} + + + + + + + ) +} +export default RedeemPointsModal From 4a9f2263f21f897371c3b3279baa3e42eff053d8 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 14 Jan 2025 02:06:40 -0500 Subject: [PATCH 06/11] Add Sidepanel component for displaying task and calendar view --- src/queries/UserQueries.jsx | 14 +- src/views/Chores/Sidepanel.jsx | 91 ++++++++++ src/views/components/Calendar.css | 79 +++++++++ src/views/components/CalendarView.jsx | 176 +++++++++++++++++++ src/views/components/IconButtonTouchable.jsx | 34 ++++ 5 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 src/views/Chores/Sidepanel.jsx create mode 100644 src/views/components/Calendar.css create mode 100644 src/views/components/CalendarView.jsx create mode 100644 src/views/components/IconButtonTouchable.jsx diff --git a/src/queries/UserQueries.jsx b/src/queries/UserQueries.jsx index 30dd203..d5f2d56 100644 --- a/src/queries/UserQueries.jsx +++ b/src/queries/UserQueries.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { useQuery } from 'react-query' import { GetAllCircleMembers, GetAllUsers } from '../utils/Fetcher' @@ -6,5 +7,16 @@ export const useAllUsers = () => { } export const useCircleMembers = () => { - return useQuery('allCircleMembers', GetAllCircleMembers) + const [refetchKey, setRefetchKey] = useState(0) + + const { data, error, isLoading, refetch } = useQuery( + ['allCircleMembers', refetchKey], + GetAllCircleMembers, + ) + const handleRefetch = () => { + setRefetchKey(prevKey => prevKey + 1) + refetch() + } + + return { data, error, isLoading, handleRefetch } } diff --git a/src/views/Chores/Sidepanel.jsx b/src/views/Chores/Sidepanel.jsx new file mode 100644 index 0000000..e5947c0 --- /dev/null +++ b/src/views/Chores/Sidepanel.jsx @@ -0,0 +1,91 @@ +import { Box, Sheet } from '@mui/joy' +import { useMediaQuery } from '@mui/material' +import { useEffect, useState } from 'react' +import { ChoresGrouper } from '../../utils/Chores' +import CalendarView from '../components/CalendarView' + +const Sidepanel = ({ chores }) => { + const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md')) + const [dueDatePieChartData, setDueDatePieChartData] = useState([]) + + useEffect(() => { + setDueDatePieChartData(generateChoreDuePieChartData(chores)) + }, []) + + const generateChoreDuePieChartData = chores => { + const groups = ChoresGrouper('due_date', chores) + return groups + .map(group => { + return { + label: group.name, + value: group.content.length, + color: group.color, + id: group.name, + } + }) + .filter(item => item.value > 0) + } + + if (!isLargeScreen) { + return null + } + return ( + + {/* + + + {dueDatePieChartData.map((entry, index) => ( + + ))} + + + `${label}: ${value.payload.value}`} + wrapperStyle={{ paddingTop: 0, marginTop: 0 }} // Adjust padding and margin + /> + + + */} + + + + + ) +} + +export default Sidepanel diff --git a/src/views/components/Calendar.css b/src/views/components/Calendar.css new file mode 100644 index 0000000..02c3661 --- /dev/null +++ b/src/views/components/Calendar.css @@ -0,0 +1,79 @@ +.react-calendar { + width: 100%; + max-width: 600px; + font-family: 'Roboto', sans-serif; + background-color: transparent; +} + +.react-calendar__tile { + padding: 1em; + min-height: 3rem; + text-align: center; + border-radius: 8px; + transition: background-color 0.3s ease; +} +.react-calendar__tile:enabled:hover { + /* lighten existing color: ; */ + background-color: rgba(0, 123, 255, 0.2); +} + +.react-calendar__tile--active { + background-color: #007bff !important; +} + +/* Today's tile styles */ +.react-calendar .react-calendar__tile--now { + /* More specific selector */ + /* background-color: #4ec1a2e3; */ + border: 2px dotted #4ec1a2e3; + border-radius: 8px; /* Consistent with other tiles */ + background-color: inherit; +} + +.react-calendar .react-calendar__tile--now:enabled:hover { + background-color: rgba(0, 123, 255, 0.1); +} + +/* Ensure dot container doesn't break row height */ +.dot-container { + display: flex; + justify-content: center; + align-items: center; + height: 16px; + /* if more than 4 dot make them go to the new line: */ + flex-wrap: wrap; +} + +.dot { + width: 0.3em; + height: 0.3em; + border-radius: 50%; + margin: 0 1px; +} + +/* Tooltip styles */ +.dot-container { + position: relative; +} + +.chore-tooltip { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + background-color: #333; + color: white; + padding: 5px 10px; + border-radius: 4px; + z-index: 10; + visibility: hidden; + opacity: 0; + transition: + opacity 0.2s ease, + visibility 0.2s ease; +} + +/* remove different color on weekend: */ +.react-calendar__month-view__days__day--weekend { + color: inherit; +} diff --git a/src/views/components/CalendarView.jsx b/src/views/components/CalendarView.jsx new file mode 100644 index 0000000..43e44b8 --- /dev/null +++ b/src/views/components/CalendarView.jsx @@ -0,0 +1,176 @@ +import { Box, Card, CardContent, Chip, Grid, Typography } from '@mui/joy' +import moment from 'moment' +import React, { useState } from 'react' +import Calendar from 'react-calendar' +import 'react-calendar/dist/Calendar.css' +import { useNavigate } from 'react-router-dom' +import { UserContext } from '../../contexts/UserContext' +import { getTextColorFromBackgroundColor, TASK_COLOR } from '../../utils/Colors' +import './Calendar.css' + +const getAssigneeColor = (assignee, userProfile) => { + return assignee === userProfile.id + ? TASK_COLOR.ASSIGNED_TO_ME + : TASK_COLOR.ASSIGNED_TO_OTHER +} + +const CalendarView = ({ chores }) => { + const { userProfile } = React.useContext(UserContext) + const [selectedDate, setSeletedDate] = useState(null) + const Navigate = useNavigate() + + const tileContent = ({ date, view }) => { + if (view === 'month') { + const dayChores = chores.filter( + chore => + new Date(chore.nextDueDate)?.toISOString().split('T')[0] === + date.toISOString().split('T')[0], + ) + + return ( +
+ {dayChores.map((chore, index) => { + if (index > 6) { + return null + } + + return ( + + ) + })} +
+ ) + } + return null + } + + return ( +
+ { + setSeletedDate(new Date(d)) + }} + /> + {!selectedDate && ( + + {[ + { name: 'Assigned to me', color: TASK_COLOR.ASSIGNED_TO_ME }, + { name: 'Assigned to other', color: TASK_COLOR.ASSIGNED_TO_OTHER }, + ].map((item, index) => ( + + + + {item.name} + + + ))} + + )} + {selectedDate && ( + + {chores + .filter( + chore => + new Date(chore.nextDueDate)?.toISOString().split('T')[0] === + selectedDate.toISOString().split('T')[0], + ) + .map((chore, idx) => ( + { + Navigate('/chores/' + chore.id) + }} + sx={{ + mb: 0.4, + py: 1, + px: 1, + + // backgroundColor: getAssigneeColor( + // chore.assignedTo, + // userProfile, + // ), + // everything show in one row: + }} + > + + + + {moment(chore.nextDueDate).format('hh:mm A')} + + {chore.name} + + + + ))} + + )} +
+ ) +} + +export default CalendarView diff --git a/src/views/components/IconButtonTouchable.jsx b/src/views/components/IconButtonTouchable.jsx new file mode 100644 index 0000000..c8cc4e5 --- /dev/null +++ b/src/views/components/IconButtonTouchable.jsx @@ -0,0 +1,34 @@ +import IconButton from '@mui/joy/IconButton' +import React, { useRef, useState } from 'react' + +const IconButtonTouchable = ({ onHold, onClick, ...props }) => { + const [holdTimeout, setHoldTimeout] = useState(null) + const holdRef = useRef(false) + + const handleMouseDown = () => { + holdRef.current = false + setHoldTimeout( + setTimeout(() => { + holdRef.current = true + onHold && onHold() + }, 1000), + ) + } + + const handleMouseUp = () => { + clearTimeout(holdTimeout) + if (!holdRef.current) { + onClick && onClick() + } + } + + return ( + + ) +} + +export default IconButtonTouchable From 27979ce869fe91fefb58a6d24b6c6dff1824f3d6 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 14 Jan 2025 02:07:08 -0500 Subject: [PATCH 07/11] Update ChoreEdit component to use 'Title' instead of 'Description' --- src/views/ChoreEdit/ChoreEdit.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index ec5625f..e3eec30 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -378,7 +378,7 @@ const ChoreEdit = () => { */} - Description : + Title : What is this chore about? setName(e.target.value)} /> {errors.name} From 62696002e63ea567ec6bf3773a7d1bf1808a6c55 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Tue, 14 Jan 2025 02:12:27 -0500 Subject: [PATCH 08/11] Add Task Modal for Task in a sentence --- src/App.jsx | 11 +- src/utils/Fetcher.jsx | 18 + src/views/Chores/MyChores.jsx | 854 ++++++++++++++------------ src/views/components/AddTaskModal.jsx | 532 ++++++++++++++++ 4 files changed, 1027 insertions(+), 388 deletions(-) create mode 100644 src/views/components/AddTaskModal.jsx 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: +