feat: add DueDatePickerPreview component for managing due dates and times

feat: implement LabelsPickerField component for selecting multiple labels

feat: create NotificationPickerField component for managing notification reminders

feat: add PriorityPickerField component for selecting task priority levels

feat: implement ProjectPickerField component for selecting projects

feat: create RepeatPickerField component for setting up recurring tasks

feat: add RepeatPickerPreview component for displaying and selecting repeat options
This commit is contained in:
Mo Tarbin
2026-07-02 20:51:53 -04:00
parent 0da9c7f965
commit f11a1fd9cc
14 changed files with 2452 additions and 151 deletions

View File

@@ -108,7 +108,7 @@ const generateSchedulePreview = (metadata, formatTimeFn) => {
return `Every ${dayNames} at ${timeStr}`
}
const RepeatOnSections = ({
export const RepeatOnSections = ({
frequencyType,
frequency,
onFrequencyUpdate,

View File

@@ -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 }) => {
</span>,
)
// 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 }) => {
}}
/>
</Box>
<Box
sx={{
paddingTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
// scrollable horizontally but hide the scrollbar:
overflowX: 'auto',
'&::-webkit-scrollbar': {
display: 'none',
},
}}
>
<DueDatePickerField
emptyDisplay={pickerEmptyDisplay}
dueDateOnly={dueDateOnly}
dueTime={dueTime}
useCustomTime={useCustomTime}
onDueDateChange={handleDueDateChange}
onDueTimeChange={handleDueTimeChange}
onUseCustomTimeChange={handleUseCustomTimeChange}
onClear={() => {
setDueDate(null)
setDueDateOnly(null)
setDueTime(null)
setUseCustomTime(false)
}}
/>
<RepeatPickerField
emptyDisplay={pickerEmptyDisplay}
value={frequency}
onChange={setFrequency}
onClear={() => setFrequency(null)}
/>
<PriorityPickerField
value={priority}
onChange={setPriority}
onClear={() => setPriority(0)}
emptyDisplay={pickerEmptyDisplay}
priorityColors={priorityColors}
priorityLabels={priorityLabels}
/>
<AssigneePickerField
emptyDisplay={pickerEmptyDisplay}
value={assignees?.[0]?.userId || null}
onChange={userId => {
if (!userId) {
setAssignees([])
} else {
setAssignees([{ userId }])
}
}}
onClear={() => setAssignees([])}
currentUserId={userProfile?.id}
members={circleMembers?.res || []}
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
// emptyDisplay='icon-text'
/>
{/* <ProjectPickerField
value={projectId}
onChange={setProjectId}
// onClear={() => setProjectId(getInitialProject())} there should be no option to unselect a project, so we don't need an onClear handler
projects={projects || []}
emptyDisplay={pickerEmptyDisplay}
/> */}
<AttachmentPickerField
attachments={attachments}
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
/>
<NotificationPickerField
value={notificationMetadata}
onChange={setNotificationMetadata}
onClear={() => setNotificationMetadata({ templates: [] })}
emptyDisplay={pickerEmptyDisplay}
/>
</Box>
{/* <Box>
<Typography level='body-sm'>Title:</Typography>
<Input
@@ -802,12 +916,13 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
/>
</Box> */}
<Box>
<Box mt={2} sx={{ display: 'flex', flexDirection: 'row', gap: 1 }}>
{!hasDescription && (
<Button
startDecorator={<Add />}
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 && (
<Button
startDecorator={<Add />}
variant='plain'
size='sm'
variant='outlined'
color='neutral'
size='md'
onClick={() => {
setHasSubTasks(true)
}}
@@ -835,39 +951,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
Subtasks
</Button>
)}
{!dueDate && (
<Button
startDecorator={<Add />}
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 && <KeyboardShortcutHint shortcut='B' />
}
>
Due Date
</Button>
)}
{!hasNotifications && dueDate && (
<Button
startDecorator={<EditNotifications />}
variant='plain'
size='sm'
onClick={() => {
setHasNotifications(true)
setFrequencyHumanReadable('Once')
setFrequency(null)
}}
>
Edit Notifications
</Button>
)}
{/* {!hasDeadline && dueDate && (
<Button
startDecorator={<Add />}
@@ -907,61 +991,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</Box>
)}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 2,
}}
>
{priority > 0 && (
<FormControl>
<Typography level='body-sm'>Priority</Typography>
<Select
defaultValue={0}
value={priority}
onChange={(e, value) => setPriority(value)}
>
<Option value='0'>No Priority</Option>
<Option value='1'>P1</Option>
<Option value='2'>P2</Option>
<Option value='3'>P3</Option>
<Option value='4'>P4</Option>
</Select>
</FormControl>
)}
{dueDate && (
<FormControl>
<Typography level='body-sm'>Due Date</Typography>
<Input
type='date'
value={dueDateOnly || ''}
onChange={handleDueDateChange}
/>
<Checkbox
size='sm'
checked={useCustomTime}
onChange={e => handleUseCustomTimeChange(e.target.checked)}
label='Set a specific time'
sx={{ mt: 1 }}
/>
<FormHelperText>
{useCustomTime
? 'Task will be due at the specified time'
: 'Task will be due at the end of the day (11:59 PM)'}
</FormHelperText>
{useCustomTime && (
<Input
type='time'
value={dueTime || '18:00'}
onChange={handleDueTimeChange}
sx={{ maxWidth: 200, mt: 1 }}
/>
)}
</FormControl>
)}
</Box>
{/* {projects.length >= 1 && (
<FormControl>
<Typography level='body-sm'>Project</Typography>
@@ -1082,33 +1111,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
</Box>
</Box>
)} */}
{hasNotifications && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Notification Schedule</Typography>
<Box sx={{ p: 0.5 }}>
<NotificationTemplate
onChange={metadata => {
if (
metadata.notifications !== notificationMetadata.templates
) {
const newNotificationMetadata = {
...notificationMetadata,
templates: metadata.notifications,
}
setNotificationMetadata(newNotificationMetadata)
}
}}
value={notificationMetadata}
showTimeline={false}
/>
</Box>
</Box>
)}
</Box>
</ResponsiveModal>
)

