import { Add } from '@mui/icons-material' import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' import { useQueryClient } from '@tanstack/react-query' import * as chrono from 'chrono-node' import moment from 'moment' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import ModalActions from '../../components/common/ModalActions' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { useFileUpload } from '../../hooks/useFileUpload' import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useCreateChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { localAIService } from '../../service/LocalAIService' import { voiceInputService } from '../../service/VoiceInputService' import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors' import { CreateLabel } from '../../utils/Fetcher' import { imageSourceToFile } from '../../utils/FileConvert' import { isPlusAccount } from '../../utils/Helpers' import { generateUUID } from '../../utils/UUID' import { useLabels } from '../Labels/LabelQueries' import { useProjects } from '../Projects/ProjectQueries' import AdvancedOptionsSection, { AdvancedOptionsTrigger, } from './AdvancedOptionsSection' import AssigneePickerField from './AssigneePickerField' import AttachmentPickerField from './AttachmentPickerField' import { parseAssignees, parseDueDate, parseLabels, parsePoints, parsePriority, parseRepeatV2, } from './CustomParsers' 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 ScanPanel from './ScanToTask/ScanPanel' import SmartTaskTitleInput from './SmartTaskTitleInput' import SubTasks from './SubTask' import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask' import VoicePanel from './VoiceToTask/VoicePanel' // Canonical reminder template shape, shared with NotificationTemplate and // LocalNotificationScheduler: a signed value plus 'm' | 'h' | 'd'. Negative is // before due, positive is after, zero is on due. const DEFAULT_NOTIFICATION_TEMPLATES = [ { value: -1, unit: 'd' }, { value: 0, unit: 'm' }, { value: 1, unit: 'd' }, ] const UNIT_ALIASES = { minute: 'm', minutes: 'm', hour: 'h', hours: 'h', day: 'd', days: 'd', } // Earlier builds stored {value: 1, unit: 'days', type: 'before'}. Nothing reads // `type`, and the scheduler's unit switch falls through on 'days', so those // entries fired at the due time (or collided on id) instead of offsetting. const normalizeTemplate = template => { const unit = UNIT_ALIASES[template.unit] || template.unit const value = Number(template.value) || 0 if (!template.type) return { value, unit } if (template.type === 'ondue') return { value: 0, unit } return { value: template.type === 'before' ? -Math.abs(value) : Math.abs(value), unit, } } const getDefaultNotification = () => { const storedDefault = localStorage.getItem('defaultNotificationTemplate') if (storedDefault) { try { const parsed = JSON.parse(storedDefault) if (Array.isArray(parsed)) { // An empty list is a deliberate "no reminders by default", not a // missing value — respect it instead of re-seeding. const normalized = parsed.map(normalizeTemplate) if (JSON.stringify(normalized) !== storedDefault) { localStorage.setItem( 'defaultNotificationTemplate', JSON.stringify(normalized), ) } return normalized } } catch { // fall through and reset to the defaults below } } localStorage.setItem( 'defaultNotificationTemplate', JSON.stringify(DEFAULT_NOTIFICATION_TEMPLATES), ) return DEFAULT_NOTIFICATION_TEMPLATES } const TaskInput = ({ initialMode, isModalOpen, onChoreUpdate, 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 { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() // Stable identities for the voice panel: these queries are undefined while // loading, and a fresh [] each render would churn the panel's parse context const voiceLabels = useMemo(() => userLabels || [], [userLabels]) const voiceMembers = useMemo(() => circleMembers?.res || [], [circleMembers]) const handleCreateLabel = useCallback( name => { const color = LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))] .value CreateLabel({ name, color }) .then(() => queryClient.invalidateQueries(['labels'])) .catch(error => console.error('Error creating label:', error)) }, [queryClient], ) // Get initial project from localStorage (current active project) const getInitialProject = () => { const saved = localStorage.getItem('selectedProject') if (saved) { try { const project = JSON.parse(saved) return project?.id || 'default' } catch { return 'default' } } return 'default' } const [taskText, setTaskText] = useState('') const [taskTitle, setTaskTitle] = useState('') const [renderedParts, setRenderedParts] = useState([]) const richTextEditorRef = useRef(null) const latestRef = useRef({}) // Picker edits made on a voice task card, applied once after the reparse // that follows landing the spoken text in the smart input const pendingVoiceOverridesRef = useRef(null) const [priority, setPriority] = useState(0) const [dueDate, setDueDate] = useState(null) const [description, setDescription] = useState(null) const [assignees, setAssignees] = useState([]) const [labelsV2, setLabelsV2] = useState([]) const [frequency, setFrequency] = useState(null) const [notificationMetadata, setNotificationMetadata] = useState({ templates: getDefaultNotification(), }) 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 [deadlineOffset, setDeadlineOffset] = useState(-1) const [requireApproval, setRequireApproval] = useState(false) const [completionWindow, setCompletionWindow] = useState(-1) const [assignStrategy, setAssignStrategy] = useState('keep_last_assigned') const [isPrivate, setIsPrivate] = useState(false) const [showAdvanced, setShowAdvanced] = useState(false) 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([]) const [draftId, setDraftId] = useState(() => generateUUID()) const [showScan, setShowScan] = useState(false) const [scanAutoCapture, setScanAutoCapture] = useState(false) const [pendingPhotoUrl, setPendingPhotoUrl] = useState(null) const [isAttachingScan, setIsAttachingScan] = useState(false) const [llmAvailable, setLlmAvailable] = useState(false) const [showVoice, setShowVoice] = useState(false) const [voiceAvailable, setVoiceAvailable] = useState(false) // Voice capture state, reported up by VoicePanel so the modal footer owns // the confirm action instead of the panel having its own button row const [voiceState, setVoiceState] = useState({ segments: [], isListening: false, }) const [creatingVoiceTasks, setCreatingVoiceTasks] = useState(false) // Reminder default the voice cards start from — read once so the array // identity stays stable across renders of the panel const voiceDefaultNotificationTemplates = useMemo( () => getDefaultNotification(), [], ) // Same arrangement for the scan panel: it reports the action for its // current phase and the modal footer renders it const [scanState, setScanState] = useState({ phase: 'idle', primaryAction: null, }) const { isNativeScanner } = useDocumentScanner() const { uploadFile } = useFileUpload({ entityType: 'chore_attachment_draft', draftId, }) useEffect(() => { localAIService.isAvailable().then(setLlmAvailable) voiceInputService.isSupported().then(setVoiceAvailable) }, []) // Quick-capture widget entry points (donetick://chores/add?mode=voice|scan) // land here: open straight into the requested panel, once per modal open so // backing out of the panel doesn't bounce the user right back into it. const appliedInitialModeRef = useRef(false) useEffect(() => { if (!isModalOpen) { appliedInitialModeRef.current = false return } if (appliedInitialModeRef.current) return // Availability resolves async — wait for the answer before deciding; if it // never turns true the modal simply stays in plain text mode. if (initialMode === 'voice') { if (!voiceAvailable) return appliedInitialModeRef.current = true setShowVoice(true) } else if (initialMode === 'scan') { if (!llmAvailable) return appliedInitialModeRef.current = true setScanAutoCapture(true) setShowScan(true) } }, [isModalOpen, initialMode, voiceAvailable, llmAvailable]) // 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(() => { if (hasDescription && richTextEditorRef.current) { // Small delay to ensure the component is fully rendered setTimeout(() => { richTextEditorRef.current.focus() }, 100) } }, [hasDescription]) useEffect(() => { const handleKeyDown = event => { const { createChore, dueDate, handleCloseModal, hasDescription, isModalOpen, } = latestRef.current const isHoldingCmd = event.ctrlKey || event.metaKey if (isHoldingCmd) { setShowKeyboardShortcuts(true) } if ( isHoldingCmd && event.key.toLowerCase() === 'e' && isModalOpen && !hasDescription ) { setHasDescription(true) setShowKeyboardShortcuts(false) } if (isHoldingCmd && event.key.toLowerCase() === 'j' && isModalOpen) { setHasSubTasks(true) setShowKeyboardShortcuts(false) } if ( isHoldingCmd && event.key.toLowerCase() === 'b' && isModalOpen && !dueDate ) { const tomorrow = moment().add(1, 'day') setDueDateOnly(tomorrow.format('YYYY-MM-DD')) setDueDate(tomorrow.endOf('day').format('YYYY-MM-DDTHH:mm:59')) setUseCustomTime(false) setDueTime(null) setShowKeyboardShortcuts(false) } if ( event.key === 'Enter' && (event.ctrlKey || event.metaKey) && isModalOpen ) { event.preventDefault() createChore() return } if (event.key === 'Escape' && isModalOpen) { event.preventDefault() handleCloseModal() return } } const handleKeyUp = event => { if (event.key === 'Control' || event.key === 'Meta') { setShowKeyboardShortcuts(false) } } window.addEventListener('keydown', handleKeyDown) window.addEventListener('keyup', handleKeyUp) return () => { window.removeEventListener('keydown', handleKeyDown) window.removeEventListener('keyup', handleKeyUp) } }, []) const renderHighlightedSentence = useCallback( ( sentence, repeatHighlight, priorityHighlight, labelsHighlight, dueDateHighlight, pointsHighlight, assigneesHighlight, ) => { const parts = [] let lastIndex = 0 let plainText = '' // Combine all highlight ranges and sort them by their start index const allHighlights = [] if (repeatHighlight) { repeatHighlight.forEach(h => allHighlights.push({ ...h, type: 'repeat', priority: 60 }), ) } if (priorityHighlight) { priorityHighlight.forEach(h => allHighlights.push({ ...h, type: 'priority', priority: 50 }), ) } if (pointsHighlight) { pointsHighlight.forEach(h => allHighlights.push({ ...h, type: 'points', priority: 45 }), ) } if (assigneesHighlight) { assigneesHighlight.forEach(h => allHighlights.push({ ...h, type: 'assignee', priority: 40 }), ) } if (labelsHighlight) { labelsHighlight.forEach(h => allHighlights.push({ ...h, type: 'label', priority: 30 }), ) } if (dueDateHighlight) { allHighlights.push({ ...dueDateHighlight, type: 'dueDate', priority: 20, }) } allHighlights.sort((a, b) => a.start - b.start) const resolvedHighlights = [] for (let i = 0; i < allHighlights.length; i++) { const current = allHighlights[i] const previous = resolvedHighlights[resolvedHighlights.length - 1] if (previous && current.start < previous.end) { if (current.priority > previous.priority) { resolvedHighlights.pop() resolvedHighlights.push(current) } } else { resolvedHighlights.push(current) } } for (const highlight of resolvedHighlights) { if (highlight.start > lastIndex) { const textBefore = sentence.substring(lastIndex, highlight.start) parts.push(textBefore) plainText += textBefore } let className = '' switch (highlight.type) { case 'repeat': className = 'highlight-repeat' break case 'priority': className = 'highlight-priority' break case 'points': className = 'highlight-points' break case 'assignee': className = 'highlight-assignee' break case 'label': className = 'highlight-label' break case 'dueDate': className = 'highlight-date' break default: break } const highlightedText = sentence.substring( highlight.start, highlight.end, ) parts.push( {highlightedText} , ) lastIndex = highlight.end } if (lastIndex < sentence.length) { const remainingText = sentence.substring(lastIndex) parts.push(remainingText) plainText += remainingText } return { parts, plainText, } }, [], ) const processText = useCallback( sentence => { const priority = parsePriority(sentence) const pointsParsed = parsePoints(sentence) const labels = parseLabels(sentence, userLabels || []) const circleMembersList = circleMembers?.res || [] const assigneesForParsing = circleMembersList.map(member => ({ userId: member.userId, username: member.username || member.displayName?.toLowerCase().replace(/\s+/g, ''), displayName: member.displayName, name: member.displayName, id: member.userId, })) const assigneesResult = parseAssignees(sentence, assigneesForParsing) const repeat = parseRepeatV2(sentence) const dueDateParsed = parseDueDate(sentence, chrono) // Set all the parsed values if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) if (labels.result) { // parseLabels only returns #mentions matched to an existing label setLabelsV2(labels.result) } if (assigneesResult.isAnyone) { // @Anyone was used - set empty assignees (anyone can do the task) setIsAnyoneTask(true) setAssignees([]) } else if (assigneesResult.result && assigneesResult.result.length > 0) { setIsAnyoneTask(false) const parsedAssignees = assigneesResult.result.map(assignee => ({ userId: assignee.userId, })) setAssignees(parsedAssignees) } else { // Only assign to current user if no @ mentions found and userProfile exists setIsAnyoneTask(false) if (userProfile?.id) { setAssignees([ { userId: userProfile.id, }, ]) } } if (repeat.result) { setFrequency(repeat.result) } const syncDueDateStates = parsedDate => { const m = moment(parsedDate) const dateOnly = m.format('YYYY-MM-DD') const timeOnly = m.format('HH:mm') setDueDateOnly(dateOnly) setDueDate(m.format('YYYY-MM-DDTHH:mm:ss')) if (timeOnly !== '23:59') { setUseCustomTime(true) setDueTime(timeOnly) } else { setUseCustomTime(false) setDueTime(null) } } let dueDateHighlight = null if (dueDateParsed.result) { syncDueDateStates(dueDateParsed.result) dueDateHighlight = dueDateParsed.highlight[0] } else if (repeat.dueDate) { syncDueDateStates(repeat.dueDate) } // Create the cleaned sentence by sequentially applying all cleanups let cleanedSentence = sentence if (priority.result) cleanedSentence = priority.cleanedSentence if (pointsParsed.result) { // Apply points cleaning to the current cleaned sentence const pointsReparse = parsePoints(cleanedSentence) if (pointsReparse.result) cleanedSentence = pointsReparse.cleanedSentence } if (labels.result) { // Apply labels cleaning to the current cleaned sentence const labelsReparse = parseLabels(cleanedSentence, userLabels || []) if (labelsReparse.result) cleanedSentence = labelsReparse.cleanedSentence } if (assigneesResult.result) { // Apply assignees cleaning to the current cleaned sentence const assigneesReparse = parseAssignees( cleanedSentence, assigneesForParsing, ) if (assigneesReparse.result) cleanedSentence = assigneesReparse.cleanedSentence } if (repeat.result) { // Apply repeat cleaning to the current cleaned sentence const repeatReparse = parseRepeatV2(cleanedSentence) if (repeatReparse.result) cleanedSentence = repeatReparse.cleanedSentence } if (dueDateParsed.result) { // Apply date cleaning to the current cleaned sentence const dueDateReparse = parseDueDate(cleanedSentence, chrono) if (dueDateReparse.result) cleanedSentence = dueDateReparse.cleanedSentence } setTaskText(sentence) setTaskTitle(cleanedSentence.trim()) // Generate highlights for rendering using original sentence positions const { parts } = renderHighlightedSentence( sentence, repeat.highlight, priority.highlight, labels.highlight, dueDateHighlight, pointsParsed.highlight, assigneesResult.highlight, ) setRenderedParts(parts) const overrides = pendingVoiceOverridesRef.current if (overrides) { pendingVoiceOverridesRef.current = null if ('priority' in overrides) setPriority(overrides.priority || 0) if ('frequency' in overrides) setFrequency(overrides.frequency) if ('labelIds' in overrides) setLabelsV2(overrides.labelIds || []) if ('notificationMetadata' in overrides) { setNotificationMetadata( overrides.notificationMetadata || { templates: [] }, ) } if ('assignees' in overrides || 'isAnyone' in overrides) { setIsAnyoneTask(!!overrides.isAnyone) setAssignees(overrides.assignees || []) } if ('dueDate' in overrides) { if (overrides.dueDate) { syncDueDateStates(overrides.dueDate) } else { setDueDate(null) setDueDateOnly(null) setDueTime(null) setUseCustomTime(false) } } } }, [userLabels, renderHighlightedSentence, circleMembers, userProfile], ) useEffect(() => { if ( !isModalOpen || userLabelsLoading || isCircleMembersLoading || !userProfile ) { return } processText(taskText) }, [ taskText, userLabelsLoading, isCircleMembersLoading, isModalOpen, userProfile, processText, ]) const handleDueDateChange = e => { const dateValue = e.target.value setDueDateOnly(dateValue) if (useCustomTime && dueTime) { setDueDate( 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')) } } const handleDueTimeChange = e => { const timeValue = e.target.value setDueTime(timeValue) if (dueDateOnly) { 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'), ) } } } const handleUseCustomTimeChange = checked => { setUseCustomTime(checked) if (checked) { const defaultTime = dueTime || '18:00' 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'), ) } } } const handleEnterPressed = () => { createChore() } // The scan keeps its source image when asked: upload it against the draft so // the server promotes it onto the chore the same way manual uploads are. const attachScannedImage = async imageSource => { // Creating the chore promotes whatever draft attachments exist at that // moment, so Create waits on this upload rather than orphaning it. setIsAttachingScan(true) try { const file = await imageSourceToFile( imageSource, `scan-${Date.now()}.jpg`, ) if (!file) return const uploaded = await uploadFile(file) if (!uploaded) return setAttachments(prev => [ ...prev, { url: uploaded.url, path: uploaded.path, name: uploaded.fileName }, ]) } finally { setIsAttachingScan(false) } } const handleTaskExtracted = ({ attachmentImage, description: extractedDesc, dueDate: extractedDue, taskName, }) => { if (attachmentImage) { attachScannedImage(attachmentImage) } if (taskName) { processText(taskName) } if (extractedDesc) { setDescription(extractedDesc) setHasDescription(true) } if (extractedDue) { const m = moment(new Date(extractedDue)) if (m.isValid()) { setDueDateOnly(m.format('YYYY-MM-DD')) setDueDate(m.endOf('day').format('YYYY-MM-DDTHH:mm:ss')) } } } // Single voice-captured task: land it in the smart input so the user // reviews it with the normal pickers before creating. const handleVoiceSingle = (text, overrides = {}) => { setShowVoice(false) if (Object.keys(overrides).length > 0) { pendingVoiceOverridesRef.current = overrides } setTaskText(text) } // Multiple voice-captured tasks: they were reviewed as cards in the panel, // so create them all directly. const handleVoiceCreateMany = async parsedTasks => { setCreatingVoiceTasks(true) const notificationTemplates = getDefaultNotification() for (const parsed of parsedTasks) { const chore = buildChorePayload(parsed, { userProfile, projectId, notificationTemplates, }) try { const result = await createChoreMutation.mutateAsync(chore) if (result?._pendingCreate) { onChoreUpdate(result) } else { onChoreUpdate({ ...chore, ...result, id: result?.id, nextDueDate: chore.dueDate, }) } } catch (error) { console.error('Error creating voice task:', error) } } setCreatingVoiceTasks(false) handleCloseModal(false) } // Footer confirm while the voice panel is open: one task lands in the smart // input for review, several are created straight away. const handleVoiceConfirm = () => { const { segments } = voiceState if (segments.length === 1) { handleVoiceSingle(segments[0].text, segments[0].overrides || {}) } else if (segments.length > 1) { // Parse only at confirm time — the cards already parse for their own // display, so there's no need to keep a parsed copy in modal state const parseCtx = { userLabels: voiceLabels, members: voiceMembers, currentUserId: userProfile?.id, } handleVoiceCreateMany( segments.map(segment => ({ ...parseVoiceTask(segment.text, parseCtx), ...(segment.overrides || {}), })), ) } } const handleCloseModal = forceRefetch => { onClose(forceRefetch) setShowScan(false) setShowVoice(false) setVoiceState({ segments: [], isListening: false }) setScanState({ phase: 'idle', primaryAction: null }) setCreatingVoiceTasks(false) setIsAttachingScan(false) setTaskText('') setTaskTitle('') setDueDate(null) setFrequency(null) setPriority(0) setPoints(-1) setIsAnyoneTask(false) setHasDescription(false) setDescription(null) setSubTasks(null) setHasSubTasks(false) setLabelsV2([]) setAssignees([]) setProjectId(getInitialProject()) setDeadlineOffset(-1) setRequireApproval(false) setCompletionWindow(-1) setAssignStrategy('keep_last_assigned') setIsPrivate(false) setShowAdvanced(false) setDueDateOnly(null) setDueTime(null) setUseCustomTime(false) setAttachments([]) setDraftId(generateUUID()) } const createChore = () => { // A scanned attachment still uploading would be orphaned by the create if (isAttachingScan) return // Handle different assignee scenarios let finalAssignees = assignees let finalAssignedTo = null let finalAssignStrategy = assignStrategy if (isAnyoneTask) { finalAssignees = [] finalAssignedTo = null finalAssignStrategy = 'no_assignee' } else if (assignees.length === 0) { finalAssignees = [{ userId: userProfile?.id }] finalAssignedTo = userProfile?.id finalAssignStrategy = assignStrategy } else { finalAssignedTo = assignees[0].userId finalAssignStrategy = assignStrategy } const chore = { name: taskTitle, description: description, assignees: finalAssignees, dueDate: dueDate ? new Date(dueDate).toISOString() : null, assignedTo: finalAssignedTo, assignStrategy: finalAssignStrategy, isRolling: false, labelsV2: labelsV2, priority: priority ? Number(priority) : 0, points: points > -1 ? points : null, deadlineOffset: deadlineOffset < 0 ? null : deadlineOffset, completionWindow: completionWindow < 0 || !dueDate ? null : completionWindow, requireApproval: requireApproval, isPrivate: isPrivate, status: 0, frequencyType: 'once', frequencyMetadata: {}, notification: false, notificationMetadata: {}, subTasks: subTasks?.length > 0 ? subTasks : null, projectId: projectId === 'default' ? null : projectId, draftId: draftId, } // Reminders are a Plus feature and only make sense when the user kept at // least one template; without the flag the backend never schedules them. const hasReminders = isPlusAccount(userProfile) && notificationMetadata?.templates?.length > 0 if (frequency) { chore.frequencyType = frequency.frequencyType chore.frequencyMetadata = frequency.frequencyMetadata chore.frequency = frequency.frequency } if (!frequency && dueDate) { // Use RFC3339/ISO-8601 format expected by backend. chore.nextDueDate = new Date(dueDate).toISOString() } if (hasReminders && (frequency || dueDate)) { chore.notification = true chore.notificationMetadata = notificationMetadata } createChoreMutation .mutateAsync(chore) .then(result => { const choreData = result if (choreData?._pendingCreate) { // Offline: task queued, add temp chore to UI immediately onChoreUpdate(choreData) } else { // Online: choreData is the created chore object returned by the mutation onChoreUpdate({ ...chore, ...choreData, id: choreData?.id, nextDueDate: chore.dueDate, }) } setTaskText('') }) .catch(error => { console.error('Error creating chore:', error) }) handleCloseModal(false) } latestRef.current = { isModalOpen, hasDescription, dueDate, createChore, handleCloseModal, } if (isCircleMembersLoading || isProjectsLoading) { return <> } return ( <> {showVoice && ( )} {showScan && scanState.primaryAction && ( )} {showScan && scanState.phase === 'processing' && ( )} {!showScan && !showVoice && ( )} } > {!showScan && !showVoice && ( <> Task in a sentence: This feature lets you create a task simply by typing a sentence. It attempt parses the sentence to identify the task's due date, priority, and frequency. Examples:
  • Priority:For highest priority any of the following keyword P1, Urgent,{' '} Important, or ASAP. For lower priorities, use P2, P3, or{' '} P4.
  • Due date: Specify dates with phrases like tomorrow, next week,{' '} Monday, or August 1st at 12pm.
  • Frequency: Set recurring tasks with terms like daily, weekly,{' '} monthly, yearly, or patterns such as{' '} every Tuesday and Thursday.
  • } />
    { setScanAutoCapture(true) setShowScan(true) } : undefined } onPhotoSelected={ llmAvailable ? dataUrl => { setScanAutoCapture(false) setPendingPhotoUrl(dataUrl) setShowScan(true) } : undefined } onVoiceClick={ voiceAvailable ? () => setShowVoice(true) : undefined } placeholder='Type your task...' onChange={text => { setTaskText(text) if (!text) setTaskTitle('') }} customRenderer={renderedParts} onEnterPressed={handleEnterPressed} onShiftEnterPressed={() => { if (!hasDescription) { setHasDescription(true) } setTimeout(() => richTextEditorRef.current?.focus(), 50) }} suggestions={{ '#': { value: 'id', display: 'name', options: userLabels ? userLabels : [], creatable: true, onCreate: handleCreateLabel, }, '!': { value: 'id', display: 'name', options: [ { id: '1', name: 'P1' }, { id: '2', name: 'P2' }, { id: '3', name: 'P3' }, { id: '4', name: 'P4' }, ], }, '@': { value: 'userId', display: 'displayName', options: [ { userId: 'anyone', displayName: 'Anyone' }, ...(circleMembers?.res || []), ], }, '*': { value: 'id', display: 'name', options: [ { id: '1', name: '1 point' }, { id: '5', name: '5 points' }, { id: '10', name: '10 points' }, { id: '25', name: '25 points' }, { id: '50', name: '50 points' }, { id: '100', name: '100 points' }, ], }, }} />
    { setDueDate(null) setDueDateOnly(null) setDueTime(null) setUseCustomTime(false) }} /> setFrequency(null)} /> setPriority(0)} emptyDisplay={pickerEmptyDisplay} priorityColors={priorityColors} priorityLabels={priorityLabels} /> a.userId)} isAnyone={isAnyoneTask} onChange={userIds => { if (userIds.includes('anyone')) { setIsAnyoneTask(true) setAssignees([]) } else { setIsAnyoneTask(false) setAssignees(userIds.map(userId => ({ userId }))) } }} onClear={() => { setIsAnyoneTask(false) setAssignees([]) }} currentUserId={userProfile?.id} members={circleMembers?.res || []} /> label.id)} onChange={ids => setLabelsV2( (userLabels || []).filter(label => ids.includes(label.id)), ) } onClear={() => setLabelsV2([])} labels={userLabels || []} /> setAttachments([])} emptyDisplay={pickerEmptyDisplay} entityType='chore_attachment_draft' draftId={draftId} /> setNotificationMetadata({ templates: [] })} emptyDisplay={pickerEmptyDisplay} /> {!hasDescription && ( )} {!hasSubTasks && ( )} setShowAdvanced(v => !v)} activeCount={ [ points > -1, requireApproval, completionWindow > -1, deadlineOffset > -1, ].filter(Boolean).length } emptyDisplay={pickerEmptyDisplay} /> 1} hasAssignees={assignees.length > 0} isPrivate={isPrivate} onIsPrivateChange={setIsPrivate} /> {hasDescription && ( Description:
    )} {hasSubTasks && ( Subtasks: )} )} {showVoice && ( )} {showScan && ( { setShowScan(false) setScanAutoCapture(false) setPendingPhotoUrl(null) setScanState({ phase: 'idle', primaryAction: null }) }} /> )}
    ) } export default TaskInput