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 && (
}
- variant='plain'
- size='sm'
+ variant='outlined'
+ color='neutral'
+ size='md'
onClick={() => {
setHasDescription(true)
// Focus will be handled by the useEffect hook
@@ -823,8 +938,9 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
{!hasSubTasks && (
}
- variant='plain'
- size='sm'
+ variant='outlined'
+ color='neutral'
+ size='md'
onClick={() => {
setHasSubTasks(true)
}}
@@ -835,39 +951,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
Subtasks
)}
- {!dueDate && (
- }
- variant='plain'
- size='sm'
- onClick={() => {
- const tomorrow = moment().add(1, 'day')
- setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
- setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:ss'))
- setUseCustomTime(false)
- setDueTime(null)
- }}
- endDecorator={
- showKeyboardShortcuts &&
- }
- >
- Due Date
-
- )}
- {!hasNotifications && dueDate && (
- }
- variant='plain'
- size='sm'
- onClick={() => {
- setHasNotifications(true)
- setFrequencyHumanReadable('Once')
- setFrequency(null)
- }}
- >
- Edit Notifications
-
- )}
+
{/* {!hasDeadline && dueDate && (
}
@@ -907,61 +991,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
)}
-
- {priority > 0 && (
-
- Priority
-
-
- )}
- {dueDate && (
-
- Due Date
-
- handleUseCustomTimeChange(e.target.checked)}
- label='Set a specific time'
- sx={{ mt: 1 }}
- />
-
- {useCustomTime
- ? 'Task will be due at the specified time'
- : 'Task will be due at the end of the day (11:59 PM)'}
-
- {useCustomTime && (
-
- )}
-
- )}
-
{/* {projects.length >= 1 && (
Project
@@ -1082,33 +1111,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
)} */}
- {hasNotifications && dueDate && (
-
- Notification Schedule
-
- {
- if (
- metadata.notifications !== notificationMetadata.templates
- ) {
- const newNotificationMetadata = {
- ...notificationMetadata,
- templates: metadata.notifications,
- }
- setNotificationMetadata(newNotificationMetadata)
- }
- }}
- value={notificationMetadata}
- showTimeline={false}
- />
-
-
- )}
)
diff --git a/src/views/components/AssigneePickerField.jsx b/src/views/components/AssigneePickerField.jsx
new file mode 100644
index 0000000..b9444c7
--- /dev/null
+++ b/src/views/components/AssigneePickerField.jsx
@@ -0,0 +1,43 @@
+import { Person } from '@mui/icons-material'
+import BaseOptionPicker from './BaseOptionPicker'
+
+const AssigneePickerField = ({
+ value = null,
+ onChange,
+ onClear,
+ members = [],
+ includeAnyone = true,
+ emptyDisplay,
+ currentUserId = null,
+}) => {
+ const options = [
+ ...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
+ ...members.map(member => ({
+ userId: member.userId,
+ displayName: member.displayName || member.username || 'Unknown',
+ })),
+ ]
+
+ const displayValue = currentUserId && value === currentUserId ? null : value
+
+ return (
+ item.userId}
+ getItemLabel={item => item.displayName}
+ renderTriggerIcon={() => }
+ renderItemStart={() => }
+ getTriggerText={({ selectedItems, isEmpty }) =>
+ isEmpty ? 'Assignee' : selectedItems[0].displayName
+ }
+ menuMinWidth={220}
+ />
+ )
+}
+
+export default AssigneePickerField
diff --git a/src/views/components/AssigneePickerPreview.jsx b/src/views/components/AssigneePickerPreview.jsx
new file mode 100644
index 0000000..82330e3
--- /dev/null
+++ b/src/views/components/AssigneePickerPreview.jsx
@@ -0,0 +1,40 @@
+import { Person } from '@mui/icons-material'
+import BaseOptionPicker from './BaseOptionPicker'
+
+const AssigneePickerPreview = ({
+ value = null,
+ onChange,
+ onClear,
+ members = [],
+ includeAnyone = true,
+ emptyDisplay,
+}) => {
+ const options = [
+ ...(includeAnyone ? [{ userId: 'anyone', displayName: 'Anyone' }] : []),
+ ...members.map(member => ({
+ userId: member.userId,
+ displayName: member.displayName || member.username || 'Unknown',
+ })),
+ ]
+
+ return (
+ item.userId}
+ getItemLabel={item => item.displayName}
+ renderTriggerIcon={() => }
+ renderItemStart={() => }
+ getTriggerText={({ selectedItems, isEmpty }) =>
+ isEmpty ? 'Assignee' : selectedItems[0].displayName
+ }
+ menuMinWidth={220}
+ />
+ )
+}
+
+export default AssigneePickerPreview
diff --git a/src/views/components/AttachmentPickerField.jsx b/src/views/components/AttachmentPickerField.jsx
new file mode 100644
index 0000000..6f9c385
--- /dev/null
+++ b/src/views/components/AttachmentPickerField.jsx
@@ -0,0 +1,258 @@
+import { AttachFile, Close, DeleteOutline, Image } from '@mui/icons-material'
+import {
+ Box,
+ Button,
+ CircularProgress,
+ IconButton,
+ Sheet,
+ Typography,
+} from '@mui/joy'
+import { ClickAwayListener, Popper } from '@mui/material'
+import { useEffect, useRef, useState } from 'react'
+import { Z_INDEX } from '../../constants/zIndex'
+import { useFileUpload } from '../../hooks/useFileUpload'
+
+const AttachmentPickerField = ({
+ attachments = [],
+ onChange,
+ onClear,
+ emptyDisplay = 'icon-text',
+ entityType = 'chore_attachment',
+ entityId,
+}) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const [isUploading, setIsUploading] = useState(false)
+ const buttonRef = useRef(null)
+ const { uploadFile } = useFileUpload({ entityType, entityId })
+
+ useEffect(() => {
+ if (!isOpen) return
+ const handleEscape = e => {
+ if (e.key === 'Escape') setIsOpen(false)
+ }
+ document.addEventListener('keydown', handleEscape)
+ return () => document.removeEventListener('keydown', handleEscape)
+ }, [isOpen])
+
+ const handleAddFile = () => {
+ const input = document.createElement('input')
+ input.setAttribute('type', 'file')
+ input.setAttribute('accept', 'image/*')
+ input.click()
+ input.onchange = async () => {
+ const file = input.files?.[0]
+ if (!file) return
+ setIsUploading(true)
+ try {
+ const url = await uploadFile(file)
+ if (url) {
+ onChange([...attachments, { url, name: file.name }])
+ }
+ } finally {
+ setIsUploading(false)
+ }
+ }
+ }
+
+ const handleRemove = index => {
+ const updated = attachments.filter((_, i) => i !== index)
+ onChange(updated)
+ if (updated.length === 0) setIsOpen(false)
+ }
+
+ const handleClear = e => {
+ e.stopPropagation()
+ onClear?.()
+ setIsOpen(false)
+ }
+
+ const isEmpty = attachments.length === 0
+ const shouldShowLabel = !isEmpty || emptyDisplay === 'icon-text'
+
+ return (
+ <>
+
+
+ {!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 }}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+ ) : (
+
+ )
+ }
+ onClick={handleAddFile}
+ disabled={isUploading}
+ >
+ {isUploading ? 'Uploading…' : 'Add image'}
+
+
+
+
+ )}
+ >
+ )
+}
+
+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
+
+
+ }
+ onClick={() => handleQuickSchedule('today')}
+ >
+ Today
+
+ }
+ onClick={() => handleQuickSchedule('tomorrow')}
+ >
+ Tomorrow
+
+ }
+ onClick={() => handleQuickSchedule('weekend')}
+ >
+ Weekend
+
+ }
+ onClick={() => handleQuickSchedule('next-week')}
+ >
+ Next week
+
+
+
+
+ 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={() => }
+ renderItemStart={({ item }) => (
+
+ )}
+ getTriggerText={({ selectedItems, isEmpty }) => {
+ if (isEmpty) return 'Labels'
+ if (selectedItems.length === 1) return selectedItems[0].name
+ return `${selectedItems.length} labels`
+ }}
+ menuMinWidth={220}
+ />
+ )
+}
+
+export default LabelsPickerField
diff --git a/src/views/components/NotificationPickerField.jsx b/src/views/components/NotificationPickerField.jsx
new file mode 100644
index 0000000..cb51134
--- /dev/null
+++ b/src/views/components/NotificationPickerField.jsx
@@ -0,0 +1,160 @@
+import { Close, NotificationsNone } from '@mui/icons-material'
+import { Box, Button, IconButton, Typography } from '@mui/joy'
+import { useEffect, useRef, useState } from 'react'
+import NotificationTemplate from '../../components/NotificationTemplate'
+import { useResponsiveModal } from '../../hooks/useResponsiveModal'
+
+const getDisplayLabel = templates => {
+ if (!templates || templates.length === 0) return 'Remind'
+ const count = templates.length
+ if (count === 1) {
+ const n = templates[0]
+ const numericValue = Number(n.value)
+ if (numericValue === 0) return 'On due date'
+ const unitName =
+ n.unit === 'm' ? 'min' : n.unit === 'h' ? 'hr' : 'day'
+ const absValue = Math.abs(numericValue)
+ const plural = absValue !== 1 ? 's' : ''
+ return `${absValue} ${unitName}${plural} ${numericValue < 0 ? 'before' : 'after'}`
+ }
+ return `${count} reminders`
+}
+
+const NotificationPickerField = ({
+ value,
+ onChange,
+ onClear,
+ emptyDisplay = 'icon-text',
+ size = 'sm',
+}) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const latestTemplatesRef = useRef(value?.templates || [])
+ const { ResponsiveModal } = useResponsiveModal()
+
+ useEffect(() => {
+ if (isOpen) {
+ latestTemplatesRef.current = value?.templates || []
+ }
+ }, [isOpen, value])
+
+ const templates = value?.templates || []
+ const hasNotifications = templates.length > 0
+ const shouldShowLabel = hasNotifications || emptyDisplay === 'icon-text'
+ const displayLabel = getDisplayLabel(templates)
+
+ const handleSave = () => {
+ onChange({ ...value, templates: latestTemplatesRef.current })
+ setIsOpen(false)
+ }
+
+ const footer = (
+
+ {hasNotifications && (
+
+ )}
+
+
+
+ )
+
+ return (
+ <>
+
+
+
+ {hasNotifications && onClear && (
+ {
+ e.stopPropagation()
+ onClear?.()
+ }}
+ sx={{
+ position: 'absolute',
+ top: -12,
+ right: -16,
+ zIndex: 10,
+ maxHeight: 18,
+ maxWidth: 18,
+ borderRadius: '50%',
+ '&:hover': { bgcolor: 'danger.softBg' },
+ }}
+ >
+
+
+ )}
+
+
+ setIsOpen(false)}
+ title='Reminders'
+ footer={footer}
+ >
+ {
+ latestTemplatesRef.current = notifications
+ }}
+ showTimeline
+ />
+
+ >
+ )
+}
+
+export default NotificationPickerField
diff --git a/src/views/components/PriorityPickerField.jsx b/src/views/components/PriorityPickerField.jsx
new file mode 100644
index 0000000..b4eca5e
--- /dev/null
+++ b/src/views/components/PriorityPickerField.jsx
@@ -0,0 +1,74 @@
+import { Flag } from '@mui/icons-material'
+import BaseOptionPicker from './BaseOptionPicker'
+
+const defaultPriorityColors = {
+ 0: '#9CA3AF',
+ 1: '#EF4444',
+ 2: '#F97316',
+ 3: '#FBBF24',
+ 4: '#3B82F6',
+}
+
+const defaultPriorityLabels = {
+ 0: 'No Priority',
+ 1: 'P1',
+ 2: 'P2',
+ 3: 'P3',
+ 4: 'P4',
+}
+
+const PriorityPickerField = ({
+ value = 0,
+ onChange,
+ onClear,
+ emptyDisplay = 'icon-text',
+ priorityColors = defaultPriorityColors,
+ priorityLabels = defaultPriorityLabels,
+ size = 'sm',
+}) => {
+ const options = [1, 2, 3, 4].map(priorityOption => ({
+ id: priorityOption,
+ label: priorityLabels[priorityOption],
+ color: priorityColors[priorityOption],
+ }))
+
+ // Don't add the 0 option to the menu - priority 0 is the "empty" state (icon only)
+
+ return (
+ item.id}
+ getItemLabel={item => item.label}
+ getItemColor={item => item.color}
+ getTriggerText={({ selectedItems, isEmpty }) => {
+ // For priority 0 (no priority), show empty string (icon only)
+ if (value === 0 || isEmpty) return 'Priority'
+ return selectedItems[0]?.label || ''
+ }}
+ renderTriggerIcon={({ selectedItems, isEmpty }) => (
+
+ )}
+ renderItemStart={({ item }) => (
+
+ )}
+ menuMinWidth={180}
+ />
+ )
+}
+
+export default PriorityPickerField
diff --git a/src/views/components/ProjectPickerField.jsx b/src/views/components/ProjectPickerField.jsx
new file mode 100644
index 0000000..2ca9cb0
--- /dev/null
+++ b/src/views/components/ProjectPickerField.jsx
@@ -0,0 +1,48 @@
+import { FolderOpen } from '@mui/icons-material'
+import BaseOptionPicker from './BaseOptionPicker'
+
+const ProjectPickerField = ({
+ value = 'default',
+ onChange,
+ onClear,
+ projects = [],
+ emptyDisplay = 'icon-text',
+}) => {
+ const options = [
+ { id: 'default', name: 'Default Project', color: '#9CA3AF' },
+ ...projects.map(project => ({
+ id: project.id,
+ name: project.name,
+ color: project.color,
+ })),
+ ]
+
+ return (
+ item.id}
+ getItemLabel={item => item.name}
+ getItemColor={item => item.color}
+ renderTriggerIcon={() => }
+ renderItemStart={({ item }) => (
+
+ )}
+ getTriggerText={({ selectedItems, isEmpty }) =>
+ isEmpty ? 'Project' : selectedItems[0].name
+ }
+ menuMinWidth={240}
+ />
+ )
+}
+
+export default ProjectPickerField
diff --git a/src/views/components/RepeatPickerField.jsx b/src/views/components/RepeatPickerField.jsx
new file mode 100644
index 0000000..f4f9299
--- /dev/null
+++ b/src/views/components/RepeatPickerField.jsx
@@ -0,0 +1,639 @@
+import { Close, Repeat } from '@mui/icons-material'
+import {
+ Box,
+ Button,
+ Checkbox,
+ Divider,
+ IconButton,
+ Input,
+ List,
+ ListItem,
+ Radio,
+ RadioGroup,
+ Typography,
+} from '@mui/joy'
+import moment from 'moment'
+import { useEffect, useState } from 'react'
+import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
+import { useResponsiveModal } from '../../hooks/useResponsiveModal'
+
+const FREQUENCY_TYPES = [
+ 'daily',
+ 'weekly',
+ 'monthly',
+ 'yearly',
+ 'adaptive',
+ 'custom',
+]
+const REPEAT_ON_TYPE = ['interval', 'days_of_the_week', 'day_of_the_month']
+
+const DAYS = [
+ 'monday',
+ 'tuesday',
+ 'wednesday',
+ 'thursday',
+ 'friday',
+ 'saturday',
+ 'sunday',
+]
+
+const MONTHS = [
+ 'january',
+ 'february',
+ 'march',
+ 'april',
+ 'may',
+ 'june',
+ 'july',
+ 'august',
+ 'september',
+ 'october',
+ 'november',
+ 'december',
+]
+
+const OCCURRENCE_OPTIONS = [
+ { value: 1, label: '1st' },
+ { value: 2, label: '2nd' },
+ { value: 3, label: '3rd' },
+ { value: 4, label: '4th' },
+ { value: -1, label: 'Last' },
+]
+
+const defaultMetadata = () => ({
+ unit: 'days',
+ time: moment(moment(new Date()).format('YYYY-MM-DD') + 'T18:00').format(),
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+})
+
+const initLocalState = value => {
+ if (!value) {
+ return {
+ frequencyType: 'daily',
+ frequency: 1,
+ frequencyMetadata: defaultMetadata(),
+ }
+ }
+
+ let { frequencyType, frequency, frequencyMetadata } = value
+
+ // Normalize parser output: interval/1/days → daily, etc.
+ if (frequencyType === 'interval' && frequency === 1) {
+ const unitTypeMap = {
+ days: 'daily',
+ weeks: 'weekly',
+ months: 'monthly',
+ years: 'yearly',
+ }
+ frequencyType = unitTypeMap[frequencyMetadata?.unit] || frequencyType
+ }
+
+ return {
+ frequencyType,
+ frequency: frequency ?? 1,
+ frequencyMetadata: {
+ ...defaultMetadata(),
+ ...frequencyMetadata,
+ },
+ }
+}
+
+const getDisplayType = frequencyType =>
+ REPEAT_ON_TYPE.includes(frequencyType) ? 'custom' : frequencyType
+
+// Shared section label
+const SectionLabel = ({ children }) => (
+
+ {children}
+
+)
+
+// Shared time-of-day picker
+const TimeRow = ({ metadata, onUpdate }) => (
+
+ Time of day
+
+ onUpdate({
+ ...metadata,
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ time: moment(
+ moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
+ ).format(),
+ })
+ }
+ sx={{ width: 120 }}
+ />
+
+)
+
+const pillListSx = {
+ '--List-gap': '8px',
+ '--ListItem-radius': '20px',
+}
+
+// Interval section
+const IntervalSection = ({
+ frequency,
+ frequencyMetadata,
+ onFrequencyUpdate,
+ onFrequencyMetadataUpdate,
+}) => (
+
+ Repeat every
+
+
+ onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
+ }
+ sx={{ width: 72 }}
+ slotProps={{ input: { min: 1, max: 999 } }}
+ />
+
+ {['days', 'weeks', 'months', 'years'].map(unit => (
+
+
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
+ }
+ overlay
+ disableIcon
+ variant='soft'
+ label={unit.charAt(0).toUpperCase() + unit.slice(1)}
+ />
+
+ ))}
+
+
+
+
+)
+
+// Days of week section
+const DaysOfWeekSection = ({
+ frequencyMetadata,
+ onFrequencyMetadataUpdate,
+}) => {
+ const selectedDays = frequencyMetadata?.days || []
+ const weekPattern = frequencyMetadata?.weekPattern || 'every_week'
+ const selectedOccurrences = frequencyMetadata?.occurrences || []
+
+ const toggleDay = day => {
+ const next = selectedDays.includes(day)
+ ? selectedDays.filter(d => d !== day)
+ : [...selectedDays, day]
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, days: next })
+ }
+
+ const toggleOccurrence = val => {
+ const next = selectedOccurrences.includes(val)
+ ? selectedOccurrences.filter(v => v !== val)
+ : [...selectedOccurrences, val]
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, occurrences: next })
+ }
+
+ return (
+
+ Days
+
+ {DAYS.map(day => (
+
+ toggleDay(day)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
+ />
+
+ ))}
+
+
+
+ Pattern
+
+ onFrequencyMetadataUpdate({
+ ...frequencyMetadata,
+ weekPattern: e.target.value,
+ occurrences:
+ e.target.value === 'every_week' ? [] : selectedOccurrences,
+ })
+ }
+ sx={{
+ padding: '3px',
+ borderRadius: '10px',
+ bgcolor: 'neutral.softBg',
+ '--RadioGroup-gap': '3px',
+ '--Radio-actionRadius': '7px',
+ display: 'inline-flex',
+ }}
+ >
+ {[
+ { value: 'every_week', label: 'Every week' },
+ { value: 'week_of_month', label: 'Specific weeks' },
+ ].map(opt => (
+ ({
+ sx: checked
+ ? {
+ bgcolor: 'background.surface',
+ boxShadow: 'sm',
+ '&:hover': { bgcolor: 'background.surface' },
+ }
+ : {},
+ }),
+ }}
+ />
+ ))}
+
+
+
+ {weekPattern === 'week_of_month' && (
+
+ Occurrences
+
+ {OCCURRENCE_OPTIONS.map(opt => (
+
+ toggleOccurrence(opt.value)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={opt.label}
+ />
+
+ ))}
+
+
+ )}
+
+
+
+ )
+}
+
+// Day of month section
+const DayOfMonthSection = ({
+ frequency,
+ frequencyMetadata,
+ onFrequencyUpdate,
+ onFrequencyMetadataUpdate,
+}) => {
+ const selectedMonths = frequencyMetadata?.months || []
+
+ const toggleMonth = month => {
+ const next = selectedMonths.includes(month)
+ ? selectedMonths.filter(m => m !== month)
+ : [...selectedMonths, month]
+ onFrequencyMetadataUpdate({ ...frequencyMetadata, months: next })
+ }
+
+ return (
+
+ Months
+
+ {MONTHS.map(month => (
+
+ toggleMonth(month)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
+ />
+
+ ))}
+
+
+
+ Day of month
+ {
+ const v = Math.min(
+ 31,
+ Math.max(1, parseInt(e.target.value, 10) || 1),
+ )
+ onFrequencyUpdate(v)
+ }}
+ sx={{ width: 72 }}
+ slotProps={{ input: { min: 1, max: 31 } }}
+ />
+
+
+
+
+ )
+}
+
+const RepeatPickerField = ({
+ value,
+ onChange,
+ onClear,
+ emptyDisplay = 'icon-text',
+ size = 'sm',
+}) => {
+ const [isOpen, setIsOpen] = useState(false)
+ const [localFrequencyType, setLocalFrequencyType] = useState('daily')
+ const [localFrequency, setLocalFrequency] = useState(1)
+ const [localFrequencyMetadata, setLocalFrequencyMetadata] =
+ useState(defaultMetadata)
+ const { ResponsiveModal } = useResponsiveModal()
+
+ useEffect(() => {
+ if (!isOpen) return
+ const init = initLocalState(value)
+ setLocalFrequencyType(init.frequencyType)
+ setLocalFrequency(init.frequency)
+ setLocalFrequencyMetadata(init.frequencyMetadata)
+ }, [isOpen, value])
+
+ const hasRepeat = Boolean(value)
+ const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
+ const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
+ const displayType = getDisplayType(localFrequencyType)
+
+ const handleTypeSelect = type => {
+ if (type === 'custom') {
+ setLocalFrequencyType('interval')
+ setLocalFrequency(1)
+ setLocalFrequencyMetadata({ ...defaultMetadata(), unit: 'days' })
+ } else {
+ setLocalFrequencyType(type)
+ setLocalFrequency(1)
+ }
+ }
+
+ const handleSubTypeSelect = newType => {
+ setLocalFrequencyType(newType)
+ if (newType === 'interval') {
+ setLocalFrequency(1)
+ setLocalFrequencyMetadata(prev => ({ ...prev, unit: 'days' }))
+ } else if (newType === 'days_of_the_week') {
+ setLocalFrequencyMetadata(prev => ({
+ ...prev,
+ days: [],
+ weekPattern: 'every_week',
+ occurrences: [],
+ }))
+ } else if (newType === 'day_of_the_month') {
+ setLocalFrequency(1)
+ setLocalFrequencyMetadata(prev => ({ ...prev, months: [] }))
+ }
+ }
+
+ const handleSave = () => {
+ onChange({
+ frequencyType: localFrequencyType,
+ frequency: localFrequency,
+ frequencyMetadata: localFrequencyMetadata,
+ })
+ setIsOpen(false)
+ }
+
+ return (
+ <>
+
+
+
+ {hasRepeat && onClear && (
+ {
+ e.stopPropagation()
+ onClear?.()
+ }}
+ sx={{
+ position: 'absolute',
+ top: -12,
+ right: -16,
+ zIndex: 10,
+ maxHeight: 18,
+ maxWidth: 18,
+ borderRadius: '50%',
+ '&:hover': { bgcolor: 'danger.softBg' },
+ }}
+ >
+
+
+ )}
+
+
+ setIsOpen(false)}
+ title='Repeat Schedule'
+ footer={
+
+ {hasRepeat && (
+
+ )}
+
+
+
+ }
+ >
+ {/* Frequency type selector */}
+
+
+ Frequency
+
+ {FREQUENCY_TYPES.map(type => (
+
+ handleTypeSelect(type)}
+ overlay
+ disableIcon
+ variant='soft'
+ label={type.charAt(0).toUpperCase() + type.slice(1)}
+ />
+
+ ))}
+
+
+
+ {/* Custom sub-type + detail panel */}
+ {displayType === 'custom' && (
+ <>
+
+ Schedule type
+ handleSubTypeSelect(e.target.value)}
+ sx={{
+ padding: '3px',
+ borderRadius: '10px',
+ bgcolor: 'neutral.softBg',
+ '--RadioGroup-gap': '3px',
+ '--Radio-actionRadius': '7px',
+ display: 'inline-flex',
+ }}
+ >
+ {REPEAT_ON_TYPE.map(type => (
+
+ i === 0 || i === arr.length - 1
+ ? w.charAt(0).toUpperCase() + w.slice(1)
+ : w,
+ )
+ .join(' ')}
+ variant='plain'
+ sx={{ px: 1.5, py: 0.5 }}
+ slotProps={{
+ action: ({ checked }) => ({
+ sx: checked
+ ? {
+ bgcolor: 'background.surface',
+ boxShadow: 'sm',
+ '&:hover': { bgcolor: 'background.surface' },
+ }
+ : {},
+ }),
+ }}
+ />
+ ))}
+
+
+
+
+
+ {localFrequencyType === 'interval' && (
+
+ )}
+ {localFrequencyType === 'days_of_the_week' && (
+
+ )}
+ {localFrequencyType === 'day_of_the_month' && (
+
+ )}
+ >
+ )}
+
+
+ >
+ )
+}
+
+export default RepeatPickerField
diff --git a/src/views/components/RepeatPickerPreview.jsx b/src/views/components/RepeatPickerPreview.jsx
new file mode 100644
index 0000000..5adb0bc
--- /dev/null
+++ b/src/views/components/RepeatPickerPreview.jsx
@@ -0,0 +1,211 @@
+import { Close, Repeat } from '@mui/icons-material'
+import { Box, Button, IconButton, Sheet, Typography } from '@mui/joy'
+import { ClickAwayListener, Popper } from '@mui/material'
+import { useEffect, useRef, useState } from 'react'
+import { Z_INDEX } from '../../constants/zIndex'
+import { getRecurrentChipText } from '../../utils/ChoreCardHelpers'
+
+const REPEAT_PRESETS = [
+ {
+ id: 'daily',
+ label: 'Daily',
+ frequencyType: 'interval',
+ frequency: 1,
+ frequencyMetadata: { unit: 'days' },
+ },
+ {
+ id: 'weekly',
+ label: 'Weekly',
+ frequencyType: 'interval',
+ frequency: 1,
+ frequencyMetadata: { unit: 'weeks' },
+ },
+ {
+ id: 'monthly',
+ label: 'Monthly',
+ frequencyType: 'interval',
+ frequency: 1,
+ frequencyMetadata: { unit: 'months' },
+ },
+ {
+ id: 'yearly',
+ label: 'Yearly',
+ frequencyType: 'interval',
+ frequency: 1,
+ frequencyMetadata: { unit: 'years' },
+ },
+]
+
+const matchPreset = value => {
+ if (!value) return null
+ return (
+ REPEAT_PRESETS.find(
+ p =>
+ p.frequencyType === value.frequencyType &&
+ p.frequency === value.frequency &&
+ p.frequencyMetadata?.unit === value.frequencyMetadata?.unit,
+ ) || null
+ )
+}
+
+const RepeatPickerPreview = ({
+ value,
+ onChange,
+ 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 hasRepeat = Boolean(value)
+ const selectedPreset = matchPreset(value)
+ const shouldShowLabel = hasRepeat || emptyDisplay === 'icon-text'
+ const displayLabel = hasRepeat ? getRecurrentChipText(value) : 'Repeat'
+
+ return (
+
+
+
+ {hasRepeat && 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)}>
+
+ {REPEAT_PRESETS.map((preset, index) => {
+ const isSelected = selectedPreset?.id === preset.id
+ return (
+
+ )
+ })}
+ {hasRepeat && (
+
+ )}
+
+
+
+ )}
+
+ )
+}
+
+export default RepeatPickerPreview