View File

@@ -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 (
<BaseOptionPicker
items={options}
value={displayValue}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
getItemValue={item => item.userId}
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
menuMinWidth={220}
/>
)
}
export default AssigneePickerField

View File

@@ -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 (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Assignee'
getItemValue={item => item.userId}
getItemLabel={item => item.displayName}
renderTriggerIcon={() => <Person sx={{ fontSize: '20px' }} />}
renderItemStart={() => <Person sx={{ fontSize: '18px' }} />}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Assignee' : selectedItems[0].displayName
}
menuMinWidth={220}
/>
)
}
export default AssigneePickerPreview

View File

@@ -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 (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
ref={buttonRef}
size='sm'
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
{isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '16px' }} />
) : (
<AttachFile sx={{ fontSize: '20px' }} />
)}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{isEmpty
? 'Attachments'
: `${attachments.length} file${attachments.length !== 1 ? 's' : ''}`}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{ name: 'offset', options: { offset: [0, 8] } },
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 240,
maxWidth: 320,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{attachments.length > 0 && (
<Box sx={{ mb: 1, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
{attachments.map((attachment, index) => (
<Box
key={index}
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
p: 0.5,
borderRadius: 'sm',
'&:hover': { bgcolor: 'background.level1' },
}}
>
<Box
component='img'
src={attachment.url}
alt={attachment.name}
sx={{
width: 36,
height: 36,
objectFit: 'cover',
borderRadius: 'sm',
flexShrink: 0,
bgcolor: 'background.level2',
}}
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling.style.display = 'flex'
}}
/>
<Box
sx={{
display: 'none',
width: 36,
height: 36,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'sm',
bgcolor: 'background.level2',
flexShrink: 0,
}}
>
<Image sx={{ fontSize: 20, color: 'text.tertiary' }} />
</Box>
<Typography
level='body-xs'
sx={{
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{attachment.name}
</Typography>
<IconButton
size='sm'
variant='plain'
color='danger'
onClick={() => handleRemove(index)}
sx={{ flexShrink: 0 }}
>
<DeleteOutline sx={{ fontSize: 16 }} />
</IconButton>
</Box>
))}
</Box>
)}
<Button
fullWidth
size='sm'
variant='outlined'
color='neutral'
startDecorator={
isUploading ? (
<CircularProgress size='sm' sx={{ '--CircularProgress-size': '14px' }} />
) : (
<AttachFile sx={{ fontSize: 16 }} />
)
}
onClick={handleAddFile}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Add image'}
</Button>
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default AttachmentPickerField

