diff --git a/src/contexts/LocalizationContext.jsx b/src/contexts/LocalizationContext.jsx index c993b85..e2fa3a4 100644 --- a/src/contexts/LocalizationContext.jsx +++ b/src/contexts/LocalizationContext.jsx @@ -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 }) => { diff --git a/src/views/Chores/SortAndGrouping.jsx b/src/views/Chores/SortAndGrouping.jsx index 608fa05..09b86a0 100644 --- a/src/views/Chores/SortAndGrouping.jsx +++ b/src/views/Chores/SortAndGrouping.jsx @@ -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) => ( Color - + {error && ( diff --git a/src/views/Modals/Inputs/ProjectModal.jsx b/src/views/Modals/Inputs/ProjectModal.jsx index b3bc503..0b97feb 100644 --- a/src/views/Modals/Inputs/ProjectModal.jsx +++ b/src/views/Modals/Inputs/ProjectModal.jsx @@ -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 */} Project Color - + {/* Error Message */} diff --git a/src/views/Modals/Inputs/WriteNFCModal.jsx b/src/views/Modals/Inputs/WriteNFCModal.jsx index 447e883..ca5a598 100644 --- a/src/views/Modals/Inputs/WriteNFCModal.jsx +++ b/src/views/Modals/Inputs/WriteNFCModal.jsx @@ -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 ( + + {isWaiting && ( + <> + + + + )} + + {isSuccess ? ( + + ) : isError ? ( + + ) : ( + + )} + + + ) +} + 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 ( - - URL written to NFC tag successfully! - - ) - } + const isWaiting = nfcStatus === 'waiting_for_tag' || nfcStatus === 'writing' + const isSuccess = nfcStatus === 'success' + const isError = nfcStatus === 'error' - if (nfcStatus === 'waiting_for_tag') { - return ( - <> - - - - Hold your device near the NFC tag - - - - - ) - } + const title = isSuccess + ? 'Tag written!' + : isError + ? 'Something went wrong' + : isWaiting + ? 'Hold near NFC tag' + : 'Write to NFC' - return ( - <> - - {nfcStatus === 'error' - ? errorMessage - : 'Press the button below to write to NFC.'} - - { - navigator.clipboard.writeText(getURL()) - alert('URL copied to clipboard!') - }} - /> - } - /> - - setIsAutoCompleteWhenScan(e.target.checked)} - label='Auto-complete when scanned' - /> - - - - - - ) - } + 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 ( - - - {nfcStatus === 'success' ? 'Success!' : 'Write to NFC'} - - {renderBody()} - + <> + + + + {/* Icon */} + + + {/* Heading */} + + {title} + + + {subtitle} + + + {/* Idle / Error: URL + toggle + CTA */} + {!isWaiting && !isSuccess && ( + <> + + + Tag URL + + + + + } + /> + {copied && ( + + Copied! + + )} + + + + + + Auto-complete on scan + + + Mark task done when tag is tapped + + + setIsAutoCompleteWhenScan(e.target.checked)} + size='sm' + /> + + + + + + + + )} + + {/* Waiting state */} + {isWaiting && ( + + )} + + {/* Success state */} + {isSuccess && ( + + )} + + + ) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index fcf6207..8f5a08d 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -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 ( <> - - - - - } - > - {!showScan && ( - <> - - - 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} - 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' }, - ], - }, - }} - /> -
    - + - { - setDueDate(null) - setDueDateOnly(null) - setDueTime(null) - setUseCustomTime(false) - }} - /> - setFrequency(null)} - /> - setPriority(0)} - emptyDisplay={pickerEmptyDisplay} - priorityColors={priorityColors} - priorityLabels={priorityLabels} - /> - { - if (!userId) { - setAssignees([]) - } else { - setAssignees([{ userId }]) - } - }} - onClear={() => setAssignees([])} - currentUserId={userProfile?.id} - members={circleMembers?.res || []} - /> - setLabelsV2([])} - labels={userLabels || []} - /> - setAttachments([])} - emptyDisplay={pickerEmptyDisplay} - entityType='chore_attachment_draft' - draftId={draftId} - /> - setNotificationMetadata({ templates: [] })} - emptyDisplay={pickerEmptyDisplay} - /> -
    - - - {!hasDescription && ( - - )} - {!hasSubTasks && ( - - )} - - - {hasDescription && ( - - Description: -
    - + Cancel + {showKeyboardShortcuts && ( + -
    -
    - )} - {hasSubTasks && ( + )} + + + + } + > + {!showScan && ( + <> - Subtasks: - + 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 + } + 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' }, + ], + }, + }} /> - )} - - )} - {showScan && ( - { - setShowScan(false) - setScanAutoCapture(false) - setPendingPhotoUrl(null) - }} - /> - )} - + + { + setDueDate(null) + setDueDateOnly(null) + setDueTime(null) + setUseCustomTime(false) + }} + /> + setFrequency(null)} + /> + setPriority(0)} + emptyDisplay={pickerEmptyDisplay} + priorityColors={priorityColors} + priorityLabels={priorityLabels} + /> + { + if (!userId) { + setAssignees([]) + } else { + setAssignees([{ userId }]) + } + }} + onClear={() => setAssignees([])} + currentUserId={userProfile?.id} + members={circleMembers?.res || []} + /> + setLabelsV2([])} + labels={userLabels || []} + /> + setAttachments([])} + emptyDisplay={pickerEmptyDisplay} + entityType='chore_attachment_draft' + draftId={draftId} + /> + setNotificationMetadata({ templates: [] })} + emptyDisplay={pickerEmptyDisplay} + /> + + + + {!hasDescription && ( + + )} + {!hasSubTasks && ( + + )} + + + {hasDescription && ( + + Description: +
    + +
    +
    + )} + {hasSubTasks && ( + + Subtasks: + + + )} + + )} + + {showScan && ( + { + setShowScan(false) + setScanAutoCapture(false) + setPendingPhotoUrl(null) + }} + /> + )} + ) } diff --git a/src/views/components/SubTask.jsx b/src/views/components/SubTask.jsx index 01a505f..673ca74 100644 --- a/src/views/components/SubTask.jsx +++ b/src/views/components/SubTask.jsx @@ -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 ( <> {editMode && ( )} - {hasChildren && ( + {hasChildren ? ( onToggleExpand(task.id)} > {expanded ? : } - )} + ) : level > 0 ? ( + + ) : null} - {!hasChildren && level > 0 && ( - // Spacer for alignment - )} - - + {!editMode && ( handleToggle(task.id)} /> )} - { - if (!editMode) { - handleToggle(task.id) + + {editMode ? ( + { + 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 ? ( - 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', + }, + }} + /> + ) : ( + handleToggle(task.id)} + > {task.name} - )} - {task.completedAt && ( - - {fmt.dateTime(task.completedAt)} - {performers.find(p => p.userId === task.completedBy) ? ( - - { - performers.find(p => p.userId === task.completedBy) - .displayName - } - - ) : null} - - )} - - - - - {editMode && ( - <> - - - - - - - handleDelete(task.id)} - > - - - + {task.completedAt && ( + + {fmt.dateTime(task.completedAt)} + {performers?.find(p => p.userId === task.completedBy) && ( + + { + performers.find(p => p.userId === task.completedBy) + .displayName + } + + )} + + )} + )} - - {/* Add subtask input field */} - {showAddSubtask && ( - - - setNewSubtask(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ flex: 1 }} - autoFocus - /> - - + {editMode && ( + + + onKeyDown( + { + key: 'Backspace', + shiftKey: true, + preventDefault: () => {}, + }, + task, + ) + } + > + - - )} + )} + - {/* Child tasks */} {hasChildren && expanded && ( - - {childTasks - .sort((a, b) => a.orderId - b.orderId) - .map((childTask, childIndex) => ( - - ))} + + {childTasks.map(childTask => ( + + ))} )} @@ -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 ( - <> - - - - {topLevelTasks - .sort((a, b) => a.orderId - b.orderId) - .map((task, index) => ( - - ))} - {editMode && ( - - setNewTask(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ flex: 1 }} - /> - - - - - )} - - - - + + + + {topLevelTasks + .sort((a, b) => a.orderId - b.orderId) + .map(task => ( + + ))} + + {editMode && tasks.length === 0 && ( + + setNewTask(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + handleAdd() + } + }} + sx={{ flex: 1 }} + /> + + + + + )} + + + ) }