diff --git a/package-lock.json b/package-lock.json index 7e89160..88f86ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "donetick", - "version": "0.1.83", + "version": "0.1.91", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "donetick", - "version": "0.1.83", + "version": "0.1.91", "dependencies": { "@capacitor/android": "^6.1.1", "@capacitor/app": "^6.0.0", @@ -26,6 +26,7 @@ "@openreplay/tracker": "^14.0.4", "@tanstack/react-query": "^5.17.0", "aos": "^2.3.4", + "chrono-node": "^2.7.7", "dotenv": "^16.4.5", "esm": "^3.2.25", "fuse.js": "^7.0.0", @@ -5362,6 +5363,18 @@ "node": ">=10" } }, + "node_modules/chrono-node": { + "version": "2.7.7", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.7.7.tgz", + "integrity": "sha512-p3S7gotuTPu5oqhRL2p1fLwQXGgdQaRTtWR3e8Di9P1Pa9mzkK5DWR5AWBieMUh2ZdOnPgrK+zCrbbtyuA+D/Q==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.10.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/classlist-polyfill": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/classlist-polyfill/-/classlist-polyfill-1.2.0.tgz", @@ -6060,6 +6073,12 @@ "node": "*" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 9b7723b..1117833 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@openreplay/tracker": "^14.0.4", "@tanstack/react-query": "^5.17.0", "aos": "^2.3.4", + "chrono-node": "^2.7.7", "dotenv": "^16.4.5", "esm": "^3.2.25", "fuse.js": "^7.0.0", diff --git a/src/App.jsx b/src/App.jsx index 037df40..12f848a 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() @@ -137,5 +134,5 @@ const startOpenReplay = () => { export default App const startApiManager = () => { - apiManager.init(); -} \ No newline at end of file + apiManager.init() +} 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/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/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() && ( - - - + )} - + )} + {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/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/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 diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index 25918b5..62ee3e1 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -259,26 +259,31 @@ const UserActivites = () => { } const generateHistoryPieChartData = history => { - const totalCompleted = history.filter( - item => item.dueDate > item.completedAt, - ).length - const totalLate = history.filter( - item => item.dueDate < item.completedAt, - ).length + const totalCompleted = + history.filter(item => item.dueDate > item.completedAt).length || 0 + const totalLate = + history.filter(item => item.dueDate < item.completedAt).length || 0 + const totalNoDueDate = history.filter(item => !item.dueDate).length || 0 return [ { label: `On time`, value: totalCompleted, - color: '#4ec1a2', + color: TASK_COLOR.COMPLETED, id: 1, }, { label: `Late`, value: totalLate, - color: '#f6ad55', + color: TASK_COLOR.LATE, id: 2, }, + { + label: `Completed`, + value: totalNoDueDate, + color: TASK_COLOR.ANYTIME, + id: 3, + }, ] } if (isChoresHistoryLoading || isChoresLoading) { diff --git a/src/views/User/UserPoints.jsx b/src/views/User/UserPoints.jsx index 133f581..e97fa31 100644 --- a/src/views/User/UserPoints.jsx +++ b/src/views/User/UserPoints.jsx @@ -7,10 +7,11 @@ import { YAxis, } from 'recharts' -import { Toll } from '@mui/icons-material' +import { CreditCard, Toll } from '@mui/icons-material' import { Avatar, Box, + Button, Card, Chip, Container, @@ -27,11 +28,17 @@ import LoadingComponent from '../components/Loading.jsx' import { useChoresHistory } from '../../queries/ChoreQueries.jsx' import { useCircleMembers } from '../../queries/UserQueries.jsx' +import { RedeemPoints } from '../../utils/Fetcher.jsx' +import RedeemPointsModal from '../Modals/RedeemPointsModal' const UserPoints = () => { const [tabValue, setTabValue] = useState(7) + const [isRedeemModalOpen, setIsRedeemModalOpen] = useState(false) - const { data: circleMembersData, isLoading: isCircleMembersLoading } = - useCircleMembers() + const { + data: circleMembersData, + isLoading: isCircleMembersLoading, + handleRefetch: handleCircleMembersRefetch, + } = useCircleMembers() const { data: choresHistoryData, @@ -208,7 +215,7 @@ const UserPoints = () => { return yearlyAggregated } - if (isChoresHistoryLoading || isCircleMembersLoading) { + if (isChoresHistoryLoading || isCircleMembersLoading || !userProfile) { return } @@ -233,6 +240,8 @@ const UserPoints = () => { sx={{ gap: 1, my: 2, + display: 'flex', + justifyContent: 'start', }} > + {circleUsers.find(user => user.userId === userProfile.id)?.role === + 'admin' && ( + + )} { + { + 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) + }) + }, + }} + /> ) } 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: +