From 65f84b116fe1246e3f1253cb957730b67539e267 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 22 Aug 2025 23:37:30 -0400 Subject: [PATCH] Refactor chore components and enhance scheduling features - Removed redundant priority color function from CompactChoreCard and imported from Colors utility. - Replaced FadeModal with ResponsiveModal in MultiSelectHelp for improved modal handling. - Simplified MyChores component by removing archived chores state and related logic, and added keyboard shortcut for navigating to archived tasks. - Integrated ResponsiveModal in RedeemPointsModal for consistent modal usage. - Added quick scheduling options in ChoreActionMenu for better task management, including scheduling for today, tomorrow, weekend, and next week. - Enhanced CustomParsers to support nth occurrence of days in monthly scheduling. - Updated NavBar links and navigation logic for improved user experience. --- src/constants/zIndex.js | 1 + src/utils/Colors.jsx | 15 + .../Authorization/MFAVerificationModal.jsx | 9 +- src/views/ChoreEdit/ChoreEdit.jsx | 173 +--- src/views/Chores/ArchivedTasks.jsx | 843 ++++++++++++++++++ src/views/Chores/CompactChoreCard.jsx | 16 +- src/views/Chores/MultiSelectHelp.jsx | 8 +- src/views/Chores/MyChores.jsx | 84 +- src/views/Modals/RedeemPointsModal.jsx | 1 + src/views/components/ChoreActionMenu.jsx | 170 +++- src/views/components/CustomParsers.js | 141 ++- src/views/components/NavBar.jsx | 68 +- 12 files changed, 1275 insertions(+), 254 deletions(-) create mode 100644 src/views/Chores/ArchivedTasks.jsx diff --git a/src/constants/zIndex.js b/src/constants/zIndex.js index 220c575..cc41e4d 100644 --- a/src/constants/zIndex.js +++ b/src/constants/zIndex.js @@ -21,6 +21,7 @@ export const Z_INDEX = { // Modals and Overlays (2000-8999) MODAL_BACKDROP: 2000, MODAL_CONTENT: 2001, + MODAL_CLOSE_BUTTON: 2002, TOAST: 3000, // Critical System UI (9000-9999) diff --git a/src/utils/Colors.jsx b/src/utils/Colors.jsx index 09fa0fb..842f2bf 100644 --- a/src/utils/Colors.jsx +++ b/src/utils/Colors.jsx @@ -107,3 +107,18 @@ export const getTextColorFromBackgroundColor = bgColor => { const b = parseInt(hex.substring(4, 6), 16) return r * 0.299 + g * 0.587 + b * 0.114 > 186 ? '#000000' : '#ffffff' } + +export const getPriorityColor = priority => { + switch (priority) { + case 1: + return TASK_COLOR.PRIORITY_1 + case 2: + return TASK_COLOR.PRIORITY_2 + case 3: + return TASK_COLOR.PRIORITY_3 + case 4: + return TASK_COLOR.PRIORITY_4 + default: + return TASK_COLOR.NO_PRIORITY + } +} diff --git a/src/views/Authorization/MFAVerificationModal.jsx b/src/views/Authorization/MFAVerificationModal.jsx index 991df11..4586fb3 100644 --- a/src/views/Authorization/MFAVerificationModal.jsx +++ b/src/views/Authorization/MFAVerificationModal.jsx @@ -10,7 +10,8 @@ import { Typography, } from '@mui/joy' import { useState } from 'react' -import FadeModal from '../../components/common/FadeModal' + +import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { VerifyMFA } from '../../utils/Fetcher' const MFAVerificationModal = ({ @@ -24,7 +25,7 @@ const MFAVerificationModal = ({ const [isBackupCode, setIsBackupCode] = useState(false) const [loading, setLoading] = useState(false) const [error, setError] = useState('') - + const { ResponsiveModal } = useResponsiveModal() const handleVerify = async () => { if (!verificationCode.trim()) { setError('Please enter a verification code') @@ -69,7 +70,7 @@ const MFAVerificationModal = ({ } return ( - + @@ -150,7 +151,7 @@ const MFAVerificationModal = ({ - + ) } diff --git a/src/views/ChoreEdit/ChoreEdit.jsx b/src/views/ChoreEdit/ChoreEdit.jsx index 487fccb..c9900aa 100644 --- a/src/views/ChoreEdit/ChoreEdit.jsx +++ b/src/views/ChoreEdit/ChoreEdit.jsx @@ -1,9 +1,4 @@ -import { - Add, - ChevronRight, - ExpandMore, - HorizontalRule, -} from '@mui/icons-material' +import { Add, HorizontalRule } from '@mui/icons-material' import { Box, Button, @@ -107,12 +102,7 @@ const ChoreEdit = () => { const [errors, setErrors] = useState({}) const [attemptToSave, setAttemptToSave] = useState(false) const [addLabelModalOpen, setAddLabelModalOpen] = useState(false) - const [expandedSections, setExpandedSections] = useState({ - basicInfo: true, - assignment: true, - schedule: true, - taskSettings: true, - }) + const { data: userLabelsRaw, isLoading: isUserLabelsLoading } = useLabels() const updateChoreMutation = useUpdateChore() const createChoreMutation = useCreateChore() @@ -135,78 +125,6 @@ const ChoreEdit = () => { const Navigate = useNavigate() - const toggleSection = sectionKey => { - setExpandedSections(prev => ({ - ...prev, - [sectionKey]: !prev[sectionKey], - })) - } - - const CollapsibleSection = ({ sectionKey, title, subtitle, children }) => { - const isExpanded = expandedSections[sectionKey] - - return ( - - toggleSection(sectionKey)} - sx={{ - mx: 0, - display: 'flex', - alignItems: 'center', - cursor: 'pointer', - width: '100%', - py: 2, - borderRadius: 'md', - backgroundColor: 'background.level1', - border: '1px solid', - borderColor: 'divider', - mb: isExpanded ? 2 : 0, - transition: 'all 0.2s ease-in-out', - '&:hover': { - backgroundColor: 'background.level2', - borderColor: 'primary.main', - }, - }} - > - {isExpanded ? ( - - ) : ( - - )} - - - {title} - - {subtitle && ( - - {subtitle} - - )} - - - - {isExpanded && ( - - {children} - - )} - - ) - } - const HandleValidateChore = () => { const errors = {} @@ -493,15 +411,21 @@ const ChoreEdit = () => { return ( {/* Section 1: Basic Information */} - + + {/* + Basic Information + */} + Name - What is the name of this chore? + + What is the name of this task? + setName(e.target.value)} /> {errors.name} @@ -510,7 +434,7 @@ const ChoreEdit = () => { Description - What is this task about? + What is this task about? { Priority - How important is this task? + How important is this task? {/* Priority Chip Selection */} { Labels - + Things to remember about this task or to tag it { Assignment Strategy - + How to pick the next assignee for the following task? @@ -789,14 +711,10 @@ const ChoreEdit = () => { )} - + {/* Section 3: Schedule & Timing */} - + { {!['once', 'no_repeat'].includes(frequencyType) && ( Scheduling Preferences - + How to reschedule the next due date? div': { p: 1 } }}> @@ -1084,14 +1002,21 @@ const ChoreEdit = () => { )} - + {/* Section 4: Task Settings */} - + + + Task Settings: + + Points System @@ -1160,16 +1085,16 @@ const ChoreEdit = () => { - Visibility - - Choose who can see this task - + Privacy Settings + Who can see this task? setIsPrivate(event.target.value)} + onChange={event => { + setIsPrivate(event.target.value === 'true' ? true : false) + }} sx={{ - '& > div': { p: 1 }, + '& > div': { py: 1 }, }} > @@ -1177,14 +1102,14 @@ const ChoreEdit = () => { Everyone in your circle - + - Only you and others that are assigned to the task + You and others that are assigned to the task - + {choreId > 0 && ( diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx new file mode 100644 index 0000000..ceeb026 --- /dev/null +++ b/src/views/Chores/ArchivedTasks.jsx @@ -0,0 +1,843 @@ +import { + Archive, + CheckBox, + CheckBoxOutlineBlank, + Close, + Delete, + SelectAll, + Unarchive, + ViewAgenda, + ViewModule, +} from '@mui/icons-material' +import { + Box, + Button, + Container, + Divider, + IconButton, + Input, + List, + Typography, +} from '@mui/joy' +import Fuse from 'fuse.js' +import { useEffect, useRef, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' +import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' +import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' +import { useNotification } from '../../service/NotificationProvider' +import { ChoreSorter } from '../../utils/Chores' +import { + DeleteChore, + GetArchivedChores, + UnArchiveChore, +} from '../../utils/Fetcher' +import LoadingComponent from '../components/Loading' +import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' +import ChoreCard from './ChoreCard' +import CompactChoreCard from './CompactChoreCard' +import MultiSelectHelp from './MultiSelectHelp' + +const ArchivedTasks = () => { + const { data: userProfile, isLoading: isUserProfileLoading } = + useUserProfile() + const { showSuccess, showError } = useNotification() + const { impersonatedUser } = useImpersonateUser() + const [archivedChores, setArchivedChores] = useState([]) + const [filteredChores, setFilteredChores] = useState([]) + const [searchTerm, setSearchTerm] = useState('') + const [performers, setPerformers] = useState([]) + const navigate = useNavigate() + const [viewMode, setViewMode] = useState( + localStorage.getItem('archivedChoreCardViewMode') || 'default', + ) + const [isLoading, setIsLoading] = useState(true) + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) + const searchInputRef = useRef(null) + + // Multi-select state + const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) + const [selectedChores, setSelectedChores] = useState(new Set()) + const [confirmModelConfig, setConfirmModelConfig] = useState({}) + + const { data: membersData, isLoading: membersLoading } = useCircleMembers() + + useEffect(() => { + const loadArchivedChores = async () => { + if (!membersLoading && userProfile) { + setPerformers(membersData.res) + try { + const response = await GetArchivedChores() + const data = await response.json() + const sortedChores = data.res.sort(ChoreSorter) + setArchivedChores(sortedChores) + setFilteredChores(sortedChores) + } catch (error) { + showError({ + title: 'Failed to load archived tasks', + message: 'Please try again later.', + }) + } finally { + setIsLoading(false) + } + } + } + loadArchivedChores() + }, [membersLoading, userProfile, membersData]) + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = event => { + const isHoldingCmdOrCtrl = event.ctrlKey || event.metaKey + + if (isHoldingCmdOrCtrl) { + setShowKeyboardShortcuts(true) + } + + // Ctrl/Cmd + F to focus search input + if (isHoldingCmdOrCtrl && event.key === 'f') { + event.preventDefault() + searchInputRef.current?.focus() + return + } + + // Ctrl/Cmd + S Toggle Multi-select mode + if (isHoldingCmdOrCtrl && event.key === 's') { + event.preventDefault() + toggleMultiSelectMode() + return + } + + // Ctrl/Cmd + A to select all + if ( + isHoldingCmdOrCtrl && + event.key === 'a' && + !['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName) + ) { + event.preventDefault() + if (!isMultiSelectMode) { + setIsMultiSelectMode(true) + setTimeout(() => { + selectAllVisibleChores() + }, 0) + } else { + selectAllVisibleChores() + } + } + + // Multi-select keyboard shortcuts + if (isMultiSelectMode) { + // Escape to clear selection or exit multi-select mode + if (event.key === 'Escape') { + event.preventDefault() + if (selectedChores.size > 0) { + clearSelection() + } else { + setIsMultiSelectMode(false) + } + return + } + + // "r" key for bulk restore (unarchive) + if ( + isHoldingCmdOrCtrl && + event.key === 'r' && + selectedChores.size > 0 + ) { + event.preventDefault() + handleBulkRestore() + return + } + + // "e" key for bulk delete + if ( + isHoldingCmdOrCtrl && + event.key === 'e' && + selectedChores.size > 0 + ) { + event.preventDefault() + handleBulkDelete() + return + } + } + } + + const handleKeyUp = event => { + if (!event.ctrlKey && !event.metaKey) { + setShowKeyboardShortcuts(false) + } + } + + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) + } + }, [isMultiSelectMode, selectedChores.size]) + + const toggleViewMode = () => { + const modes = ['default', 'compact'] + const currentIndex = modes.indexOf(viewMode) + const nextIndex = (currentIndex + 1) % modes.length + const newMode = modes[nextIndex] + setViewMode(newMode) + localStorage.setItem('archivedChoreCardViewMode', newMode) + } + + const searchOptions = { + keys: ['name', 'raw_label'], + includeScore: true, + isCaseSensitive: false, + findAllMatches: true, + } + + const fuse = new Fuse( + archivedChores.map(c => ({ + ...c, + raw_label: c.labelsV2?.map(c => c.name).join(' '), + })), + searchOptions, + ) + + const handleSearchChange = e => { + const search = e.target.value + if (search === '') { + setFilteredChores(archivedChores) + setSearchTerm('') + return + } + + const term = search.toLowerCase() + setSearchTerm(term) + setFilteredChores(fuse.search(term).map(result => result.item)) + } + + const handleSearchClose = () => { + setSearchTerm('') + setFilteredChores(archivedChores) + searchInputRef.current?.blur() + } + + const handleChoreUpdated = (updatedChore, event) => { + if (event === 'unarchive') { + // Remove from archived list when unarchived + const newArchivedChores = archivedChores.filter( + chore => chore.id !== updatedChore.id, + ) + const newFilteredChores = filteredChores.filter( + chore => chore.id !== updatedChore.id, + ) + setArchivedChores(newArchivedChores) + setFilteredChores(newFilteredChores) + + showSuccess({ + title: 'Task Restored', + message: 'The task has been restored and is now active.', + }) + } + } + + const handleChoreDeleted = deletedChore => { + const newArchivedChores = archivedChores.filter( + chore => chore.id !== deletedChore.id, + ) + const newFilteredChores = filteredChores.filter( + chore => chore.id !== deletedChore.id, + ) + setArchivedChores(newArchivedChores) + setFilteredChores(newFilteredChores) + + showSuccess({ + title: 'Task Deleted', + message: 'The archived task has been permanently deleted.', + }) + } + + // Multi-select helper functions + const toggleMultiSelectMode = () => { + const newMode = !isMultiSelectMode + setIsMultiSelectMode(newMode) + + if (!newMode) { + setSelectedChores(new Set()) + } + } + + const toggleChoreSelection = choreId => { + const newSelection = new Set(selectedChores) + if (newSelection.has(choreId)) { + newSelection.delete(choreId) + } else { + newSelection.add(choreId) + } + setSelectedChores(newSelection) + } + + const selectAllVisibleChores = () => { + const visibleChores = + searchTerm?.length > 0 ? filteredChores : archivedChores + if (visibleChores.length > 0) { + const allIds = new Set(visibleChores.map(chore => chore.id)) + setSelectedChores(allIds) + } + } + + const clearSelection = () => { + if (selectedChores.size === 0) { + setIsMultiSelectMode(false) + return + } + setSelectedChores(new Set()) + } + + const getSelectedChoresData = () => { + return Array.from(selectedChores) + .map(id => archivedChores.find(chore => chore.id === id)) + .filter(Boolean) + } + + // Bulk operations + const handleBulkRestore = async () => { + const selectedData = getSelectedChoresData() + if (selectedData.length === 0) return + + setConfirmModelConfig({ + isOpen: true, + title: 'Restore Tasks', + confirmText: 'Restore', + cancelText: 'Cancel', + message: `Restore ${selectedData.length} task${selectedData.length > 1 ? 's' : ''} to active list?`, + onClose: async isConfirmed => { + if (isConfirmed === true) { + try { + const restoredTasks = [] + const failedTasks = [] + + for (const chore of selectedData) { + try { + await UnArchiveChore(chore.id) + restoredTasks.push(chore) + } catch (error) { + failedTasks.push(chore) + } + } + + if (restoredTasks.length > 0) { + showSuccess({ + title: '📤 Tasks Restored', + message: `Successfully restored ${restoredTasks.length} task${restoredTasks.length > 1 ? 's' : ''}.`, + }) + + // Remove restored tasks from archived list + const restoredIds = new Set(restoredTasks.map(c => c.id)) + const newArchivedChores = archivedChores.filter( + c => !restoredIds.has(c.id), + ) + const newFilteredChores = filteredChores.filter( + c => !restoredIds.has(c.id), + ) + setArchivedChores(newArchivedChores) + setFilteredChores(newFilteredChores) + } + + if (failedTasks.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be restored.`, + }) + } + + clearSelection() + } catch (error) { + showError({ + title: 'Bulk Restore Failed', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + setConfirmModelConfig({}) + }, + }) + } + + const handleBulkDelete = async () => { + const selectedData = getSelectedChoresData() + if (selectedData.length === 0) return + + setConfirmModelConfig({ + isOpen: true, + title: 'Delete Archived Tasks', + confirmText: 'Delete', + cancelText: 'Cancel', + message: `Permanently delete ${selectedData.length} archived task${selectedData.length > 1 ? 's' : ''}?\n\nThis action cannot be undone.`, + onClose: async isConfirmed => { + if (isConfirmed === true) { + try { + const deletedTasks = [] + const failedTasks = [] + + for (const chore of selectedData) { + try { + await DeleteChore(chore.id) + deletedTasks.push(chore) + } catch (error) { + failedTasks.push(chore) + } + } + + if (deletedTasks.length > 0) { + showSuccess({ + title: '🗑️ Tasks Deleted', + message: `Successfully deleted ${deletedTasks.length} task${deletedTasks.length > 1 ? 's' : ''}.`, + }) + + const deletedIds = new Set(deletedTasks.map(c => c.id)) + const newArchivedChores = archivedChores.filter( + c => !deletedIds.has(c.id), + ) + const newFilteredChores = filteredChores.filter( + c => !deletedIds.has(c.id), + ) + setArchivedChores(newArchivedChores) + setFilteredChores(newFilteredChores) + } + + if (failedTasks.length > 0) { + showError({ + title: 'Some Tasks Failed', + message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be deleted.`, + }) + } + + clearSelection() + } catch (error) { + showError({ + title: 'Bulk Delete Failed', + message: 'An unexpected error occurred. Please try again.', + }) + } + } + setConfirmModelConfig({}) + }, + }) + } + + // Helper function to render the appropriate card component + const renderChoreCard = (chore, key) => { + const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard + return ( + toggleChoreSelection(chore.id)} + /> + ) + } + + if (isUserProfileLoading || performers.length === 0 || isLoading) { + return + } + + return ( + + {/* Header */} + {/* + + + Archived Tasks + + + */} + + {/* Search and Controls */} + + + } + endDecorator={ + searchTerm && ( + + + + + + + ) + } + /> + + {/* View Mode Toggle Button */} + + {viewMode === 'default' ? : } + + + {/* Multi-select Toggle Button */} + + + {isMultiSelectMode ? : } + + + + + + {/* Multi-select Toolbar */} + {isMultiSelectMode && ( + + + {/* Selection Info and Controls */} + + + + + {selectedChores.size} task + {selectedChores.size !== 1 ? 's' : ''} selected + + + + + + + + + + + + {/* Action Buttons */} + + + + + + + + )} + + {/* Content */} + {filteredChores.length === 0 ? ( + + + + {searchTerm ? 'No archived tasks found' : 'No archived tasks'} + + + {searchTerm + ? 'Try adjusting your search terms' + : 'Archived tasks will appear here when you archive them from the main task list'} + + {searchTerm && ( + + )} + + ) : ( + + + {filteredChores.length} archived task + {filteredChores.length !== 1 ? 's' : ''} + {searchTerm && ` matching "${searchTerm}"`} + + + + {filteredChores.map(chore => + renderChoreCard(chore, `archived-${chore.id}`), + )} + + + )} + + {/* Multi-select Help */} + + + {/* Confirmation Modal */} + {confirmModelConfig?.isOpen && ( + + )} + + ) +} + +export default ArchivedTasks diff --git a/src/views/Chores/CompactChoreCard.jsx b/src/views/Chores/CompactChoreCard.jsx index d68c0d3..fd57a66 100644 --- a/src/views/Chores/CompactChoreCard.jsx +++ b/src/views/Chores/CompactChoreCard.jsx @@ -31,8 +31,8 @@ import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' import { notInCompletionWindow } from '../../utils/Chores.jsx' import { + getPriorityColor, getTextColorFromBackgroundColor, - TASK_COLOR, } from '../../utils/Colors.jsx' import { ApproveChore, @@ -614,20 +614,6 @@ const CompactChoreCard = ({ return parts.join(' • ') } - const getPriorityColor = priority => { - switch (priority) { - case 1: - return TASK_COLOR.PRIORITY_1 - case 2: - return TASK_COLOR.PRIORITY_2 - case 3: - return TASK_COLOR.PRIORITY_3 - case 4: - return TASK_COLOR.PRIORITY_4 - default: - return TASK_COLOR.NO_PRIORITY - } - } const handleChorePause = () => { PauseChore(chore.id).then(response => { if (response.ok) { diff --git a/src/views/Chores/MultiSelectHelp.jsx b/src/views/Chores/MultiSelectHelp.jsx index b902e84..1aabe7b 100644 --- a/src/views/Chores/MultiSelectHelp.jsx +++ b/src/views/Chores/MultiSelectHelp.jsx @@ -1,9 +1,11 @@ import { Close, HelpOutline, Keyboard } from '@mui/icons-material' import { Box, Button, Card, Divider, IconButton, Typography } from '@mui/joy' import { useState } from 'react' -import FadeModal from '../../components/common/FadeModal' +import { useResponsiveModal } from '../../hooks/useResponsiveModal' const MultiSelectHelp = ({ isVisible = true }) => { + const { ResponsiveModal } = useResponsiveModal() + const [isHelpOpen, setIsHelpOpen] = useState(false) if (!isVisible) return null @@ -32,7 +34,7 @@ const MultiSelectHelp = ({ isVisible = true }) => { {/* Help Modal */} - setIsHelpOpen(false)}> + setIsHelpOpen(false)}> { Got it! - + ) } diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 1a91811..e359f77 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -17,7 +17,6 @@ import { SkipNext, Sort, Style, - Unarchive, ViewAgenda, ViewModule, } from '@mui/icons-material' @@ -42,7 +41,7 @@ import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useChores } from '../../queries/ChoreQueries' import { useNotification } from '../../service/NotificationProvider' -import { ArchiveChore, GetArchivedChores } from '../../utils/Fetcher' +import { ArchiveChore } from '../../utils/Fetcher' import Priorities from '../../utils/Priorities' import LoadingComponent from '../components/Loading' import { useLabels } from '../Labels/LabelQueries' @@ -76,7 +75,6 @@ const MyChores = () => { const { showSuccess, showError, showWarning } = useNotification() const { impersonatedUser } = useImpersonateUser() const [chores, setChores] = useState([]) - const [archivedChores, setArchivedChores] = useState(null) const [filteredChores, setFilteredChores] = useState([]) const [searchFilter, setSearchFilter] = useState('All') const [choreSections, setChoreSections] = useState([]) @@ -366,6 +364,13 @@ const MyChores = () => { } return } + + // Ctrl/Cmd + O to navigate to archived tasks + if (isHoldingCmdOrCtrl && event.key === 'o') { + event.preventDefault() + Navigate('/archived') + return + } } const handleKeyUp = event => { if (!event.ctrlKey && !event.metaKey) { @@ -524,16 +529,6 @@ const MyChores = () => { newFilteredChores = newFilteredChores.filter( chore => chore.id !== updatedChore.id, ) - if (archivedChores !== null) { - setArchivedChores([...archivedChores, updatedChore]) - } - } - if (event === 'unarchive') { - newChores.push(updatedChore) - newFilteredChores.push(updatedChore) - setArchivedChores( - archivedChores.filter(chore => chore.id !== updatedChore.id), - ) } setChores(newChores) setFilteredChores(newFilteredChores) @@ -726,9 +721,8 @@ const MyChores = () => { } const getSelectedChoresData = () => { - const allChores = [...chores, ...(archivedChores || [])] return Array.from(selectedChores) - .map(id => allChores.find(chore => chore.id === id)) + .map(id => chores.find(chore => chore.id === id)) .filter(Boolean) } @@ -822,14 +816,6 @@ const MyChores = () => { title: '📦 Tasks Archived', message: `Successfully archived ${archivedTasks.length} task${archivedTasks.length > 1 ? 's' : ''}.`, }) - // Update archived chores state - setArchivedChores([ - ...(archivedChores || []), - ...archivedTasks.map(c => ({ - ...c, - archived: true, - })), - ]) } if (failedTasks.length > 0) { showError({ @@ -1595,7 +1581,7 @@ const MyChores = () => { Current Filter: {searchFilter} )} - {filteredChores.length === 0 && archivedChores == null && ( + {filteredChores.length === 0 && ( { justifyContent: 'center', mt: 2, }} - > - {archivedChores === null && ( - - - - )} - {archivedChores !== null && ( - <> - - - - {archivedChores?.length} - - - } - > - Archived - - - - {archivedChores?.map(chore => renderChoreCard(chore))} - - )} - + > { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + + switch (option) { + case 'today': { + // Schedule for today at the next available slot: 9am, 12pm, 5pm, or now if after 5pm + const nowHour = now.getHours() + const scheduled = new Date(today) + if (nowHour < 9) { + scheduled.setHours(9, 0, 0, 0) + } else if (nowHour < 12) { + scheduled.setHours(12, 0, 0, 0) + } else if (nowHour < 17) { + scheduled.setHours(17, 0, 0, 0) + } else { + // After 5pm, use current time + scheduled.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds(), + ) + } + return scheduled + } + case 'tomorrow-morning': { + const tomorrowMorning = new Date(today) + tomorrowMorning.setDate(today.getDate() + 1) + tomorrowMorning.setHours(9, 0, 0, 0) + return tomorrowMorning + } + case 'tomorrow': { + const tomorrow = new Date(today) + tomorrow.setDate(today.getDate() + 1) + tomorrow.setHours(12, 0, 0, 0) // Set to noon + return tomorrow + } + case 'tomorrow-afternoon': { + const tomorrowAfternoon = new Date(today) + tomorrowAfternoon.setDate(today.getDate() + 1) + tomorrowAfternoon.setHours(14, 0, 0, 0) + return tomorrowAfternoon + } + case 'weekend': { + const weekend = new Date(today) + const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7 + weekend.setDate(today.getDate() + daysUntilSaturday) + return weekend + } + case 'next-week': { + const nextWeek = new Date(today) + const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7 + nextWeek.setDate(today.getDate() + daysUntilMonday) + return nextWeek + } + default: + return today + } + } + + const handleQuickSchedule = option => { + const date = option === 'remove' ? null : getQuickScheduleDate(option) + UpdateDueDate(chore.id, date).then(response => { + if (response.ok) { + response.json().then(data => { + const newChore = { + ...chore, + nextDueDate: date ? date.toISOString() : null, + } + onChoreUpdate( + newChore, + option === 'remove' ? 'due-date-removed' : 'rescheduled', + ) + }) + } + }) + handleMenuClose() + } + return ( <> + e.stopPropagation()} + > + + { + e.stopPropagation() + handleQuickSchedule('today') + }} + > + + + + + { + e.stopPropagation() + handleQuickSchedule('tomorrow') + }} + > + + + + {/* + { + e.stopPropagation() + handleQuickSchedule('tomorrow-afternoon') + }} + > + + + */} + + { + e.stopPropagation() + handleQuickSchedule('weekend') + }} + > + + + + + { + e.stopPropagation() + handleQuickSchedule('next-week') + }} + > + + + + + { + e.stopPropagation() + handleQuickSchedule('remove') + }} + > + + + + + { e.stopPropagation() diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index 718714b..5e12fda 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -135,6 +135,18 @@ export const parseRepeatV2 = inputSentence => { regex: /(\d+)(?:th|st|nd|rd)? of every month/i, name: 'Every {day} of every month', }, + { + frequencyType: 'days_of_the_week:nth_occurrence', + regex: + /(first|second|third|fourth|last|\d+(?:st|nd|rd|th)?) (monday|tuesday|wednesday|thursday|friday|saturday|sunday) of (?:the )?month/i, + name: '{occurrence} {day} of the month', + }, + { + frequencyType: 'days_of_the_week:nth_occurrence_multiple', + regex: + /((?:first|second|third|fourth|last|\d+(?:st|nd|rd|th)?)(?:,? (?:and |& )?))+\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)s? of (?:the )?month/i, + name: '{occurrences} {day} of the month', + }, { frequencyType: 'daily', regex: /(every day|daily|everyday)/i, @@ -361,6 +373,116 @@ export const parseRepeatV2 = inputSentence => { ], cleanedSentence: inputSentence.replace(match[0], '').trim(), } + + case 'days_of_the_week:nth_occurrence': + const occurrenceText = match[1].toLowerCase() + const dayName = match[2].toLowerCase() + + // Map occurrence words to numbers + const occurrenceMap = { + first: 1, + '1st': 1, + second: 2, + '2nd': 2, + third: 3, + '3rd': 3, + fourth: 4, + '4th': 4, + last: -1, + } + + const occurrence = + occurrenceMap[occurrenceText] || + parseInt(occurrenceText.replace(/\D/g, ''), 10) + + if (!VALID_DAYS[dayName]) { + return { result: null, name: null, cleanedSentence: inputSentence } + } + + result.frequencyType = 'days_of_the_week' + result.frequencyMetadata.days = [VALID_DAYS[dayName].toLowerCase()] + result.frequencyMetadata.weekPattern = 'nth_day_of_month' + result.frequencyMetadata.occurrences = [occurrence] + + const startIndex = inputSentence + .toLowerCase() + .indexOf(match[0].toLowerCase()) + return { + result, + name: pattern.name + .replace('{occurrence}', match[1]) + .replace('{day}', VALID_DAYS[dayName]), + highlight: [ + { + text: inputSentence.substring( + startIndex, + startIndex + match[0].length, + ), + start: startIndex, + end: startIndex + match[0].length, + }, + ], + cleanedSentence: inputSentence.replace(match[0], '').trim(), + } + + case 'days_of_the_week:nth_occurrence_multiple': + const occurrencesText = match[1].toLowerCase() + const dayName2 = match[2].toLowerCase() + + if (!VALID_DAYS[dayName2]) { + return { result: null, name: null, cleanedSentence: inputSentence } + } + + // Parse multiple occurrences like "first, second and third" + const occurrences = occurrencesText + .replace(/,?\s*(and|&)\s*/g, ' ') + .split(/\s+/) + .filter(word => word.trim()) + .map(word => { + const cleanWord = word.replace(',', '').trim() + const occurrenceMap = { + first: 1, + '1st': 1, + second: 2, + '2nd': 2, + third: 3, + '3rd': 3, + fourth: 4, + '4th': 4, + last: -1, + } + return ( + occurrenceMap[cleanWord] || + parseInt(cleanWord.replace(/\D/g, ''), 10) + ) + }) + .filter(num => !isNaN(num)) + + result.frequencyType = 'days_of_the_week' + result.frequencyMetadata.days = [VALID_DAYS[dayName2].toLowerCase()] + result.frequencyMetadata.weekPattern = 'nth_day_of_month' + result.frequencyMetadata.occurrences = occurrences + + const startIndex2 = inputSentence + .toLowerCase() + .indexOf(match[0].toLowerCase()) + return { + result, + name: pattern.name + .replace('{occurrences}', match[1]) + .replace('{day}', VALID_DAYS[dayName2]), + highlight: [ + { + text: inputSentence.substring( + startIndex2, + startIndex2 + match[0].length, + ), + start: startIndex2, + end: startIndex2 + match[0].length, + }, + ], + cleanedSentence: inputSentence.replace(match[0], '').trim(), + } } } return { @@ -375,17 +497,19 @@ export const parseAssignees = (inputSentence, users) => { const sentence = inputSentence.toLowerCase() const result = [] const highlight = [] - - for (const user of users) { - if (sentence.includes(`@${user.username.toLowerCase()}`)) { + // 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()}`)) { result.push(user) const index = inputSentence .toLowerCase() - .indexOf(`@${user.username.toLowerCase()}`) + .indexOf(`@${user.displayName.toLowerCase()}`) highlight.push({ - text: `@${user.username}`, + text: `@${user.displayName}`, start: index, - end: index + user.username.length + 1, + end: index + user.displayName.length + 1, }) } } @@ -395,7 +519,10 @@ export const parseAssignees = (inputSentence, users) => { result, highlight, cleanedSentence: sentence.replace( - new RegExp(`@(${users.map(u => u.username).join('|')})`, 'g'), + new RegExp( + `@(${result.map(u => u.displayName.toLowerCase()).join('|')})`, + 'g', + ), '', ), } diff --git a/src/views/components/NavBar.jsx b/src/views/components/NavBar.jsx index 47fdb49..867959c 100644 --- a/src/views/components/NavBar.jsx +++ b/src/views/components/NavBar.jsx @@ -1,14 +1,13 @@ import Logo from '@/assets/logo.svg' import { - AccountBox, + Archive, + ArrowBack, History, - HomeOutlined, + Inbox, ListAlt, Logout, MenuRounded, - Message, SettingsOutlined, - ShareOutlined, Toll, Widgets, } from '@mui/icons-material' @@ -30,9 +29,14 @@ import ThemeToggleButton from '../Settings/ThemeToggleButton' import NavBarLink from './NavBarLink' const links = [ { - to: '/my/chores', - label: 'Home', - icon: , + to: '/chores', + label: 'All Tasks', + icon: , + }, + { + to: '/archived', + label: 'Archived', + icon: , }, // { @@ -60,21 +64,21 @@ const links = [ label: 'Points', icon: , }, - { - to: '/settings#sharing', - label: 'Sharing', - icon: , - }, - { - to: '/settings#notifications', - label: 'Notifications', - icon: , - }, - { - to: '/settings#account', - label: 'Account', - icon: , - }, + // { + // to: '/settings#sharing', + // label: 'Sharing', + // icon: , + // }, + // { + // to: '/settings#notifications', + // label: 'Notifications', + // icon: , + // }, + // { + // to: '/settings#account', + // label: 'Account', + // icon: , + // }, { to: '/settings', label: 'Settings', @@ -119,16 +123,26 @@ const NavBar = () => { backgroundColor: 'var(--joy-palette-background-body)', }} > - setDrawerOpen(true)}> - - + {['/chores', '/'].includes(location.pathname) ? ( + setDrawerOpen(true)} + > + + + ) : ( + navigate(-1)}> + + + )} { - navigate('/my/chores') + navigate('/chores') }} > - + Logo