Merge pull request #135 from donetick/bugfixes-07-09-2026

improve task management
This commit is contained in:
Mohamad Tarbin
2026-07-13 00:21:40 -04:00
committed by GitHub
7 changed files with 1154 additions and 795 deletions

View File

@@ -28,6 +28,7 @@ export const AVAILABLE_LANGUAGES = [
{ code: 'nl', name: 'Dutch', nativeName: 'Nederlands' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
{ code: 'pt', name: 'Portuguese (Brazil)', nativeName: 'Português (Brasil)' },
{ code: 'ja', name: 'Japanese', nativeName: '日本語' },
]
export const LocalizationProvider = ({ children }) => {

View File

@@ -83,6 +83,8 @@ const SortAndGrouping = ({
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
{ name: 'Created Date', value: 'created_date' },
{ name: 'Updated Date', value: 'updated_date' },
]
const filterItems = [
@@ -332,6 +334,8 @@ const SortAndGrouping = ({
{ name: 'Due Date', value: 'due_date' },
{ name: 'Priority', value: 'priority' },
{ name: 'Labels', value: 'labels' },
{ name: 'Created Date', value: 'created_date' },
{ name: 'Updated Date', value: 'updated_date' },
].map((item, index) => (
<MenuItem
key={`${k}-${item?.value}`}

View File

@@ -1,12 +1,4 @@
import {
Box,
Button,
FormControl,
Input,
Option,
Select,
Typography,
} from '@mui/joy'
import { Box, Button, FormControl, Input, Typography } from '@mui/joy'
import { useEffect, useState } from 'react'
import { useQueryClient } from '@tanstack/react-query'
@@ -125,39 +117,30 @@ function LabelModal({ isOpen, onClose, label }) {
<Typography gutterBottom level='body-sm' alignSelf='start'>
Color
</Typography>
<Select
value={color}
onChange={(e, value) => value && setColor(value)}
renderValue={selected => (
<Typography
startDecorator={
<Box
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>
}
>
{selected.label}
</Typography>
)}
>
{LABEL_COLORS.map(val => (
<Option key={val.value} value={val.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{ background: val.value }}
/>
<Typography sx={{ ml: 1 }} variant='caption'>
{val.name}
</Typography>
</Box>
</Option>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{LABEL_COLORS.map(colorOption => (
<Box
key={colorOption.value}
title={colorOption.name}
onClick={() => setColor(colorOption.value)}
sx={{
width: 26,
height: 26,
borderRadius: '50%',
background: colorOption.value,
cursor: 'pointer',
outline:
color === colorOption.value
? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent',
outlineOffset: '2px',
transition: 'all 0.15s ease',
flexShrink: 0,
'&:hover': { transform: 'scale(1.2)' },
}}
/>
))}
</Select>
</Box>
</FormControl>
{error && (

View File

@@ -5,8 +5,6 @@ import {
FormControl,
FormLabel,
Input,
Option,
Select,
Stack,
Textarea,
Typography,
@@ -223,39 +221,30 @@ const ProjectModal = ({ isOpen, onClose, onSave, project }) => {
{/* Color Selection */}
<FormControl>
<FormLabel>Project Color</FormLabel>
<Select
value={projectColor}
onChange={(e, value) => value && setProjectColor(value)}
renderValue={selected => (
<Typography
startDecorator={
<Box
className='size-4'
borderRadius={10}
sx={{ background: selected.value }}
/>
}
>
{selected.label}
</Typography>
)}
>
{PROJECT_COLORS.map(color => (
<Option key={color.value} value={color.value}>
<Box className='flex items-center justify-between'>
<Box
width={20}
height={20}
borderRadius={10}
sx={{ background: color.value }}
/>
<Typography sx={{ ml: 1 }} variant='caption'>
{color.name}
</Typography>
</Box>
</Option>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{PROJECT_COLORS.map(colorOption => (
<Box
key={colorOption.value}
title={colorOption.name}
onClick={() => setProjectColor(colorOption.value)}
sx={{
width: 26,
height: 26,
borderRadius: '50%',
background: colorOption.value,
cursor: 'pointer',
outline:
projectColor === colorOption.value
? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent',
outlineOffset: '2px',
transition: 'all 0.15s ease',
flexShrink: 0,
'&:hover': { transform: 'scale(1.2)' },
}}
/>
))}
</Select>
</Box>
</FormControl>
{/* Error Message */}

View File

@@ -1,23 +1,125 @@
import { CopyAll } from '@mui/icons-material'
import { Capacitor } from '@capacitor/core'
import {
CheckCircle,
ContentCopy,
ErrorOutline,
Nfc,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
CircularProgress,
IconButton,
Input,
ListItem,
Switch,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import { Capacitor } from '@capacitor/core'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { startNativeNFCWrite } from '../../../service/NFCWriter'
const pulseKeyframes = `
@keyframes nfc-pulse {
0% { transform: scale(1); opacity: 0.6; }
70% { transform: scale(1.6); opacity: 0; }
100% { transform: scale(1.6); opacity: 0; }
}
@keyframes nfc-pulse-2 {
0% { transform: scale(1); opacity: 0.4; }
70% { transform: scale(2.1); opacity: 0; }
100% { transform: scale(2.1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.nfc-pulse-ring { animation: none !important; }
}
`
function NFCIcon({ status }) {
const isWaiting = status === 'waiting_for_tag'
const isSuccess = status === 'success'
const isError = status === 'error'
return (
<Box
sx={{
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 96,
height: 96,
mx: 'auto',
mb: 3,
}}
>
{isWaiting && (
<>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
border: '2px solid',
borderColor: 'primary.400',
animation: 'nfc-pulse 1.8s ease-out infinite',
}}
/>
<Box
className='nfc-pulse-ring'
sx={{
position: 'absolute',
inset: 0,
borderRadius: '50%',
border: '2px solid',
borderColor: 'primary.300',
animation: 'nfc-pulse-2 1.8s ease-out infinite 0.4s',
}}
/>
</>
)}
<Box
sx={{
width: 80,
height: 80,
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: isSuccess
? 'success.softBg'
: isError
? 'danger.softBg'
: isWaiting
? 'primary.softBg'
: 'neutral.100',
transition: 'background-color 0.25s ease',
}}
>
{isSuccess ? (
<CheckCircle sx={{ fontSize: 40, color: 'success.500' }} />
) : isError ? (
<ErrorOutline sx={{ fontSize: 40, color: 'danger.500' }} />
) : (
<Nfc
sx={{
fontSize: 40,
color: isWaiting ? 'primary.500' : 'neutral.500',
transition: 'color 0.25s ease',
}}
/>
)}
</Box>
</Box>
)
}
function WriteNFCModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const [nfcStatus, setNfcStatus] = useState('idle') // 'idle' | 'writing' | 'waiting_for_tag' | 'success' | 'error'
const [nfcStatus, setNfcStatus] = useState('idle')
const [errorMessage, setErrorMessage] = useState('')
const [isAutoCompleteWhenScan, setIsAutoCompleteWhenScan] = useState(false)
const [copied, setCopied] = useState(false)
const cancelScanRef = useRef(null)
const isNative = Capacitor.isNativePlatform()
@@ -45,6 +147,12 @@ function WriteNFCModal({ config }) {
setNfcStatus('idle')
}
const handleCopy = () => {
navigator.clipboard.writeText(getURL())
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const writeToNFC = async () => {
const url = getURL()
@@ -78,99 +186,182 @@ function WriteNFCModal({ config }) {
} else {
setNfcStatus('error')
setErrorMessage(
'NFC is not supported by this browser. You can still copy the URL and write it to an NFC tag using a compatible device.',
'NFC is not supported by this browser. Copy the URL and write it to an NFC tag using a compatible device.',
)
}
}
}
const renderBody = () => {
if (nfcStatus === 'success') {
return (
<Typography level='body-md' gutterBottom>
URL written to NFC tag successfully!
</Typography>
)
}
const isWaiting = nfcStatus === 'waiting_for_tag' || nfcStatus === 'writing'
const isSuccess = nfcStatus === 'success'
const isError = nfcStatus === 'error'
if (nfcStatus === 'waiting_for_tag') {
return (
<>
<Box
display='flex'
flexDirection='column'
alignItems='center'
gap={2}
py={3}
>
<CircularProgress size='lg' />
<Typography level='body-md' textAlign='center'>
Hold your device near the NFC tag
</Typography>
</Box>
<Button
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
</>
)
}
const title = isSuccess
? 'Tag written!'
: isError
? 'Something went wrong'
: isWaiting
? 'Hold near NFC tag'
: 'Write to NFC'
return (
<>
<Typography level='body-md' gutterBottom>
{nfcStatus === 'error'
? errorMessage
: 'Press the button below to write to NFC.'}
</Typography>
<Input
value={getURL()}
fullWidth
readOnly
label='URL'
sx={{ mt: 1 }}
endDecorator={
<CopyAll
sx={{ cursor: 'pointer' }}
onClick={() => {
navigator.clipboard.writeText(getURL())
alert('URL copied to clipboard!')
}}
/>
}
/>
<ListItem>
<Checkbox
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
label='Auto-complete when scanned'
/>
</ListItem>
<Box display='flex' justifyContent='space-around' mt={1}>
<Button
size='lg'
onClick={writeToNFC}
fullWidth
disabled={nfcStatus === 'writing'}
>
Write NFC
</Button>
</Box>
</>
)
}
const subtitle = isSuccess
? 'Your NFC tag is ready to use.'
: isError
? errorMessage
: isWaiting
? 'Keep your device near the tag until complete.'
: 'Encode this task link onto any NFC tag.'
return (
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Typography level='h4' mb={1}>
{nfcStatus === 'success' ? 'Success!' : 'Write to NFC'}
</Typography>
{renderBody()}
</ResponsiveModal>
<>
<style>{pulseKeyframes}</style>
<ResponsiveModal open={config?.isOpen} onClose={handleClose}>
<Box sx={{ px: 0.5, pb: 1 }}>
{/* Icon */}
<NFCIcon status={nfcStatus} />
{/* Heading */}
<Typography
level='title-lg'
textAlign='center'
sx={{ mb: 0.75, fontWeight: 600 }}
>
{title}
</Typography>
<Typography
level='body-sm'
textAlign='center'
sx={{ color: 'text.secondary', mb: 3, px: 2 }}
>
{subtitle}
</Typography>
{/* Idle / Error: URL + toggle + CTA */}
{!isWaiting && !isSuccess && (
<>
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{
mb: 0.75,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.06em',
color: 'text.tertiary',
}}
>
Tag URL
</Typography>
<Input
value={getURL()}
readOnly
size='sm'
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
'--Input-focusedHighlight': 'transparent',
bgcolor: 'neutral.50',
}}
endDecorator={
<IconButton
size='sm'
variant='plain'
color={copied ? 'success' : 'neutral'}
onClick={handleCopy}
title='Copy URL'
>
<ContentCopy sx={{ fontSize: 16 }} />
</IconButton>
}
/>
{copied && (
<Typography
level='body-xs'
sx={{ color: 'success.500', mt: 0.5, textAlign: 'right' }}
>
Copied!
</Typography>
)}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
py: 1.5,
px: 2,
borderRadius: 'md',
bgcolor: 'neutral.50',
mb: 3,
}}
>
<Box>
<Typography level='body-sm' fontWeight={500}>
Auto-complete on scan
</Typography>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Mark task done when tag is tapped
</Typography>
</Box>
<Switch
checked={isAutoCompleteWhenScan}
onChange={e => setIsAutoCompleteWhenScan(e.target.checked)}
size='sm'
/>
</Box>
<Box sx={{ display: 'flex', gap: 1.5 }}>
<Button
size='lg'
variant='outlined'
color='neutral'
sx={{ flex: 1 }}
onClick={isError ? handleClose : handleClose}
>
Cancel
</Button>
<Button
size='lg'
sx={{ flex: 1 }}
onClick={writeToNFC}
disabled={nfcStatus === 'writing'}
startDecorator={
nfcStatus === 'writing' ? (
<CircularProgress size='sm' />
) : (
<Nfc />
)
}
>
{nfcStatus === 'writing' ? 'Starting…' : 'Write tag'}
</Button>
</Box>
</>
)}
{/* Waiting state */}
{isWaiting && (
<Button
size='lg'
variant='outlined'
color='neutral'
fullWidth
onClick={handleCancel}
>
Cancel
</Button>
)}
{/* Success state */}
{isSuccess && (
<Button size='lg' fullWidth onClick={handleClose}>
Done
</Button>
)}
</Box>
</ResponsiveModal>
</>
)
}

View File

@@ -22,6 +22,8 @@ import {
import SmartTaskTitleInput from './SmartTaskTitleInput'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import { TASK_COLOR } from '../../utils/Colors'
import AssigneePickerField from './AssigneePickerField'
import AttachmentPickerField from './AttachmentPickerField'
@@ -29,12 +31,10 @@ import DueDatePickerField from './DueDatePickerField'
import LabelsPickerField from './LabelsPickerField'
import LearnMoreButton from './LearnMore'
import NotificationPickerField from './NotificationPickerField'
import ScanPanel from './ScanToTask/ScanPanel'
import { useDocumentScanner } from '../../hooks/useDocumentScanner'
import { localAIService } from '../../service/LocalAIService'
import PriorityPickerField from './PriorityPickerField'
import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask'
const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate')
@@ -558,7 +558,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
createChore()
}
const handleTaskExtracted = ({ taskName, description: extractedDesc, dueDate: extractedDue }) => {
const handleTaskExtracted = ({
taskName,
description: extractedDesc,
dueDate: extractedDue,
}) => {
if (taskName) {
processText(taskName)
}
@@ -700,314 +704,330 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => {
return (
<>
<ResponsiveModal
open={isModalOpen}
onClose={handleCloseModal}
size='lg'
fullWidth={true}
title='Create new task'
footer={
<Box
sx={{
marginTop: 2,
display: 'flex',
flexDirection: 'row',
justifyContent: 'end',
gap: 1,
}}
>
<Button
size='lg'
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
sx={{ ml: 1 }}
withCtrl={false}
/>
)}
</Button>
<Button
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Box>
}
>
{!showScan && (
<>
<Box>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Task in a sentence:</Typography>
<LearnMoreButton
content={
<>
<Typography level='body-sm' sx={{ mb: 1 }}>
This feature lets you create a task simply by typing a
sentence. It attempt parses the sentence to identify the
task&apos;s due date, priority, and frequency.
</Typography>
<Typography level='body-sm' sx={{ fontWeight: 'bold', mt: 2 }}>
Examples:
</Typography>
<Typography
level='body-sm'
component='ul'
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
>
<li>
<strong>Priority:</strong>For highest priority any of the
following keyword <em>P1</em>, <em>Urgent</em>,{' '}
<em>Important</em>, or <em>ASAP</em>. For lower
priorities, use <em>P2</em>, <em>P3</em>, or <em>P4</em>.
</li>
<li>
<strong>Due date:</strong> Specify dates with phrases
like <em>tomorrow</em>, <em>next week</em>,{' '}
<em>Monday</em>, or <em>August 1st at 12pm</em>.
</li>
<li>
<strong>Frequency:</strong> Set recurring tasks with
terms like <em>daily</em>, <em>weekly</em>,{' '}
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
<em>every Tuesday and Thursday</em>.
</li>
</Typography>
</>
}
/>
</Box>
<SmartTaskTitleInput
autoFocus
value={taskText}
isNativeScanner={isNativeScanner}
onScanClick={llmAvailable ? () => {
setScanAutoCapture(true)
setShowScan(true)
} : undefined}
onPhotoSelected={llmAvailable ? dataUrl => {
setScanAutoCapture(false)
setPendingPhotoUrl(dataUrl)
setShowScan(true)
} : undefined}
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
if (!text) setTaskTitle('')
}}
customRenderer={renderedParts}
onEnterPressed={handleEnterPressed}
suggestions={{
'#': {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
},
'!': {
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' },
],
},
}}
/>
</Box>
<ResponsiveModal
open={isModalOpen}
onClose={handleCloseModal}
size='lg'
fullWidth={true}
title='Create new task'
footer={
<Box
sx={{
paddingTop: 2,
paddingBottom: 1,
marginTop: 2,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
flexWrap: isMobile ? 'nowrap' : 'wrap',
justifyContent: 'end',
gap: 1,
}}
>
<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_draft'
draftId={draftId}
/>
<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='outlined'
color='neutral'
size='md'
onClick={() => setHasDescription(true)}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='E' />
}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasSubTasks(true)}
endDecorator={
showKeyboardShortcuts && <KeyboardShortcutHint shortcut='J' />
}
>
Subtasks
</Button>
)}
</Box>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<div>
<RichTextEditor
ref={richTextEditorRef}
onChange={setDescription}
value={description || ''}
entityType={'chore_description'}
<Button
size='lg'
variant='outlined'
color='neutral'
onClick={handleCloseModal}
>
Cancel
{showKeyboardShortcuts && (
<KeyboardShortcutHint
shortcut='Esc'
sx={{ ml: 1 }}
withCtrl={false}
/>
</div>
</Box>
)}
{hasSubTasks && (
)}
</Button>
<Button
size='lg'
variant='solid'
color='primary'
disabled={!taskTitle.trim()}
onClick={createChore}
>
Create
{showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='Enter' sx={{ ml: 1 }} />
)}
</Button>
</Box>
}
>
{!showScan && (
<>
<Box>
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks
editMode={true}
tasks={subTasks ? subTasks : []}
setTasks={setSubTasks}
shouldFocus={true}
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
}}
>
<Typography level='body-sm'>Task in a sentence:</Typography>
<LearnMoreButton
content={
<>
<Typography level='body-sm' sx={{ mb: 1 }}>
This feature lets you create a task simply by typing a
sentence. It attempt parses the sentence to identify the
task&apos;s due date, priority, and frequency.
</Typography>
<Typography
level='body-sm'
sx={{ fontWeight: 'bold', mt: 2 }}
>
Examples:
</Typography>
<Typography
level='body-sm'
component='ul'
sx={{ pl: 2, mt: 1, listStyle: 'disc' }}
>
<li>
<strong>Priority:</strong>For highest priority any of
the following keyword <em>P1</em>, <em>Urgent</em>,{' '}
<em>Important</em>, or <em>ASAP</em>. For lower
priorities, use <em>P2</em>, <em>P3</em>, or{' '}
<em>P4</em>.
</li>
<li>
<strong>Due date:</strong> Specify dates with phrases
like <em>tomorrow</em>, <em>next week</em>,{' '}
<em>Monday</em>, or <em>August 1st at 12pm</em>.
</li>
<li>
<strong>Frequency:</strong> Set recurring tasks with
terms like <em>daily</em>, <em>weekly</em>,{' '}
<em>monthly</em>, <em>yearly</em>, or patterns such as{' '}
<em>every Tuesday and Thursday</em>.
</li>
</Typography>
</>
}
/>
</Box>
<SmartTaskTitleInput
autoFocus
value={taskText}
isNativeScanner={isNativeScanner}
onScanClick={
llmAvailable
? () => {
setScanAutoCapture(true)
setShowScan(true)
}
: undefined
}
onPhotoSelected={
llmAvailable
? dataUrl => {
setScanAutoCapture(false)
setPendingPhotoUrl(dataUrl)
setShowScan(true)
}
: undefined
}
placeholder='Type your task...'
onChange={text => {
setTaskText(text)
if (!text) setTaskTitle('')
}}
customRenderer={renderedParts}
onEnterPressed={handleEnterPressed}
suggestions={{
'#': {
value: 'id',
display: 'name',
options: userLabels ? userLabels : [],
},
'!': {
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' },
],
},
}}
/>
</Box>
)}
</>
)}
{showScan && (
<ScanPanel
open
autoCapture={scanAutoCapture}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onClose={() => {
setShowScan(false)
setScanAutoCapture(false)
setPendingPhotoUrl(null)
}}
/>
)}
</ResponsiveModal>
<Box
sx={{
paddingTop: 2,
paddingBottom: 1,
display: 'flex',
flexDirection: 'row',
gap: 1.5,
overflowX: 'auto',
'&::-webkit-scrollbar': { display: 'none' },
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_draft'
draftId={draftId}
/>
<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='outlined'
color='neutral'
size='md'
onClick={() => setHasDescription(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='E' />
)
}
>
Description
</Button>
)}
{!hasSubTasks && (
<Button
startDecorator={<Add />}
variant='outlined'
color='neutral'
size='md'
onClick={() => setHasSubTasks(true)}
endDecorator={
showKeyboardShortcuts && (
<KeyboardShortcutHint shortcut='J' />
)
}
>
Subtasks
</Button>
)}
</Box>
{hasDescription && (
<Box>
<Typography level='body-sm'>Description:</Typography>
<div>
<RichTextEditor
ref={richTextEditorRef}
onChange={setDescription}
value={description || ''}
entityType={'chore_description'}
/>
</div>
</Box>
)}
{hasSubTasks && (
<Box>
<Typography level='body-sm'>Subtasks:</Typography>
<SubTasks
editMode={true}
tasks={subTasks ? subTasks : []}
setTasks={setSubTasks}
shouldFocus={true}
/>
</Box>
)}
</>
)}
{showScan && (
<ScanPanel
open
autoCapture={scanAutoCapture}
onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl}
onClose={() => {
setShowScan(false)
setScanAutoCapture(false)
setPendingPhotoUrl(null)
}}
/>
)}
</ResponsiveModal>
</>
)
}