View File

@@ -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 (
<>
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={'sm'}
variant={isEmpty ? 'outlined' : 'soft'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
borderRadius: '128px',
minHeight: 40,
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
backgroundColor: triggerColor ? `${triggerColor}20` : undefined,
borderColor: triggerColor || undefined,
color: triggerColor || undefined,
'&:hover': {
backgroundColor: triggerColor ? `${triggerColor}28` : undefined,
borderColor: triggerColor || undefined,
},
}}
>
{renderTriggerIcon?.({ selectedItems, isEmpty })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 180 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{triggerText}
</Typography>
</Button>
{!isEmpty && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={handleClear}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement={placement}
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: menuMinWidth,
maxHeight: menuMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
p: 0.75,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{items.map((item, index) => {
const optionValue = getItemValue(item)
const selected = isSelected(item)
const itemColor = getItemColor ? getItemColor(item) : undefined
return (
<Button
key={optionValue ?? index}
variant={selected ? 'soft' : 'plain'}
color='neutral'
onClick={() => handleSelect(optionValue)}
sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start',
gap: 1,
whiteSpace: 'nowrap',
mb: index === items.length - 1 ? 0 : 0.5,
color: selected
? itemColor || 'text.primary'
: 'text.primary',
}}
>
{renderItemStart?.({ item, selected })}
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{getItemLabel(item)}
</Typography>
</Button>
)
})}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</>
)
}
export default BaseOptionPicker

View File

@@ -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 (
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={size}
variant={hasDueDate ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<CalendarMonth sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{dueDateLabel}
</Typography>
</Button>
{hasDueDate && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 260,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Due Date
</Typography>
<Box
sx={{ display: 'flex', gap: 0.5, mb: 1.25, flexWrap: 'wrap' }}
>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Today sx={{ fontSize: 16 }} />}
onClick={() => handleQuickSchedule('today')}
>
Today
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<WbSunny sx={{ fontSize: 16 }} />}
onClick={() => handleQuickSchedule('tomorrow')}
>
Tomorrow
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Weekend sx={{ fontSize: 16 }} />}
onClick={() => handleQuickSchedule('weekend')}
>
Weekend
</Button>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<NextWeek sx={{ fontSize: 16 }} />}
onClick={() => handleQuickSchedule('next-week')}
>
Next week
</Button>
</Box>
<Input
type='date'
value={dueDateOnly || ''}
onChange={onDueDateChange}
sx={{ mb: 1 }}
/>
<Typography
level='body-xs'
sx={{ mb: 0.5, color: 'text.tertiary' }}
>
Due time (optional)
</Typography>
<Input
type='time'
value={dueTime || ''}
disabled={!dueDateOnly}
onChange={e => {
if (!useCustomTime) {
onUseCustomTimeChange?.(true)
}
onDueTimeChange?.(e)
}}
sx={{ maxWidth: 200, mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!useCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!dueDateOnly}
onClick={() => onUseCustomTimeChange?.(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={useCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!dueDateOnly}
onClick={() => onUseCustomTimeChange?.(true)}
>
Specific time
</Button>
</Box>
{hasDueDate && (
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
>
Clear due date
</Button>
)}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</Box>
)
}
export default DueDatePickerField

View File

@@ -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 (
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
}}
>
<Button
ref={buttonRef}
size={size}
variant={hasDueDate ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<CalendarMonth sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{dueDateLabel}
</Typography>
</Button>
{hasDueDate && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': {
bgcolor: 'danger.softBg',
},
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{
name: 'offset',
options: {
offset: [0, 8],
},
},
{
name: 'flip',
options: {
fallbackPlacements: ['bottom-start', 'top-start'],
},
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 260,
p: 1,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
<Typography level='body-sm' sx={{ mb: 0.5 }}>
Due Date
</Typography>
<Input
type='date'
value={dueDateOnly || ''}
onChange={onDueDateChange}
sx={{ mb: 1 }}
/>
<Typography
level='body-xs'
sx={{ mb: 0.5, color: 'text.tertiary' }}
>
Due time (optional)
</Typography>
<Input
type='time'
value={dueTime || ''}
disabled={!dueDateOnly}
onChange={e => {
if (!useCustomTime) {
onUseCustomTimeChange?.(true)
}
onDueTimeChange?.(e)
}}
sx={{ maxWidth: 200, mb: 1 }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!useCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!dueDateOnly}
onClick={() => onUseCustomTimeChange?.(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={useCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!dueDateOnly}
onClick={() => onUseCustomTimeChange?.(true)}
>
Specific time
</Button>
</Box>
{hasDueDate && (
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
>
Clear due date
</Button>
)}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</Box>
)
}
export default DueDatePickerPreview

View File

@@ -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 (
<BaseOptionPicker
items={options}
multiple
values={values}
onValuesChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Labels'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <Label sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<Label
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) => {
if (isEmpty) return 'Labels'
if (selectedItems.length === 1) return selectedItems[0].name
return `${selectedItems.length} labels`
}}
menuMinWidth={220}
/>
)
}
export default LabelsPickerField

