diff --git a/src/views/ChoreEdit/RepeatSection.jsx b/src/views/ChoreEdit/RepeatSection.jsx index a7d3c44..4cb4fa9 100644 --- a/src/views/ChoreEdit/RepeatSection.jsx +++ b/src/views/ChoreEdit/RepeatSection.jsx @@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => { return `Every ${dayNames} at ${timeStr}` } -const RepeatOnSections = ({ +export const RepeatOnSections = ({ frequencyType, frequency, onFrequencyUpdate, diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 806a897..1bb2e07 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -1,15 +1,6 @@ -import { Add, EditNotifications } from '@mui/icons-material' -import { - Box, - Button, - Checkbox, - FormHelperText, - Input, - Option, - Select, - Typography, -} from '@mui/joy' -import { FormControl } from '@mui/material' +import { Add } from '@mui/icons-material' +import { Box, Button, Typography } from '@mui/joy' +import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useRef, useState } from 'react' @@ -30,8 +21,15 @@ import { import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' -import NotificationTemplate from '../../components/NotificationTemplate' +import { TASK_COLOR } from '../../utils/Colors' +import AssigneePickerField from './AssigneePickerField' +import AttachmentPickerField from './AttachmentPickerField' +import DueDatePickerField from './DueDatePickerField' +import LabelsPickerField from './LabelsPickerField' import LearnMoreButton from './LearnMore' +import NotificationPickerField from './NotificationPickerField' +import PriorityPickerField from './PriorityPickerField' +import RepeatPickerField from './RepeatPickerField' import RichTextEditor from './RichTextEditor' import SubTasks from './SubTask' const getDefaultNotification = () => { @@ -54,6 +52,8 @@ const getDefaultNotification = () => { const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const { ResponsiveModal } = useResponsiveModal() + const isMobile = useMediaQuery(theme => theme.breakpoints.down('sm')) + const pickerEmptyDisplay = isMobile ? 'icon' : 'icon-text' const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: circleMembers, isLoading: isCircleMembersLoading } = useCircleMembers() @@ -98,7 +98,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [isAnyoneTask, setIsAnyoneTask] = useState(false) const [hasDescription, setHasDescription] = useState(false) const [hasSubTasks, setHasSubTasks] = useState(false) - const [hasNotifications, setHasNotifications] = useState(false) const [hasDeadline, setHasDeadline] = useState(false) const [deadlineOffset, setDeadlineOffset] = useState(-1) const [dueDateOnly, setDueDateOnly] = useState(null) @@ -106,6 +105,24 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const [useCustomTime, setUseCustomTime] = useState(false) const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) const [projectId, setProjectId] = useState(getInitialProject()) + const [attachments, setAttachments] = useState([]) + + // Priority colors + const priorityColors = { + 0: TASK_COLOR.NO_PRIORITY, + 1: TASK_COLOR.PRIORITY_1, + 2: TASK_COLOR.PRIORITY_2, + 3: TASK_COLOR.PRIORITY_3, + 4: TASK_COLOR.PRIORITY_4, + } + + const priorityLabels = { + 0: '--', + 1: 'P1', + 2: 'P2', + 3: 'P3', + 4: 'P4', + } // set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key: useEffect(() => { @@ -262,20 +279,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { resolvedHighlights.push(current) } } else { - // No overlap, add the current highlight resolvedHighlights.push(current) } } for (const highlight of resolvedHighlights) { - // Add the text before the highlight if (highlight.start > lastIndex) { const textBefore = sentence.substring(lastIndex, highlight.start) parts.push(textBefore) plainText += textBefore } - // Determine the class name based on the highlight type let className = '' switch (highlight.type) { case 'repeat': @@ -300,7 +314,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { break } - // Add the highlighted span const highlightedText = sentence.substring( highlight.start, highlight.end, @@ -310,9 +323,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { key={highlight.start} className={className} style={{ - // text underline: textDecoration: 'underline', - // textDecorationColor: 'red', textDecorationThickness: '2px', textDecorationStyle: 'dashed', }} @@ -321,11 +332,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { , ) - // Update the last index to the end of the current highlight lastIndex = highlight.end } - // Add any remaining text after the last highlight if (lastIndex < sentence.length) { const remainingText = sentence.substring(lastIndex) parts.push(remainingText) @@ -342,12 +351,10 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const processText = useCallback( sentence => { - // Parse everything from the original sentence to get correct highlight positions const priority = parsePriority(sentence) const pointsParsed = parsePoints(sentence) const labels = parseLabels(sentence, userLabels || []) - // Parse assignees using circle members const circleMembersList = circleMembers?.res || [] const assigneesForParsing = circleMembersList.map(member => ({ userId: member.userId, @@ -364,9 +371,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const dueDateParsed = parseDueDate(sentence, chrono) // Set all the parsed values - if (priority.result) setPriority(priority.result) + if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) - if (labels.result) setLabelsV2(labels.result) + if (labels.result) { + // parseLabels returns array of label objects, extract their IDs + const labelIds = labels.result + .filter(label => label.id) // Only labels with IDs (existing labels) + .map(label => label.id) + setLabelsV2(labelIds) + } if (assigneesResult.isAnyone) { // @Anyone was used - set empty assignees (anyone can do the task) @@ -500,6 +513,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { moment(`${dateValue}T${dueTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } else { + setUseCustomTime(false) + setDueTime(null) setDueDate(moment(dateValue).endOf('day').format('YYYY-MM-DDTHH:mm:ss')) } } @@ -508,9 +523,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { const timeValue = e.target.value setDueTime(timeValue) if (dueDateOnly) { - setDueDate( - moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), - ) + if (timeValue) { + setUseCustomTime(true) + setDueDate( + moment(`${dueDateOnly}T${timeValue}`).format('YYYY-MM-DDTHH:mm:00'), + ) + } else { + setUseCustomTime(false) + setDueDate( + moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), + ) + } } } @@ -518,13 +541,16 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { setUseCustomTime(checked) if (checked) { const defaultTime = dueTime || '18:00' - setDueTime(defaultTime) + if (!dueTime) { + setDueTime(defaultTime) + } if (dueDateOnly) { setDueDate( moment(`${dueDateOnly}T${defaultTime}`).format('YYYY-MM-DDTHH:mm:00'), ) } } else { + setDueTime(null) if (dueDateOnly) { setDueDate( moment(dueDateOnly).endOf('day').format('YYYY-MM-DDTHH:mm:ss'), @@ -605,6 +631,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { notificationMetadata: {}, subTasks: subTasks?.length > 0 ? subTasks : null, projectId: projectId === 'default' ? null : projectId, + attachments: attachments.length > 0 ? attachments : null, } if (frequency) { @@ -793,6 +820,93 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => { }} /> + + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + + setLabelsV2([])} + labels={userLabels || []} + // emptyDisplay='icon-text' + /> + {/* setProjectId(getInitialProject())} there should be no option to unselect a project, so we don't need an onClear handler + projects={projects || []} + emptyDisplay={pickerEmptyDisplay} + /> */} + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment' + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + {/* Title: { /> */} - + {!hasDescription && ( )} - {!dueDate && ( - - )} - {!hasNotifications && dueDate && ( - - )} + {/* {!hasDeadline && dueDate && ( + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {attachments.length > 0 && ( + + {attachments.map((attachment, index) => ( + + { + e.target.style.display = 'none' + e.target.nextSibling.style.display = 'flex' + }} + /> + + + + + {attachment.name} + + handleRemove(index)} + sx={{ flexShrink: 0 }} + > + + + + ))} + + )} + + + + + + )} + + ) +} + +export default AttachmentPickerField diff --git a/src/views/components/BaseOptionPicker.jsx b/src/views/components/BaseOptionPicker.jsx new file mode 100644 index 0000000..c176020 --- /dev/null +++ b/src/views/components/BaseOptionPicker.jsx @@ -0,0 +1,253 @@ +import { Close } from '@mui/icons-material' +import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const BaseOptionPicker = ({ + items = [], + value = null, + values = [], + multiple = false, + onChange, + onValuesChange, + emptyDisplay = 'icon', + emptyLabel = 'Select', + placement = 'top-start', + menuMinWidth = 180, + menuMaxHeight = 280, + getItemValue = item => item.id, + getItemLabel = item => item.label, + renderItemStart, + renderTriggerIcon, + getItemColor, + getTriggerText, + onClear, +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const selectedItems = useMemo(() => { + if (multiple) { + const selectedSet = new Set(values) + return items.filter(item => selectedSet.has(getItemValue(item))) + } + + if (value === null || value === undefined) return [] + return items.filter(item => getItemValue(item) === value) + }, [items, multiple, value, values, getItemValue]) + + const isEmpty = selectedItems.length === 0 + const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text' + + const triggerText = getTriggerText + ? getTriggerText({ selectedItems, isEmpty }) + : isEmpty + ? emptyLabel + : getItemLabel(selectedItems[0]) + + const triggerColor = isEmpty + ? undefined + : getItemColor + ? getItemColor(selectedItems[0]) + : undefined + + const handleSelect = selectedValue => { + if (multiple) { + const selectedSet = new Set(values) + if (selectedSet.has(selectedValue)) { + selectedSet.delete(selectedValue) + } else { + selectedSet.add(selectedValue) + } + onValuesChange?.(Array.from(selectedSet)) + return + } + + onChange?.(selectedValue) + setIsOpen(false) + } + + const isSelected = item => { + const optionValue = getItemValue(item) + if (multiple) { + return values.includes(optionValue) + } + return value === optionValue + } + + const handleClear = e => { + e.stopPropagation() + onClear?.() + } + + return ( + <> + + + {!isEmpty && onClear && ( + + + + )} + + + {isOpen && ( + + setIsOpen(false)}> + + {items.map((item, index) => { + const optionValue = getItemValue(item) + const selected = isSelected(item) + const itemColor = getItemColor ? getItemColor(item) : undefined + + return ( + + ) + })} + + + + )} + + ) +} + +export default BaseOptionPicker diff --git a/src/views/components/DueDatePickerField.jsx b/src/views/components/DueDatePickerField.jsx new file mode 100644 index 0000000..6410f8e --- /dev/null +++ b/src/views/components/DueDatePickerField.jsx @@ -0,0 +1,304 @@ +import { + CalendarMonth, + Close, + NextWeek, + Today, + WbSunny, + Weekend, +} from '@mui/icons-material' +import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import moment from 'moment' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const DueDatePickerField = ({ + dueDateOnly, + dueTime, + useCustomTime, + onDueDateChange, + onDueTimeChange, + onUseCustomTimeChange, + onClear, + emptyDisplay = 'icon-text', + size = 'sm', +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + const getQuickScheduleDate = option => { + const now = new Date() + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + + switch (option) { + case 'today': + return today + case 'tomorrow': { + const tomorrow = new Date(today) + tomorrow.setDate(today.getDate() + 1) + return tomorrow + } + 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 = getQuickScheduleDate(option) + const dateStr = date.toISOString().split('T')[0] + onDueDateChange?.({ target: { value: dateStr } }) + setIsOpen(false) + } + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const hasDueDate = Boolean(dueDateOnly) + const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text' + + const dueDateLabel = useMemo(() => { + if (!dueDateOnly) { + return 'Due' + } + + const formattedDate = moment(dueDateOnly).format('MMM D') + if (useCustomTime && dueTime) { + return `${formattedDate}, ${dueTime}` + } + + return formattedDate + }, [dueDateOnly, dueTime, useCustomTime]) + + return ( + + + {hasDueDate && onClear && ( + { + e.stopPropagation() + onClear?.() + }} + sx={{ + position: 'absolute', + top: -12, + right: -16, + zIndex: 10, + maxHeight: 18, + maxWidth: 18, + borderRadius: '50%', + '&:hover': { + bgcolor: 'danger.softBg', + }, + }} + > + + + )} + + {isOpen && ( + + setIsOpen(false)}> + + + Due Date + + + + + + + + + + Due time (optional) + + { + if (!useCustomTime) { + onUseCustomTimeChange?.(true) + } + onDueTimeChange?.(e) + }} + sx={{ maxWidth: 200, mb: 1 }} + /> + + + + + {hasDueDate && ( + + )} + + + + )} + + ) +} + +export default DueDatePickerField diff --git a/src/views/components/DueDatePickerPreview.jsx b/src/views/components/DueDatePickerPreview.jsx new file mode 100644 index 0000000..989b59a --- /dev/null +++ b/src/views/components/DueDatePickerPreview.jsx @@ -0,0 +1,221 @@ +import { CalendarMonth, Close } from '@mui/icons-material' +import { Box, Button, IconButton, Input, Sheet, Typography } from '@mui/joy' +import { ClickAwayListener, Popper } from '@mui/material' +import moment from 'moment' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Z_INDEX } from '../../constants/zIndex' + +const DueDatePickerPreview = ({ + dueDateOnly, + dueTime, + useCustomTime, + onDueDateChange, + onDueTimeChange, + onUseCustomTimeChange, + onClear, + emptyDisplay = 'icon-text', + size = 'sm', +}) => { + const [isOpen, setIsOpen] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + const handleEscape = event => { + if (event.key === 'Escape') { + setIsOpen(false) + } + } + + document.addEventListener('keydown', handleEscape) + return () => { + document.removeEventListener('keydown', handleEscape) + } + }, [isOpen]) + + const hasDueDate = Boolean(dueDateOnly) + const shouldShowLabel = hasDueDate || emptyDisplay === 'icon-text' + + const dueDateLabel = useMemo(() => { + if (!dueDateOnly) { + return 'Due' + } + + const formattedDate = moment(dueDateOnly).format('MMM D') + if (useCustomTime && dueTime) { + return `${formattedDate}, ${dueTime}` + } + + return formattedDate + }, [dueDateOnly, dueTime, useCustomTime]) + + return ( + + + {hasDueDate && onClear && ( + { + e.stopPropagation() + onClear?.() + }} + sx={{ + position: 'absolute', + top: -12, + right: -16, + zIndex: 10, + maxHeight: 18, + maxWidth: 18, + borderRadius: '50%', + '&:hover': { + bgcolor: 'danger.softBg', + }, + }} + > + + + )} + + {isOpen && ( + + setIsOpen(false)}> + + + Due Date + + + + Due time (optional) + + { + if (!useCustomTime) { + onUseCustomTimeChange?.(true) + } + onDueTimeChange?.(e) + }} + sx={{ maxWidth: 200, mb: 1 }} + /> + + + + + {hasDueDate && ( + + )} + + + + )} + + ) +} + +export default DueDatePickerPreview diff --git a/src/views/components/LabelsPickerField.jsx b/src/views/components/LabelsPickerField.jsx new file mode 100644 index 0000000..bf74602 --- /dev/null +++ b/src/views/components/LabelsPickerField.jsx @@ -0,0 +1,48 @@ +import { Label } from '@mui/icons-material' +import BaseOptionPicker from './BaseOptionPicker' + +const LabelsPickerField = ({ + values = [], + onChange, + onClear, + labels = [], + emptyDisplay = 'icon-text', +}) => { + const options = labels.map(label => ({ + id: label.id, + name: label.name, + color: label.color, + })) + + return ( + item.id} + getItemLabel={item => item.name} + getItemColor={item => item.color} + renderTriggerIcon={() =>