diff --git a/src/components/NotificationTemplate.jsx b/src/components/NotificationTemplate.jsx index 0eeec00..490099a 100644 --- a/src/components/NotificationTemplate.jsx +++ b/src/components/NotificationTemplate.jsx @@ -731,7 +731,7 @@ const NotificationTemplate = ({ setShowSaveDefault(false) }} > - Save Preference + Remember for Future Tasks )} diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index 0d2a046..d8869d3 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -259,6 +259,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => { const resp = await GetChoresHistory(limit, includeMembers) return resp?.res || [] }, + staleTime: 0, }) const handleLimitChange = newLimit => { diff --git a/src/views/Authorization/ForgotPasswordView.jsx b/src/views/Authorization/ForgotPasswordView.jsx index 86b2229..0e154bf 100644 --- a/src/views/Authorization/ForgotPasswordView.jsx +++ b/src/views/Authorization/ForgotPasswordView.jsx @@ -1,5 +1,4 @@ // create boilerplate for ResetPasswordView: -import Logo from '../../Logo' import { Box, Button, @@ -12,6 +11,7 @@ import { } from '@mui/joy' import { useState } from 'react' import { useNavigate } from 'react-router-dom' +import Logo from '../../Logo' import { useNotification } from '../../service/NotificationProvider' import { ResetPassword } from '../../utils/Fetcher' @@ -77,10 +77,7 @@ const ForgotPasswordView = () => { } return ( - + { {resetStatusOk === null && ( <> - + Enter your email, and we'll send you a link to get into your account. - + + + Email Address + + { )} @@ -1229,7 +1231,7 @@ const ChoreEdit = () => { setShowSavePrivacyDefault(false) }} > - Save Preference + Remember for Future Tasks )} diff --git a/src/views/Modals/Inputs/CreateChildUserModal.jsx b/src/views/Modals/Inputs/CreateChildUserModal.jsx index 2586112..d33c943 100644 --- a/src/views/Modals/Inputs/CreateChildUserModal.jsx +++ b/src/views/Modals/Inputs/CreateChildUserModal.jsx @@ -6,7 +6,7 @@ import { Input, Typography, } from '@mui/joy' -import React, { useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { useResponsiveModal } from '../../../hooks/useResponsiveModal' function CreateChildUserModal({ isOpen, onClose, onSuccess }) { @@ -30,9 +30,9 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { newErrors.childName = 'Sub account name must be at least 2 characters' } else if (childName.length > 20) { newErrors.childName = 'Sub account name must be less than 20 characters' - } else if (!/^[a-zA-Z0-9_]+$/.test(childName)) { + } else if (!/^[a-z.-]+$/.test(childName)) { newErrors.childName = - 'Sub account name can only contain letters, numbers, and underscores' + 'Sub account name can only contain lowercase letters, dot and dash' } } @@ -133,9 +133,6 @@ function CreateChildUserModal({ isOpen, onClose, onSuccess }) { {errors.childName && ( {errors.childName} )} - - This will create a username like: primaryname_subaccountname - diff --git a/src/views/User/UserActivities.jsx b/src/views/User/UserActivities.jsx index 03b44eb..a53d43e 100644 --- a/src/views/User/UserActivities.jsx +++ b/src/views/User/UserActivities.jsx @@ -882,10 +882,7 @@ const UserActivites = () => { ) }} renderValue={() => { - if ( - selectedUser === undefined || - selectedUser === 'all' - ) { + if (selectedUser === undefined || selectedUser === 'all') { return ( { } > { - circleUsers.find( - user => user.userId === selectedUser, - )?.displayName + circleUsers.find(user => user.userId === selectedUser) + ?.displayName } ) @@ -1048,7 +1044,7 @@ const UserActivites = () => { {/* Conditional Content Based on Data Availability */} - {(!choresData.res?.length > 0 || !choresHistory?.length > 0) ? ( + {!choresData.res?.length > 0 || !choresHistory?.length > 0 ? ( { {chartData[selectedChart].title} - + {chartData[selectedChart].description} { }) const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null) const [subTasks, setSubTasks] = useState(null) + const [points, setPoints] = useState(-1) + const [isAnyoneTask, setIsAnyoneTask] = useState(false) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) const [hasNotifications, setHasNotifications] = useState(false) @@ -170,6 +173,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { priorityHighlight, labelsHighlight, dueDateHighlight, + pointsHighlight, + assigneesHighlight, ) => { const parts = [] let lastIndex = 0 @@ -179,24 +184,34 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const allHighlights = [] if (repeatHighlight) { repeatHighlight.forEach(h => - allHighlights.push({ ...h, type: 'repeat', priority: 40 }), + allHighlights.push({ ...h, type: 'repeat', priority: 60 }), ) } if (priorityHighlight) { priorityHighlight.forEach(h => - allHighlights.push({ ...h, type: 'priority', priority: 30 }), + allHighlights.push({ ...h, type: 'priority', priority: 50 }), + ) + } + if (pointsHighlight) { + pointsHighlight.forEach(h => + allHighlights.push({ ...h, type: 'points', priority: 45 }), + ) + } + if (assigneesHighlight) { + assigneesHighlight.forEach(h => + allHighlights.push({ ...h, type: 'assignee', priority: 40 }), ) } if (labelsHighlight) { labelsHighlight.forEach(h => - allHighlights.push({ ...h, type: 'label', priority: 20 }), + allHighlights.push({ ...h, type: 'label', priority: 30 }), ) } if (dueDateHighlight) { allHighlights.push({ ...dueDateHighlight, type: 'dueDate', - priority: 10, + priority: 20, }) } @@ -234,6 +249,12 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { case 'priority': className = 'highlight-priority' break + case 'points': + className = 'highlight-points' + break + case 'assignee': + className = 'highlight-assignee' + break case 'label': className = 'highlight-label' break @@ -286,22 +307,11 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const processText = useCallback( sentence => { - let cleanedSentence = sentence + // Parse everything from the original sentence to get correct highlight positions const priority = parsePriority(sentence) - if (priority.result) setPriority(priority.result) - cleanedSentence = priority.cleanedSentence - const labels = parseLabels(sentence, userLabels) - if (labels.result) { - cleanedSentence = labels.cleanedSentence - setLabelsV2(labels.result) - } + const pointsParsed = parsePoints(sentence) + const labels = parseLabels(sentence, userLabels || []) - const repeat = parseRepeatV2(sentence) - if (repeat.result) { - setFrequency(repeat.result) - setFrequencyHumanReadable(repeat.name) - cleanedSentence = repeat.cleanedSentence - } // Parse assignees using circle members const circleMembersList = circleMembers?.res || [] const assigneesForParsing = circleMembersList.map(member => ({ @@ -315,28 +325,44 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { })) const assigneesResult = parseAssignees(sentence, assigneesForParsing) - if (assigneesResult.result) { - cleanedSentence = assigneesResult.cleanedSentence - console.log('CLEANED', cleanedSentence) - - setAssignees( - assigneesResult.result.map(assignee => ({ - userId: assignee.userId, - })), - ) - } else { - setAssignees([ - { - userId: userProfile.id, - }, - ]) - } - // Parse due date + const repeat = parseRepeatV2(sentence) const dueDateParsed = parseDueDate(sentence, chrono) + + // Set all the parsed values + if (priority.result) setPriority(priority.result) + if (pointsParsed.result) setPoints(pointsParsed.result) + if (labels.result) setLabelsV2(labels.result) + + if (assigneesResult.isAnyone) { + // @Anyone was used - set empty assignees (anyone can do the task) + setIsAnyoneTask(true) + setAssignees([]) + } else if (assigneesResult.result && assigneesResult.result.length > 0) { + setIsAnyoneTask(false) + const parsedAssignees = assigneesResult.result.map(assignee => ({ + userId: assignee.userId, + })) + setAssignees(parsedAssignees) + } else { + // Only assign to current user if no @ mentions found and userProfile exists + setIsAnyoneTask(false) + if (userProfile?.id) { + setAssignees([ + { + userId: userProfile.id, + }, + ]) + } + } + + if (repeat.result) { + setFrequency(repeat.result) + setFrequencyHumanReadable(repeat.name) + } + let dueDateHighlight = null if (dueDateParsed.result) { setDueDate(moment(dueDateParsed.result).format('YYYY-MM-DDTHH:mm:ss')) - cleanedSentence = dueDateParsed.cleanedSentence dueDateHighlight = dueDateParsed.highlight[0] } @@ -351,24 +377,69 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } } + // Create the cleaned sentence by sequentially applying all cleanups + let cleanedSentence = sentence + if (priority.result) cleanedSentence = priority.cleanedSentence + if (pointsParsed.result) { + // Apply points cleaning to the current cleaned sentence + const pointsReparse = parsePoints(cleanedSentence) + if (pointsReparse.result) + cleanedSentence = pointsReparse.cleanedSentence + } + if (labels.result) { + // Apply labels cleaning to the current cleaned sentence + const labelsReparse = parseLabels(cleanedSentence, userLabels || []) + if (labelsReparse.result) + cleanedSentence = labelsReparse.cleanedSentence + } + if (assigneesResult.result) { + // Apply assignees cleaning to the current cleaned sentence + const assigneesReparse = parseAssignees( + cleanedSentence, + assigneesForParsing, + ) + if (assigneesReparse.result) + cleanedSentence = assigneesReparse.cleanedSentence + } + if (repeat.result) { + // Apply repeat cleaning to the current cleaned sentence + const repeatReparse = parseRepeatV2(cleanedSentence) + if (repeatReparse.result) + cleanedSentence = repeatReparse.cleanedSentence + } + if (dueDateParsed.result) { + // Apply date cleaning to the current cleaned sentence + const dueDateReparse = parseDueDate(cleanedSentence, chrono) + if (dueDateReparse.result) + cleanedSentence = dueDateReparse.cleanedSentence + } + setTaskText(sentence) setTaskTitle(cleanedSentence.trim()) - const { parts, plainText } = renderHighlightedSentence( + + // Generate highlights for rendering using original sentence positions + const { parts } = renderHighlightedSentence( sentence, repeat.highlight, priority.highlight, labels.highlight, dueDateHighlight, + pointsParsed.highlight, + assigneesResult.highlight, ) setRenderedParts(parts) - setTaskTitle(plainText) }, - [userLabels, renderHighlightedSentence], + [userLabels, renderHighlightedSentence, circleMembers, userProfile], ) useEffect(() => { - if (!isModalOpen || userLabelsLoading || isCircleMembersLoading) { + if ( + !isModalOpen || + userLabelsLoading || + isCircleMembersLoading || + !userProfile + ) { return } @@ -378,6 +449,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { userLabelsLoading, isCircleMembersLoading, isModalOpen, + userProfile, processText, ]) @@ -393,6 +465,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setFrequency(null) setFrequencyHumanReadable(null) setPriority(0) + setPoints(-1) + setIsAnyoneTask(false) setHasDescription(false) setDescription(null) setSubTasks(null) @@ -402,17 +476,41 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { } const createChore = () => { + // Handle different assignee scenarios + let finalAssignees = assignees + let finalAssignedTo = null + let finalAssignStrategy = 'random' + + if (isAnyoneTask) { + // @Anyone was explicitly used - anyone can do the task + finalAssignees = [] + finalAssignedTo = null + finalAssignStrategy = 'no_assignee' + } else if (assignees.length === 0) { + // No assignees and no @Anyone - fallback to current user + finalAssignees = [{ userId: userProfile?.id }] + finalAssignedTo = userProfile?.id + finalAssignStrategy = 'keep_last_assigned' + } else if (assignees.length === 1) { + // Single assignee + finalAssignedTo = assignees[0].userId + finalAssignStrategy = 'keep_last_assigned' + } else { + // Multiple assignees + finalAssignedTo = null + finalAssignStrategy = 'random' + } + const chore = { name: taskTitle, - assignees: - assignees.length > 0 ? assignees : [{ userId: userProfile.id }], + assignees: finalAssignees, dueDate: dueDate ? new Date(dueDate).toISOString() : null, - assignedTo: assignees.length > 0 ? assignees[0].userId : userProfile.id, - assignStrategy: 'random', + assignedTo: finalAssignedTo, + assignStrategy: finalAssignStrategy, isRolling: false, - labelsV2: labelsV2, priority: priority ? Number(priority) : 0, + points: points > -1 ? points : null, status: 0, frequencyType: 'once', frequencyMetadata: {}, @@ -552,7 +650,22 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { '@': { value: 'userId', display: 'displayName', - options: circleMembers?.res || [], + options: [ + { userId: 'anyone', displayName: 'Anyone' }, + ...(circleMembers?.res || []), + ], + }, + '*': { + value: 'id', + display: 'name', + options: [ + { id: '1', name: '1 point' }, + { id: '5', name: '5 points' }, + { id: '10', name: '10 points' }, + { id: '25', name: '25 points' }, + { id: '50', name: '50 points' }, + { id: '100', name: '100 points' }, + ], }, }} /> diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index 22667a9..64691c2 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -85,35 +85,63 @@ export const parsePriority = inputSentence => { export const parseLabels = (inputSentence, userLabels) => { let sentence = inputSentence.toLowerCase() const currentLabels = [] - // label will always be prefixed #: + const newLabels = [] + const allHighlights = [] - for (const label of userLabels) { - if (sentence.includes(`#${label.name.toLowerCase()}`)) { - currentLabels.push(label) - sentence = sentence.replace(`#${label.name.toLowerCase()}`, '') + // Find all #label patterns in the sentence + const labelPattern = /#(\w+)/gi + const matches = [...inputSentence.matchAll(labelPattern)] + + for (const match of matches) { + const labelName = match[1] + const fullMatch = match[0] + const startIndex = match.index + + // Check if this label already exists + const existingLabel = userLabels.find( + label => label.name.toLowerCase() === labelName.toLowerCase(), + ) + + if (existingLabel) { + currentLabels.push(existingLabel) + } else { + // Create a new label object for new labels + newLabels.push({ + name: labelName, + color: '#3b82f6', // Default blue color + isNew: true, + }) } + + allHighlights.push({ + text: fullMatch, + start: startIndex, + end: startIndex + fullMatch.length, + }) + + // Remove the label from the sentence + sentence = sentence.replace(fullMatch.toLowerCase(), '') } - if (currentLabels.length > 0) { + + const allLabels = [...currentLabels, ...newLabels] + + if (allLabels.length > 0) { return { - result: currentLabels, - highlight: currentLabels.map(label => { - const index = inputSentence - .toLowerCase() - .indexOf(`#${label.name.toLowerCase()}`) - return { - text: `#${label.name}`, - start: index, - end: index + label.name.length + 1, - } - }), - - cleanedSentence: sentence.replace( - new RegExp(`#(${userLabels.map(l => l.name).join('|')})`, 'g'), - '', - ), + result: allLabels, + newLabels: newLabels, + highlight: allHighlights, + cleanedSentence: inputSentence + .replace(labelPattern, '') + .replace(/\s+/g, ' ') + .trim(), } } - return { result: null, cleanedSentence: sentence } + + return { + result: null, + newLabels: [], + cleanedSentence: inputSentence, + } } export const parseRepeatV2 = inputSentence => { @@ -498,37 +526,135 @@ export const parseAssignees = (inputSentence, users) => { const sentence = inputSentence.toLowerCase() const result = [] const highlight = [] - // sort users by the longest so we remove first the full match: - for (const user of users.sort( - (a, b) => b.displayName.length - a.displayName.length, - )) { - if (sentence.includes(`@${user.displayName.toLowerCase()}`)) { + const matchedTexts = [] + + // Check for @Anyone first (special case) + const anyoneRegex = /@anyone(?=\s|$)/i + const anyoneMatch = inputSentence.match(anyoneRegex) + + if (anyoneMatch) { + const index = inputSentence.search(anyoneRegex) + highlight.push({ + text: anyoneMatch[0], + start: index, + end: index + anyoneMatch[0].length, + }) + matchedTexts.push({ pattern: '@anyone', original: anyoneMatch[0] }) + + // For @Anyone, return empty result (no specific assignees) + let cleanedSentence = inputSentence + for (const matchedText of matchedTexts) { + cleanedSentence = cleanedSentence.replace( + new RegExp( + matchedText.original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'gi', + ), + '', + ) + } + + return { + result: [], // Empty assignees for @Anyone + isAnyone: true, // Flag to indicate @Anyone was used + highlight, + cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(), + } + } + + // Sort users by longest displayName first to avoid partial matches + const sortedUsers = users.sort( + (a, b) => (b.displayName?.length || 0) - (a.displayName?.length || 0), + ) + + for (const user of sortedUsers) { + if (!user.displayName) continue + + // Only match on display name - use word boundaries for exact matching + const displayNamePattern = `@${user.displayName.toLowerCase()}` + // Use word boundary or space/end to ensure exact match, not partial + const exactMatchRegex = new RegExp( + `@${user.displayName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?=\\s|$)`, + 'i', + ) + const exactMatch = inputSentence.match(exactMatchRegex) + + if (exactMatch && !result.some(r => r.userId === user.userId)) { result.push(user) - const index = inputSentence - .toLowerCase() - .indexOf(`@${user.displayName.toLowerCase()}`) + const index = inputSentence.search(exactMatchRegex) + highlight.push({ - text: `@${user.displayName}`, + text: exactMatch[0], start: index, - end: index + user.displayName.length + 1, + end: index + exactMatch[0].length, + }) + matchedTexts.push({ + pattern: displayNamePattern, + original: exactMatch[0], }) } } if (result.length > 0) { - return { - result, - highlight, - cleanedSentence: sentence.replace( + let cleanedSentence = inputSentence + // Remove all matched assignee patterns using the original matched text + for (const matchedText of matchedTexts) { + cleanedSentence = cleanedSentence.replace( new RegExp( - `@(${result.map(u => u.displayName.toLowerCase()).join('|')})`, - 'g', + matchedText.original.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + 'gi', ), '', - ), + ) + } + + return { + result, + isAnyone: false, + highlight, + cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(), } } - return { result: null, cleanedSentence: sentence } + + return { result: null, isAnyone: false, cleanedSentence: inputSentence } +} + +export const parsePoints = inputSentence => { + let sentence = inputSentence.toLowerCase() + const pointsPattern = /\*(\d+)\s*(?:points?)?/gi + const match = sentence.match(pointsPattern) + + if (!match) { + return { + result: null, + highlight: [], + cleanedSentence: inputSentence, + } + } + + // Extract the first points match + const pointsMatch = match[0] + const pointsValue = parseInt(pointsMatch.replace(/\D/g, ''), 10) + const startIndex = inputSentence + .toLowerCase() + .indexOf(pointsMatch.toLowerCase()) + + return { + result: pointsValue, + highlight: [ + { + text: pointsMatch, + start: startIndex, + end: startIndex + pointsMatch.length, + }, + ], + cleanedSentence: inputSentence + .replace( + new RegExp(pointsMatch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), + '', + ) + .replace(/\s+/g, ' ') + .trim(), + } } export const parseDueDate = (inputSentence, chrono) => {