View File

@@ -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 = (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasNotifications && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove all
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button variant='solid' color='primary' size='lg' onClick={handleSave}>
Apply
</Button>
</Box>
)
return (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasNotifications ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<NotificationsNone sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasNotifications && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Reminders'
footer={footer}
>
<NotificationTemplate
value={value}
onChange={({ notifications }) => {
latestTemplatesRef.current = notifications
}}
showTimeline
/>
</ResponsiveModal>
</>
)
}
export default NotificationPickerField

View File

@@ -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 (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
size={size}
getItemValue={item => 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 }) => (
<Flag
sx={{
color: isEmpty || value === 0 ? '' : selectedItems[0]?.color,
fontSize: '20px',
}}
/>
)}
renderItemStart={({ item }) => (
<Flag
sx={{
color: item.color,
fontSize: '18px',
}}
/>
)}
menuMinWidth={180}
/>
)
}
export default PriorityPickerField

View File

@@ -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 (
<BaseOptionPicker
items={options}
value={value}
onChange={onChange}
onClear={onClear}
emptyDisplay={emptyDisplay}
emptyLabel='Project'
getItemValue={item => item.id}
getItemLabel={item => item.name}
getItemColor={item => item.color}
renderTriggerIcon={() => <FolderOpen sx={{ fontSize: '20px' }} />}
renderItemStart={({ item }) => (
<FolderOpen
sx={{
fontSize: '18px',
color: item.color || 'text.secondary',
}}
/>
)}
getTriggerText={({ selectedItems, isEmpty }) =>
isEmpty ? 'Project' : selectedItems[0].name
}
menuMinWidth={240}
/>
)
}
export default ProjectPickerField

View File