View File

@@ -7,7 +7,6 @@ import {
} from '@dnd-kit/core'
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
@@ -16,10 +15,8 @@ import {
ChevronRight,
Delete,
DragIndicator,
Edit,
ExpandMore,
KeyboardReturn,
PlaylistAdd,
} from '@mui/icons-material'
import {
Box,
@@ -31,47 +28,56 @@ import {
ListItem,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext'
import { useUserProfile } from '../../queries/UserQueries'
import { CompleteSubTask } from '../../utils/Fetcher'
function getVisibleOrder(tasks, expandedIds) {
const result = []
const addTask = task => {
result.push(task)
if (expandedIds.has(task.id)) {
tasks
.filter(t => t.parentId === task.id)
.sort((a, b) => a.orderId - b.orderId)
.forEach(addTask)
}
}
tasks
.filter(t => t.parentId === null)
.sort((a, b) => a.orderId - b.orderId)
.forEach(addTask)
return result
}
function nextTempId(tasks) {
return Math.min(0, ...tasks.map(t => t.id)) - 1
}
function SortableItem({
task,
index,
handleToggle,
handleDelete,
handleAddSubtask,
allTasks,
setTasks,
level = 0,
level,
editMode,
performers = [],
expandedIds,
onToggleExpand,
handleToggle,
inputRefs,
onKeyDown,
performers,
}) {
const { fmt } = useLocalization()
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({
id: task.id,
data: { completedAt: task.completedAt, completedBy: task.completedBy },
// Add touch sensor options for better mobile scrolling
options: {
activationConstraint: {
// Require a small movement before activating drag to allow scrolling
delay: 250,
tolerance: 5,
},
},
})
useSortable({ id: task.id })
const [isEditing, setIsEditing] = useState(false)
const [editedText, setEditedText] = useState(task.name)
const [expanded, setExpanded] = useState(false)
const [showAddSubtask, setShowAddSubtask] = useState(false)
const [newSubtask, setNewSubtask] = useState('')
// Find child tasks
const childTasks = allTasks.filter(t => t.parentId === task.id)
const expanded = expandedIds.has(task.id)
const childTasks = allTasks
.filter(t => t.parentId === task.id)
.sort((a, b) => a.orderId - b.orderId)
const hasChildren = childTasks.length > 0
const style = {
@@ -79,226 +85,154 @@ function SortableItem({
transition,
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
flexDirection: { xs: 'column', sm: 'row' },
// Enable default touch behavior for scrolling
touchAction: 'auto',
paddingLeft: `${level * 24}px`,
}
const handleEdit = () => {
setIsEditing(true)
}
const handleSave = () => {
setIsEditing(false)
task.name = editedText
// Update the task in the parent component
setTasks(prevTasks =>
prevTasks.map(t => (t.id === task.id ? { ...t, name: editedText } : t)),
)
}
const handleExpandClick = () => {
setExpanded(!expanded)
}
const handleAddSubtaskClick = () => {
setShowAddSubtask(!showAddSubtask)
}
const submitNewSubtask = () => {
if (!newSubtask.trim()) return
handleAddSubtask(task.id, newSubtask)
setNewSubtask('')
setShowAddSubtask(false)
setExpanded(true) // Auto-expand to show the new subtask
}
const handleKeyPress = event => {
if (event.key === 'Enter') {
submitNewSubtask()
}
}
return (
<>
<ListItem ref={setNodeRef} style={style} {...attributes}>
{editMode && (
<IconButton
{...listeners}
{...attributes}
size='sm'
// Add data attribute for selective activation
data-drag-handle='true'
// Only restrict touch actions on the drag handle
sx={{ touchAction: 'none' }}
sx={{ touchAction: 'none', cursor: 'grab' }}
>
<DragIndicator />
</IconButton>
)}
{hasChildren && (
{hasChildren ? (
<IconButton
size='sm'
variant='plain'
color='neutral'
onClick={handleExpandClick}
onClick={() => onToggleExpand(task.id)}
>
{expanded ? <ExpandMore /> : <ChevronRight />}
</IconButton>
)}
) : level > 0 ? (
<Box sx={{ width: 28 }} />
) : null}
{!hasChildren && level > 0 && (
<Box sx={{ width: 28 }} /> // Spacer for alignment
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flex: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
{!editMode && (
<Checkbox
checked={!!task.completedAt}
onChange={() => handleToggle(task.id)}
/>
)}
<Box
sx={{
flex: 1,
minHeight: 50,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
onClick={() => {
if (!editMode) {
handleToggle(task.id)
{editMode ? (
<Input
slotProps={{
input: {
ref: el => {
inputRefs.current[task.id] = el
},
},
}}
value={task.name}
placeholder='Task name...'
onChange={e =>
setTasks(prev =>
prev.map(t =>
t.id === task.id ? { ...t, name: e.target.value } : t,
),
)
}
}}
>
{isEditing ? (
<Input
value={editedText}
onChange={e => setEditedText(e.target.value)}
onBlur={handleSave}
onKeyDown={e => {
if (!(e.metaKey || e.ctrlKey) && e.key === 'Enter') {
handleSave()
}
}}
autoFocus
/>
) : (
onKeyDown={e => onKeyDown(e, task)}
sx={{
flex: 1,
border: 'none',
backgroundColor: 'transparent',
boxShadow: 'none',
'--Input-focusedHighlight': 'var(--joy-palette-primary-300)',
'&:not(:focus-within)': {
boxShadow: 'none',
backgroundColor: 'transparent',
},
}}
/>
) : (
<Box
sx={{
flex: 1,
minHeight: 50,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
cursor: 'pointer',
}}
onClick={() => handleToggle(task.id)}
>
<Typography
sx={{
textDecoration: task.completedAt ? 'line-through' : 'none',
}}
onDoubleClick={handleEdit}
>
{task.name}
</Typography>
)}
{task.completedAt && (
<Typography
sx={{
display: { xs: 'block', sm: 'inline' },
color: 'text.secondary',
fontSize: 'sm',
}}
>
{fmt.dateTime(task.completedAt)}
{performers.find(p => p.userId === task.completedBy) ? (
<Chip>
{
performers.find(p => p.userId === task.completedBy)
.displayName
}
</Chip>
) : null}
</Typography>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
{editMode && (
<>
<IconButton
variant='soft'
color='primary'
size='sm'
onClick={handleAddSubtaskClick}
title='Add subtask'
>
<PlaylistAdd />
</IconButton>
<IconButton variant='soft' size='sm' onClick={handleEdit}>
<Edit />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={() => handleDelete(task.id)}
>
<Delete />
</IconButton>
</>
{task.completedAt && (
<Typography sx={{ color: 'text.secondary', fontSize: 'sm' }}>
{fmt.dateTime(task.completedAt)}
{performers?.find(p => p.userId === task.completedBy) && (
<Chip>
{
performers.find(p => p.userId === task.completedBy)
.displayName
}
</Chip>
)}
</Typography>
)}
</Box>
)}
</Box>
</ListItem>
{/* Add subtask input field */}
{showAddSubtask && (
<ListItem
sx={{
paddingLeft: `${(level + 1) * 24}px`,
paddingTop: 0,
paddingBottom: 1,
}}
>
<Box sx={{ display: 'flex', width: '100%', gap: 1 }}>
<Input
placeholder='Add new subtask...'
value={newSubtask}
onChange={e => setNewSubtask(e.target.value)}
onKeyPress={handleKeyPress}
sx={{ flex: 1 }}
autoFocus
/>
<IconButton onClick={submitNewSubtask} size='sm'>
<KeyboardReturn />
{editMode && (
<Box sx={{ display: 'flex', gap: 1 }}>
<IconButton
variant='soft'
color='danger'
size='sm'
title='Delete (Shift+Backspace)'
onClick={() =>
onKeyDown(
{
key: 'Backspace',
shiftKey: true,
preventDefault: () => {},
},
task,
)
}
>
<Delete />
</IconButton>
</Box>
</ListItem>
)}
)}
</ListItem>
{/* Child tasks */}
{hasChildren && expanded && (
<Box sx={{ paddingLeft: `${level * 24}px` }}>
{childTasks
.sort((a, b) => a.orderId - b.orderId)
.map((childTask, childIndex) => (
<SortableItem
key={childTask.id}
task={childTask}
index={childIndex}
handleToggle={handleToggle}
handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={allTasks}
setTasks={setTasks}
level={level + 1}
editMode={editMode}
performers={performers}
/>
))}
<Box>
{childTasks.map(childTask => (
<SortableItem
key={childTask.id}
task={childTask}
allTasks={allTasks}
setTasks={setTasks}
level={level + 1}
editMode={editMode}
expandedIds={expandedIds}
onToggleExpand={onToggleExpand}
handleToggle={handleToggle}
inputRefs={inputRefs}
onKeyDown={onKeyDown}
performers={performers}
/>
))}
</Box>
)}
</>
@@ -314,19 +248,22 @@ const SubTasks = ({
shouldFocus = false,
}) => {
const [newTask, setNewTask] = useState('')
const [expandedIds, setExpandedIds] = useState(new Set())
const { data: userProfile } = useUserProfile()
const { impersonatedUser } = useImpersonateUser()
const inputRefs = useRef({})
const focusId = id => {
setTimeout(() => {
inputRefs.current[id]?.focus()
}, 50)
}
const topLevelTasks = tasks.filter(task => task.parentId === null)
// Create sensors for touch handling
const sensors = useSensors(
useSensor(PointerSensor, {
// Configure for better mobile scrolling
activationConstraint: {
delay: 100,
tolerance: 8,
},
activationConstraint: { delay: 100, tolerance: 8 },
}),
)
@@ -336,7 +273,6 @@ const SubTasks = ({
? null
: new Date().toISOString()
// Update the task
const updatedTasks = tasks.map(task =>
task.id === taskId
? {
@@ -347,7 +283,6 @@ const SubTasks = ({
: task,
)
// If completing a task, also complete all child tasks
if (newCompletedAt) {
const completeChildren = parentId => {
const children = updatedTasks.filter(t => t.parentId === parentId)
@@ -358,7 +293,7 @@ const SubTasks = ({
...updatedTasks[index],
completedAt: newCompletedAt,
}
completeChildren(child.id) // Recursively complete grandchildren
completeChildren(child.id)
}
})
}
@@ -366,73 +301,309 @@ const SubTasks = ({
}
CompleteSubTask(taskId, Number(choreId), newCompletedAt).then(res => {
if (res.status !== 200) {
console.log('Error updating task')
return
}
if (res.status !== 200) console.log('Error updating task')
})
setTasks(updatedTasks)
}
const handleDelete = taskId => {
// Find all descendant tasks to delete
const findDescendants = id => {
const descendants = []
const children = tasks.filter(t => t.parentId === id)
const handleDelete = useCallback(
taskId => {
const findDescendants = id => {
const descendants = []
tasks
.filter(t => t.parentId === id)
.forEach(child => {
descendants.push(child.id)
descendants.push(...findDescendants(child.id))
})
return descendants
}
const idsToDelete = [taskId, ...findDescendants(taskId)]
setTasks(
tasks
.filter(task => !idsToDelete.includes(task.id))
.map((task, index) => ({
...task,
orderId: task.parentId === null ? index : task.orderId,
})),
)
},
[tasks, setTasks],
)
children.forEach(child => {
descendants.push(child.id)
descendants.push(...findDescendants(child.id))
})
const handleToggleExpand = useCallback(taskId => {
setExpandedIds(prev => {
const next = new Set(prev)
next.has(taskId) ? next.delete(taskId) : next.add(taskId)
return next
})
}, [])
return descendants
}
const handleKeyDown = useCallback(
(e, task) => {
const input = inputRefs.current[task.id]
const selStart = input?.selectionStart ?? 0
const selEnd = input?.selectionEnd ?? 0
const valLen = input?.value?.length ?? 0
const cursorAtStart = selStart === 0 && selEnd === 0
const cursorAtEnd = selStart === valLen && selEnd === valLen
const descendantIds = findDescendants(taskId)
const idsToDelete = [taskId, ...descendantIds]
// Enter → add sibling after current task at same level
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
const newId = nextTempId(tasks)
const newTaskObj = {
id: newId,
name: '',
completedAt: null,
parentId: task.parentId,
orderId: task.orderId + 1,
}
setTasks(prev => [
...prev.map(t =>
t.parentId === task.parentId && t.orderId > task.orderId
? { ...t, orderId: t.orderId + 1 }
: t,
),
newTaskObj,
])
focusId(newId)
return
}
// Filter out the task and all its descendants
const updatedTasks = tasks
.filter(task => !idsToDelete.includes(task.id))
.map((task, index) => ({
...task,
orderId: task.parentId === null ? index : task.orderId,
}))
// Shift+Enter → add child subtask nested under current
if (e.key === 'Enter' && e.shiftKey) {
e.preventDefault()
const newId = nextTempId(tasks)
const childCount = tasks.filter(t => t.parentId === task.id).length
const newTaskObj = {
id: newId,
name: '',
completedAt: null,
parentId: task.id,
orderId: childCount,
}
setExpandedIds(prev => new Set([...prev, task.id]))
setTasks(prev => [...prev, newTaskObj])
focusId(newId)
return
}
setTasks(updatedTasks)
}
// ArrowUp → focus previous visible task
if (e.key === 'ArrowUp' && !e.shiftKey) {
e.preventDefault()
const visible = getVisibleOrder(tasks, expandedIds)
const idx = visible.findIndex(t => t.id === task.id)
if (idx > 0) inputRefs.current[visible[idx - 1].id]?.focus()
return
}
// ArrowDown → focus next visible task
if (e.key === 'ArrowDown' && !e.shiftKey) {
e.preventDefault()
const visible = getVisibleOrder(tasks, expandedIds)
const idx = visible.findIndex(t => t.id === task.id)
if (idx < visible.length - 1)
inputRefs.current[visible[idx + 1].id]?.focus()
return
}
// Shift+ArrowUp → move task up among siblings; at top, promote before parent
if (e.key === 'ArrowUp' && e.shiftKey) {
e.preventDefault()
const siblings = tasks
.filter(t => t.parentId === task.parentId)
.sort((a, b) => a.orderId - b.orderId)
const idx = siblings.findIndex(t => t.id === task.id)
if (idx <= 0) {
// Already first sibling — promote to parent level, insert before parent
if (task.parentId === null) return
const parent = tasks.find(t => t.id === task.parentId)
if (!parent) return
setTasks(prev =>
prev.map(t => {
// Shift items at parent's orderId and above to make room
if (t.id === task.id)
return {
...t,
parentId: parent.parentId,
orderId: parent.orderId,
}
if (
t.parentId === parent.parentId &&
t.orderId >= parent.orderId &&
t.id !== task.id
)
return { ...t, orderId: t.orderId + 1 }
return t
}),
)
focusId(task.id)
return
}
const prev = siblings[idx - 1]
setTasks(all =>
all.map(t => {
if (t.id === task.id) return { ...t, orderId: prev.orderId }
if (t.id === prev.id) return { ...t, orderId: task.orderId }
return t
}),
)
focusId(task.id)
return
}
// Shift+ArrowDown → move task down among siblings; at bottom, promote after parent
if (e.key === 'ArrowDown' && e.shiftKey) {
e.preventDefault()
const siblings = tasks
.filter(t => t.parentId === task.parentId)
.sort((a, b) => a.orderId - b.orderId)
const idx = siblings.findIndex(t => t.id === task.id)
if (idx >= siblings.length - 1) {
// Already last sibling — promote to parent level, insert after parent
if (task.parentId === null) return
const parent = tasks.find(t => t.id === task.parentId)
if (!parent) return
setTasks(prev =>
prev.map(t => {
if (t.id === task.id)
return {
...t,
parentId: parent.parentId,
orderId: parent.orderId + 1,
}
if (
t.parentId === parent.parentId &&
t.orderId > parent.orderId &&
t.id !== task.id
)
return { ...t, orderId: t.orderId + 1 }
return t
}),
)
focusId(task.id)
return
}
const next = siblings[idx + 1]
setTasks(all =>
all.map(t => {
if (t.id === task.id) return { ...t, orderId: next.orderId }
if (t.id === next.id) return { ...t, orderId: task.orderId }
return t
}),
)
focusId(task.id)
return
}
// Shift+ArrowLeft (at cursor start) or Shift+Tab → outdent one level
const shouldOutdent =
(e.key === 'ArrowLeft' && e.shiftKey && cursorAtStart) ||
(e.key === 'Tab' && e.shiftKey)
if (shouldOutdent) {
e.preventDefault()
if (task.parentId === null) return
const parent = tasks.find(t => t.id === task.parentId)
if (!parent) return
const newOrderId = parent.orderId + 1
setTasks(prev =>
prev.map(t => {
if (t.id === task.id)
return { ...t, parentId: parent.parentId, orderId: newOrderId }
if (
t.parentId === parent.parentId &&
t.orderId >= newOrderId &&
t.id !== task.id
)
return { ...t, orderId: t.orderId + 1 }
return t
}),
)
focusId(task.id)
return
}
// Shift+ArrowRight (at cursor end) or Tab → indent under previous sibling
const shouldIndent =
(e.key === 'ArrowRight' && e.shiftKey && cursorAtEnd) ||
(e.key === 'Tab' && !e.shiftKey)
if (shouldIndent) {
e.preventDefault()
const siblings = tasks
.filter(t => t.parentId === task.parentId)
.sort((a, b) => a.orderId - b.orderId)
const idx = siblings.findIndex(t => t.id === task.id)
if (idx <= 0) return
const newParent = siblings[idx - 1]
const newChildCount = tasks.filter(
t => t.parentId === newParent.id,
).length
setExpandedIds(prev => new Set([...prev, newParent.id]))
setTasks(prev =>
prev.map(t =>
t.id === task.id
? { ...t, parentId: newParent.id, orderId: newChildCount }
: t,
),
)
focusId(task.id)
return
}
// Backspace on empty task → delete and focus previous
if (e.key === 'Backspace' && !e.shiftKey && task.name === '') {
e.preventDefault()
const visible = getVisibleOrder(tasks, expandedIds)
const idx = visible.findIndex(t => t.id === task.id)
if (idx > 0) focusId(visible[idx - 1].id)
handleDelete(task.id)
return
}
// Shift+Backspace or Shift+Delete → delete task and focus nearest
if ((e.key === 'Backspace' || e.key === 'Delete') && e.shiftKey) {
e.preventDefault()
const visible = getVisibleOrder(tasks, expandedIds)
const idx = visible.findIndex(t => t.id === task.id)
if (idx > 0) focusId(visible[idx - 1].id)
else if (idx < visible.length - 1) focusId(visible[idx + 1].id)
handleDelete(task.id)
return
}
// Escape → blur current input
if (e.key === 'Escape') {
input?.blur()
}
},
[tasks, expandedIds, setTasks, handleDelete],
)
const addInputRef = useRef(null)
const handleAdd = () => {
if (!newTask.trim()) return
const newTaskObj = {
name: newTask,
completedAt: null,
orderId: topLevelTasks.length,
parentId: null,
id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newTaskObj])
setNewTask('')
}
const handleAddSubtask = (parentId, name) => {
if (!name.trim()) return
// Find siblings to determine orderId
const siblings = tasks.filter(t => t.parentId === parentId)
const newSubtask = {
name,
completedAt: null,
orderId: siblings.length,
parentId,
id: (tasks.length + 1) * -1, // Temporary negative ID
}
setTasks([...tasks, newSubtask])
const id1 = nextTempId(tasks)
const id2 = id1 - 1
flushSync(() => {
setTasks([
...tasks,
{
id: id1,
name: newTask,
completedAt: null,
orderId: 0,
parentId: null,
},
{ id: id2, name: '', completedAt: null, orderId: 1, parentId: null },
])
setNewTask('')
})
inputRefs.current[id2]?.focus()
}
const onDragEnd = event => {
@@ -442,21 +613,21 @@ const SubTasks = ({
setTasks(items => {
const oldIndex = items.findIndex(item => item.id === active.id)
const newIndex = items.findIndex(item => item.id === over.id)
if (oldIndex === -1 || newIndex === -1) return items
const activeItem = items[oldIndex]
const overItem = items[newIndex]
const reorderedItems = arrayMove(items, oldIndex, newIndex)
const reordered = [...items]
reordered.splice(oldIndex, 1)
reordered.splice(newIndex, 0, activeItem)
const parentId = overItem.parentId
const siblings = reorderedItems.filter(item => item.parentId === parentId)
const siblings = reordered.filter(item => item.parentId === parentId)
return reorderedItems.map(item => {
if (item.id === activeItem.id) {
return reordered.map(item => {
if (item.id === activeItem.id)
return { ...item, parentId, orderId: siblings.indexOf(item) }
}
return item.parentId === parentId
? { ...item, orderId: siblings.indexOf(item) }
: item
@@ -464,64 +635,64 @@ const SubTasks = ({
})
}
const handleKeyPress = event => {
if (event.key === 'Enter') {
handleAdd()
}
}
return (
<>
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
sensors={sensors}
>
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
<List
sx={{
padding: 0,
// Improve scrolling behavior on mobile
maxHeight: 'inherit',
overflow: 'visible',
WebkitOverflowScrolling: 'touch',
}}
>
{topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map((task, index) => (
<SortableItem
key={task.id}
task={task}
index={index}
handleToggle={handleToggle}
handleDelete={handleDelete}
handleAddSubtask={handleAddSubtask}
allTasks={tasks}
setTasks={setTasks}
editMode={editMode}
performers={performers}
/>
))}
{editMode && (
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Input
autoFocus={shouldFocus}
placeholder='Add new task...'
value={newTask}
onChange={e => setNewTask(e.target.value)}
onKeyPress={handleKeyPress}
sx={{ flex: 1 }}
/>
<IconButton onClick={handleAdd}>
<KeyboardReturn />
</IconButton>
</ListItem>
)}
</List>
</SortableContext>
</DndContext>
</>
<DndContext
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
sensors={sensors}
>
<SortableContext items={tasks} strategy={verticalListSortingStrategy}>
<List
sx={{
padding: 0,
maxHeight: 'inherit',
overflow: 'visible',
WebkitOverflowScrolling: 'touch',
}}
>
{topLevelTasks
.sort((a, b) => a.orderId - b.orderId)
.map(task => (
<SortableItem
key={task.id}
task={task}
allTasks={tasks}
setTasks={setTasks}
level={0}
editMode={editMode}
expandedIds={expandedIds}
onToggleExpand={handleToggleExpand}
handleToggle={handleToggle}
inputRefs={inputRefs}
onKeyDown={handleKeyDown}
performers={performers}
/>
))}
{editMode && tasks.length === 0 && (
<ListItem sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Input
autoFocus={shouldFocus}
placeholder='Add new task... (Enter to add)'
value={newTask}
slotProps={{ input: { ref: addInputRef } }}
onChange={e => setNewTask(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault()
handleAdd()
}
}}
sx={{ flex: 1 }}
/>
<IconButton onClick={handleAdd}>
<KeyboardReturn />
</IconButton>
</ListItem>
)}
</List>
</SortableContext>
</DndContext>
)
}