Merge pull request #125 from donetick/add-task-modal-v2

Add task modal v2
This commit is contained in:
Mohamad Tarbin
2026-07-03 02:01:33 -04:00
committed by GitHub
15 changed files with 2447 additions and 340 deletions

View File

@@ -12,7 +12,7 @@ import Input from '@mui/joy/Input'
import Option from '@mui/joy/Option'
import Select from '@mui/joy/Select'
import Typography from '@mui/joy/Typography'
import { useCallback, useEffect, useState, useRef } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NOTIFICATION_TYPE, TASK_COLOR } from '../utils/Colors'
import { TIME_UNITS } from '../utils/DurationUtils'
@@ -512,6 +512,7 @@ const NotificationTemplate = ({
'--Badge-fontSize': '0.7rem',
'--Badge-paddingX': '5px',
top: 10,
left: 10,
'& .MuiBadge-badge': {
background: colors.bgColor,
color: 'white',

View File

@@ -0,0 +1,91 @@
import imageCompression from 'browser-image-compression'
import { useCallback } from 'react'
import { useUserProfile } from '../queries/UserQueries'
import { useNotification } from '../service/NotificationProvider'
import { apiClient } from '../utils/ApiClient'
import { isPlusAccount, resolvePhotoURL } from '../utils/Helpers'
export const useFileUpload = ({ entityType = 'chore_attachment', entityId } = {}) => {
const { showError } = useNotification()
const { data: userProfile } = useUserProfile()
const uploadFile = useCallback(
async file => {
if (!isPlusAccount(userProfile)) {
showError({
title: 'Plus Feature',
message:
'Image uploads are not available in the Basic plan. Upgrade to Plus to add images to your content.',
})
return null
}
try {
const compressionOptions = {
maxSizeMB: entityType === 'profile' ? 0.5 : 1,
maxWidthOrHeight: entityType === 'profile' ? 320 : 1200,
useWebWorker: true,
fileType: 'image/jpeg',
}
const compressedFile = await imageCompression(file, compressionOptions)
const compressedJpegFile = new File(
[compressedFile],
`${file.name.split('.')[0]}.jpg`,
{ type: 'image/jpeg' },
)
const formData = new FormData()
formData.append('file', compressedJpegFile)
formData.append('entityType', entityType)
if (entityId) formData.append('entityId', entityId)
const response = await apiClient.upload('/assets/chore', formData)
if (response.status === 507) {
showError({
title: 'Storage Quota Exceeded',
message: 'You have exceeded your quota for uploading files.',
})
return null
} else if (response.status === 413) {
showError({
title: 'File Too Large',
message: 'The file you are trying to upload is too large.',
})
return null
} else if (response.status === 403 && !isPlusAccount(userProfile)) {
showError({
title: 'Upgrade Required',
message: 'Image uploads are only available for Plus accounts.',
})
return null
} else if (response.status === 403) {
showError({
title: 'Permission Denied',
message: 'You do not have permission to upload files.',
})
return null
} else if (!response.ok) {
showError({
title: 'Upload Failed',
message: 'Failed to upload image.',
})
return null
}
const data = await response.json()
return resolvePhotoURL(data.url || data.sign)
} catch {
showError({
title: 'Upload Failed',
message: 'An error occurred while processing the image.',
})
return null
}
},
[entityType, entityId, showError, userProfile],
)
return { uploadFile, isPlus: isPlusAccount(userProfile) }
}

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 = () => {
@@ -46,18 +44,20 @@ const getDefaultNotification = () => {
]
localStorage.setItem(
'defaultNotification',
'defaultNotificationTemplate',
JSON.stringify(defaultNotification),
)
return defaultNotification
}
const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const TaskInput = ({ 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()
const { data: projects = [], isLoading: isProjectsLoading } = useProjects()
const { isLoading: isProjectsLoading } = useProjects()
const createChoreMutation = useCreateChore()
const { data: userProfile } = useUserProfile()
@@ -80,9 +80,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [taskTitle, setTaskTitle] = useState('')
const [renderedParts, setRenderedParts] = useState([])
const textareaRef = useRef(null)
const mainInputRef = useRef(null)
const richTextEditorRef = useRef(null)
const latestRef = useRef({})
const [priority, setPriority] = useState(0)
const [dueDate, setDueDate] = useState(null)
const [description, setDescription] = useState(null)
@@ -92,20 +91,35 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
const [notificationMetadata, setNotificationMetadata] = useState({
templates: getDefaultNotification(),
})
const [frequencyHumanReadable, setFrequencyHumanReadable] = useState(null)
const [subTasks, setSubTasks] = useState(null)
const [points, setPoints] = useState(-1)
const [isAnyoneTask, setIsAnyoneTask] = useState(false)
const [hasDescription, setHasDescription] = useState(false)
const [hasSubTasks, setHasSubTasks] = useState(false)
const [hasNotifications, setHasNotifications] = useState(false)
const [hasDeadline, setHasDeadline] = useState(false)
const [deadlineOffset, setDeadlineOffset] = useState(-1)
const [dueDateOnly, setDueDateOnly] = useState(null)
const [dueTime, setDueTime] = useState(null)
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(() => {
@@ -117,12 +131,17 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}, [hasDescription])
// set showKeyboardShortcuts true as soon as the user hold ctrl or cmd key:
useEffect(() => {
const handleKeyDown = event => {
const {
isModalOpen,
hasDescription,
dueDate,
createChore,
handleCloseModal,
} = latestRef.current
const isHoldingCmd = event.ctrlKey || event.metaKey
if (isHoldingCmd) {
// event.preventDefault()
setShowKeyboardShortcuts(true)
}
if (
@@ -135,10 +154,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setShowKeyboardShortcuts(false)
}
if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) {
// add subtask:
setHasSubTasks(true)
setShowKeyboardShortcuts(false)
// set focus on the first subtask input:
}
if (
isHoldingCmd &&
@@ -146,7 +163,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
isModalOpen &&
!dueDate
) {
// add due date:
const tomorrow = moment().add(1, 'day')
setDueDateOnly(tomorrow.format('YYYY-MM-DD'))
setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59'))
@@ -154,7 +170,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setDueTime(null)
setShowKeyboardShortcuts(false)
}
// Enter key to create task
if (
event.key === 'Enter' &&
(event.ctrlKey || event.metaKey) &&
@@ -164,7 +179,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
createChore()
return
}
// Escape key to cancel/close modal
if (event.key === 'Escape' && isModalOpen) {
event.preventDefault()
handleCloseModal()
@@ -185,22 +199,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}, [])
useEffect(() => {
if (isModalOpen && textareaRef.current) {
textareaRef.current.focus()
textareaRef.current.selectionStart = textareaRef.current.value?.length
textareaRef.current.selectionEnd = textareaRef.current.value?.length
}
}, [isModalOpen])
useEffect(() => {
if (autoFocus > 0 && mainInputRef.current) {
mainInputRef.current.focus()
mainInputRef.current.selectionStart = mainInputRef.current.value?.length
mainInputRef.current.selectionEnd = mainInputRef.current.value?.length
}
}, [autoFocus])
const renderHighlightedSentence = useCallback(
(
sentence,
@@ -262,20 +260,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 +295,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
break
}
// Add the highlighted span
const highlightedText = sentence.substring(
highlight.start,
highlight.end,
@@ -310,9 +304,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 +313,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 +332,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 +352,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)
@@ -392,7 +386,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
if (repeat.result) {
setFrequency(repeat.result)
setFrequencyHumanReadable(repeat.name)
}
const syncDueDateStates = parsedDate => {
@@ -500,6 +493,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 +503,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 +521,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'),
@@ -543,7 +549,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setTaskTitle('')
setDueDate(null)
setFrequency(null)
setFrequencyHumanReadable(null)
setPriority(0)
setPoints(-1)
setIsAnyoneTask(false)
@@ -554,7 +559,6 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
setLabelsV2([])
setAssignees([])
setProjectId(getInitialProject())
setHasDeadline(false)
setDeadlineOffset(-1)
setDueDateOnly(null)
setDueTime(null)
@@ -605,6 +609,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) {
@@ -617,8 +622,8 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}
}
if (!frequency && dueDate) {
// use dueDate converted to UTC:
chore.nextDueDate = new Date(dueDate).toUTCString()
// Use RFC3339/ISO-8601 format expected by backend.
chore.nextDueDate = new Date(dueDate).toISOString()
chore.notificationMetadata = notificationMetadata
}
@@ -645,6 +650,15 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
})
handleCloseModal(false)
}
latestRef.current = {
isModalOpen,
hasDescription,
dueDate,
createChore,
handleCloseModal,
}
if (isCircleMembersLoading || isProjectsLoading) {
return <></>
}
@@ -685,6 +699,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
@@ -748,7 +763,7 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
<SmartTaskTitleInput
autoFocus
value={taskText}
placeholder='Type your full text here...'
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
}}
@@ -793,21 +808,96 @@ const TaskInput = ({ autoFocus, onChoreUpdate, isModalOpen, onClose }) => {
}}
/>
</Box>
{/* <Box>
<Typography level='body-sm'>Title:</Typography>
<Input
value={taskTitle}
onChange={e => setTaskTitle(e.target.value)}
sx={{ width: '100%', fontSize: '16px' }}
/>
</Box> */}
<Box
sx={{
paddingTop: 2,
paddingBottom: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
<Box>
// scrollable horizontally but hide the scrollbar:
overflowX: 'auto',
'&::-webkit-scrollbar': {
display: 'none',
},
// if not mobile then go to next line if not enough space( show chip on next line):
flexWrap: isMobile ? 'nowrap' : 'wrap',
}}
>
<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 || []}
/>
<AttachmentPickerField
attachments={attachments}
onChange={setAttachments}
onClear={() => setAttachments([])}
emptyDisplay={pickerEmptyDisplay}
entityType='chore_attachment'
/>
<NotificationPickerField
value={notificationMetadata}
onChange={setNotificationMetadata}
onClear={() => setNotificationMetadata({ templates: [] })}
emptyDisplay={pickerEmptyDisplay}
/>
</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 +913,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,52 +926,6 @@ 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 />}
variant='plain'
size='sm'
onClick={() => {
setHasDeadline(true)
setDeadlineOffset(86400)
}}
>
Set Deadline
</Button>
)} */}
</Box>
{hasDescription && (
@@ -906,210 +951,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>
<Select
value={projectId}
onChange={(event, newValue) => setProjectId(newValue)}
sx={{ minWidth: '15rem' }}
>
<Option key='default' value='default'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: '#1976d2',
}}
>
{(() => {
const IconComponent = getIconComponent('FolderOpen')
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor('#1976d2'),
}}
/>
)
})()}
</Avatar>
Default Project
</Box>
</Option>
{projects.map(project => (
<Option key={project.id} value={project.id}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar
size='sm'
sx={{
width: 24,
height: 24,
bgcolor: project.color || '#1976d2',
}}
>
{project.icon ? (
(() => {
const IconComponent = getIconComponent(project.icon)
return (
<IconComponent
sx={{
fontSize: 14,
color: getTextColorFromBackgroundColor(
project.color || '#1976d2',
),
}}
/>
)
})()
) : (
<></>
)}
</Avatar>
{project.name}
</Box>
</Option>
))}
</Select>
</FormControl>
)} */}
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'start',
gap: 2,
}}
>
{/* <FormControl>
<Typography level='body-sm'>Assignees</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{assignees.length > 0 ? (
assignees.map((assignee, index) => (
<Chip
key={assignee.userId || index}
variant='soft'
size='lg'
color='primary'
>
{assignee.displayName || assignee.username}
</Chip>
))
) : (
<Chip variant='soft' size='sm' color='neutral'>
{userProfile.displayName}
</Chip>
)}
</Box>
</FormControl> */}
{/* {hasDeadline && dueDate && (
<Box
sx={{
flexDirection: 'column',
alignItems: 'start',
}}
>
<Typography level='body-sm'>Deadline</Typography>
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}
>
<DurationInput
value={deadlineOffset}
onChange={setDeadlineOffset}
size='sm'
minValue={0}
/>
<Typography level='body-sm'>after due date</Typography>
</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,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,628 @@
import {
Bedtime,
CalendarMonth,
Close,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import Calendar from 'react-calendar'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const DueDatePickerField = ({
dueDateOnly,
dueTime,
useCustomTime,
onDueDateChange,
onDueTimeChange,
onUseCustomTimeChange,
onClear,
emptyDisplay = 'icon-text',
size = 'sm',
}) => {
const [isOpen, setIsOpen] = useState(false)
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (isOpen) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
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
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const handleQuickSchedule = option => {
const date = getQuickScheduleDate(option)
setLocalDueDateOnly(date.toISOString().split('T')[0])
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
onUseCustomTimeChange?.(localUseCustomTime)
if (localUseCustomTime && localDueTime) {
onDueTimeChange?.({ target: { value: localDueTime } })
} else {
onDueTimeChange?.({ target: { value: '' } })
}
setIsOpen(false)
}
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
size={size}
variant={hasDueDate ? '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',
}}
>
<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>
)}
</Box>
<ResponsiveModal
open={isOpen}
onClose={() => setIsOpen(false)}
title='Due Date'
footer={
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1 }}>
{hasDueDate && (
<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>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)' }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = getQuickScheduleDate(opt.key)
.toISOString()
.split('T')[0]
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly
? new Date(`${localDueDateOnly}T00:00:00`)
: null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
</>
)
}
export default DueDatePickerField

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

@@ -1,3 +1,18 @@
:root,
[data-joy-color-scheme='light'] {
--highlight-date-color: #b45309;
--highlight-repeat-color: #15803d;
--highlight-label-color: #1d4ed8;
--highlight-priority-color: #be123c;
}
[data-joy-color-scheme='dark'] {
--highlight-date-color: #fca5a5;
--highlight-repeat-color: #86efac;
--highlight-label-color: #93c5fd;
--highlight-priority-color: #f9a8d4;
}
.smart-task-display {
position: absolute;
width: 100%;
@@ -11,27 +26,36 @@
white-space: pre-wrap;
box-sizing: border-box;
}
.smart-task-common {
font-size: 1.2em;
line-height: 1.2em;
font-family: inherit;
caret-color: #f08080;
caret-color: var(--highlight-date-color);
}
.highlight-date {
color: #f08080;
color: var(--highlight-date-color);
}
.highlight-repeat {
color: #90ee90;
color: var(--highlight-repeat-color);
}
.highlight-label {
color: #add8e6;
color: var(--highlight-label-color);
}
.highlight-priority {
color: #ffb6c1;
color: var(--highlight-priority-color);
}
.highlight-assignee {
color: var(--highlight-repeat-color);
}
.highlight-points {
color: var(--highlight-label-color);
}
.task-input {
@@ -39,4 +63,6 @@
width: 100%;
border-radius: 8px;
box-sizing: border-box;
border: 1px solid var(--joy-palette-neutral-outlinedBorder, #d0d5dd);
overflow: auto;
}

View File

@@ -183,10 +183,7 @@ const SmartTaskTitleInput = ({
return (
<div>
<div
className='task-input overflow-auto rounded border'
style={{ minHeight: '2.4em' }}
>
<div className='task-input' style={{ minHeight: '2.4em' }}>
<textarea
ref={titleInputRef}
autoFocus={autoFocus}