@@ -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 }) => (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
mb: 0.75,
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
{children}
</Typography>
)
// Shared time-of-day picker
const TimeRow = ({ metadata, onUpdate }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Time of day</SectionLabel>
<Input
type='time'
size='sm'
value={moment(metadata?.time).format('HH:mm')}
onChange={e =>
onUpdate({
...metadata,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
time: moment(
moment(new Date()).format('YYYY-MM-DD') + 'T' + e.target.value,
).format(),
})
}
sx={{ width: 120 }}
/>
</Box>
)
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
// Interval section
const IntervalSection = ({
frequency,
frequencyMetadata,
onFrequencyUpdate,
onFrequencyMetadataUpdate,
}) => (
<Box>
<SectionLabel>Repeat every</SectionLabel>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}
>
<Input
type='number'
size='sm'
value={frequency}
onChange={e =>
onFrequencyUpdate(Math.max(1, parseInt(e.target.value, 10) || 1))
}
sx={{ width: 72 }}
slotProps={{ input: { min: 1, max: 999 } }}
/>
<List orientation='horizontal' wrap sx={pillListSx}>
{['days', 'weeks', 'months', 'years'].map(unit => (
<ListItem key={unit}>
<Checkbox
checked={frequencyMetadata?.unit === unit}
onClick={() =>
onFrequencyMetadataUpdate({ ...frequencyMetadata, unit })
}
overlay
disableIcon
variant='soft'
label={unit.charAt(0).toUpperCase() + unit.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
// 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 (
<Box>
<SectionLabel>Days</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{DAYS.map(day => (
<ListItem key={day}>
<Checkbox
checked={selectedDays.includes(day)}
onClick={() => toggleDay(day)}
overlay
disableIcon
variant='soft'
label={day.charAt(0).toUpperCase() + day.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ mt: 2 }}>
<SectionLabel>Pattern</SectionLabel>
<RadioGroup
orientation='horizontal'
value={weekPattern}
onChange={e =>
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 => (
<Radio
key={opt.value}
value={opt.value}
color='neutral'
disableIcon
label={opt.label}
variant='plain'
sx={{ px: 1.5, py: 0.5 }}
slotProps={{
action: ({ checked }) => ({
sx: checked
? {
bgcolor: 'background.surface',
boxShadow: 'sm',
'&:hover': { bgcolor: 'background.surface' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
{weekPattern === 'week_of_month' && (
<Box sx={{ mt: 1.5 }}>
<SectionLabel>Occurrences</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{OCCURRENCE_OPTIONS.map(opt => (
<ListItem key={opt.value}>
<Checkbox
checked={selectedOccurrences.includes(opt.value)}
onClick={() => toggleOccurrence(opt.value)}
overlay
disableIcon
variant='soft'
label={opt.label}
/>
</ListItem>
))}
</List>
</Box>
)}
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
// 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 (
<Box>
<SectionLabel>Months</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{MONTHS.map(month => (
<ListItem key={month}>
<Checkbox
checked={selectedMonths.includes(month)}
onClick={() => toggleMonth(month)}
overlay
disableIcon
variant='soft'
label={month.charAt(0).toUpperCase() + month.slice(1, 3)}
/>
</ListItem>
))}
</List>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mt: 2 }}>
<SectionLabel>Day of month</SectionLabel>
<Input
type='number'
size='sm'
value={frequency}
onChange={e => {
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 } }}
/>
</Box>
<TimeRow
metadata={frequencyMetadata}
onUpdate={onFrequencyMetadataUpdate}
/>
</Box>
)
}
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 (
<>
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
size={size}
variant={hasRepeat ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(true)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<Repeat sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasRepeat && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Repeat Schedule'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasRepeat && (
<Button
variant='plain'
color='danger'
size='lg'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ mr: 'auto' }}
>
Remove
</Button>
)}
<Button
variant='outlined'
color='neutral'
size='lg'
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='lg'
onClick={handleSave}
>
Apply
</Button>
</Box>
}
>
{/* Frequency type selector */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box>
<SectionLabel>Frequency</SectionLabel>
<List orientation='horizontal' wrap sx={pillListSx}>
{FREQUENCY_TYPES.map(type => (
<ListItem key={type}>
<Checkbox
checked={displayType === type}
onClick={() => handleTypeSelect(type)}
overlay
disableIcon
variant='soft'
label={type.charAt(0).toUpperCase() + type.slice(1)}
/>
</ListItem>
))}
</List>
</Box>
{/* Custom sub-type + detail panel */}
{displayType === 'custom' && (
<>
<Box>
<SectionLabel>Schedule type</SectionLabel>
<RadioGroup
orientation='horizontal'
value={localFrequencyType}
onChange={e => 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 => (
<Radio
key={type}
value={type}
color='neutral'
disableIcon
label={type
.split('_')
.map((w, i, arr) =>
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' },
}
: {},
}),
}}
/>
))}
</RadioGroup>
</Box>
<Divider />
{localFrequencyType === 'interval' && (
<IntervalSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'days_of_the_week' && (
<DaysOfWeekSection
frequencyMetadata={localFrequencyMetadata}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
{localFrequencyType === 'day_of_the_month' && (
<DayOfMonthSection
frequency={localFrequency}
frequencyMetadata={localFrequencyMetadata}
onFrequencyUpdate={setLocalFrequency}
onFrequencyMetadataUpdate={setLocalFrequencyMetadata}
/>
)}
</>
)}
</Box>
</ResponsiveModal>
</>
)
}
export default RepeatPickerField

View File

@@ -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 (
<Box sx={{ position: 'relative', display: 'flex', alignItems: 'center' }}>
<Button
ref={buttonRef}
size={size}
variant={hasRepeat ? 'soft' : 'outlined'}
color='neutral'
onClick={() => setIsOpen(prev => !prev)}
sx={{
minHeight: 40,
borderRadius: '128px',
minWidth: 'min-content',
px: shouldShowLabel ? 1.25 : 0.75,
gap: shouldShowLabel ? 1 : 0,
justifyContent: 'flex-start',
whiteSpace: 'nowrap',
transition: 'all 0.25s ease-in-out',
}}
>
<Repeat sx={{ fontSize: '20px' }} />
<Typography
level='body-sm'
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: shouldShowLabel ? 220 : 0,
opacity: shouldShowLabel ? 1 : 0,
transform: shouldShowLabel ? 'translateX(0)' : 'translateX(-4px)',
transition:
'max-width 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.25s ease-in-out',
}}
>
{displayLabel}
</Typography>
</Button>
{hasRepeat && onClear && (
<IconButton
size='sm'
variant='soft'
color='danger'
onClick={e => {
e.stopPropagation()
onClear?.()
}}
sx={{
position: 'absolute',
top: -12,
right: -16,
zIndex: 10,
maxHeight: 18,
maxWidth: 18,
borderRadius: '50%',
'&:hover': { bgcolor: 'danger.softBg' },
}}
>
<Close sx={{ fontSize: '18px' }} />
</IconButton>
)}
{isOpen && (
<Popper
open={isOpen}
anchorEl={buttonRef.current}
placement='top-start'
modifiers={[
{ name: 'offset', options: { offset: [0, 8] } },
{
name: 'flip',
options: { fallbackPlacements: ['bottom-start', 'top-start'] },
},
]}
sx={{ zIndex: Z_INDEX.MODAL_CLOSE_BUTTON + 1 }}
>
<ClickAwayListener onClickAway={() => setIsOpen(false)}>
<Sheet
variant='outlined'
sx={{
minWidth: 180,
p: 0.75,
borderRadius: 'md',
boxShadow: 'lg',
bgcolor: 'background.popup',
}}
>
{REPEAT_PRESETS.map((preset, index) => {
const isSelected = selectedPreset?.id === preset.id
return (
<Button
key={preset.id}
variant={isSelected ? 'soft' : 'plain'}
color='neutral'
onClick={() => {
onChange({
frequencyType: preset.frequencyType,
frequency: preset.frequency,
frequencyMetadata: preset.frequencyMetadata,
})
setIsOpen(false)
}}
sx={{
width: '100%',
display: 'flex',
justifyContent: 'flex-start',
mb: index === REPEAT_PRESETS.length - 1 ? 0 : 0.5,
}}
>
<Typography level='body-sm'>
{getRecurrentChipText(preset)}
</Typography>
</Button>
)
})}
{hasRepeat && (
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => {
onClear?.()
setIsOpen(false)
}}
sx={{ width: '100%', mt: 0.5, justifyContent: 'flex-start' }}
>
No repeat
</Button>
)}
</Sheet>
</ClickAwayListener>
</Popper>
)}
</Box>
)
}
export default RepeatPickerPreview