Enhance project management UI and handle bulk changes (#186)

* enable hold to select, remove once x1 chip, unify reschedule modal

* project title still show fromwhen login from different account
it's was cache in local storage so makdikngsure we do clean up

* Fix: show the button for creating task on the modal instead of the panel

* Bulk moving project, Move the button in scanner to the modal

* - Handle bulk change for projects
- allow no assignee being selected
- choreactionmenu become modal on mobile
This commit is contained in:
Mohamad Tarbin
2026-08-02 16:14:52 -04:00
committed by GitHub
parent 9478f7d0f5
commit b2949b2dd9
20 changed files with 1587 additions and 954 deletions

120
src/hooks/useLongPress.js Normal file
View File

@@ -0,0 +1,120 @@
import { useCallback, useEffect, useRef } from 'react'
const DEFAULT_DELAY_MS = 450
// Deliberately smaller than the swipe list's swipeStartThreshold (10px) so the
// hold is abandoned before a swipe is even recognized.
const MOVE_TOLERANCE_PX = 6
const haptic = async () => {
try {
const { Haptics, ImpactStyle } = await import('@capacitor/haptics')
await Haptics.impact({ style: ImpactStyle.Medium })
} catch {
// no haptics on this platform
}
}
/**
* Press-and-hold gesture that works for both touch and mouse.
*
* Returns `handlers` to spread on the element and a `cancel` function so the
* owner can abandon a pending hold when another gesture wins (e.g. the swipe
* list reports a swipe start). A press that drifts more than
* MOVE_TOLERANCE_PX, scrolls, or gets cancelled by the browser never fires,
* and the click that follows a successful hold is swallowed so the element's
* normal click action doesn't also run.
*/
export const useLongPress = (
onLongPress,
{ delay = DEFAULT_DELAY_MS, enabled = true } = {},
) => {
const timerRef = useRef(null)
const originRef = useRef(null)
const firedRef = useRef(false)
const clear = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
originRef.current = null
}, [])
// Watch movement on the window rather than only on the element: while the
// swipe list drags the row it translates under the finger, and the pointer
// can end up over a different element than the one we started on.
useEffect(() => {
const handleWindowMove = event => {
if (!timerRef.current || !originRef.current) return
const point = event.touches?.[0] ?? event
if (point.clientX === undefined) return
const dx = Math.abs(point.clientX - originRef.current.x)
const dy = Math.abs(point.clientY - originRef.current.y)
if (dx > MOVE_TOLERANCE_PX || dy > MOVE_TOLERANCE_PX) {
clear()
}
}
window.addEventListener('pointermove', handleWindowMove, {
capture: true,
passive: true,
})
window.addEventListener('touchmove', handleWindowMove, {
capture: true,
passive: true,
})
window.addEventListener('scroll', clear, { capture: true, passive: true })
return () => {
window.removeEventListener('pointermove', handleWindowMove, true)
window.removeEventListener('touchmove', handleWindowMove, true)
window.removeEventListener('scroll', clear, true)
clear()
}
}, [clear])
const start = useCallback(
event => {
if (!enabled || !onLongPress) return
// Ignore right/middle mouse buttons
if (event.pointerType === 'mouse' && event.button !== 0) return
clear()
firedRef.current = false
originRef.current = { x: event.clientX, y: event.clientY }
timerRef.current = setTimeout(() => {
firedRef.current = true
timerRef.current = null
haptic()
onLongPress(event)
}, delay)
},
[enabled, onLongPress, delay, clear],
)
const handlers = {
onPointerDown: start,
onPointerUp: clear,
onPointerCancel: clear,
onPointerLeave: clear,
onDragStart: clear,
// Swallow the click that the browser fires after the finger lifts
onClickCapture: event => {
if (firedRef.current) {
firedRef.current = false
event.preventDefault()
event.stopPropagation()
}
},
onContextMenu: event => {
// A touch long-press otherwise pops the native context menu on top
if (firedRef.current) {
event.preventDefault()
}
},
}
return { handlers, cancel: clear }
}
export default useLongPress

View File

@@ -67,6 +67,24 @@ const isNetworkError = error =>
((error instanceof TypeError && error.message === 'Failed to fetch') || ((error instanceof TypeError && error.message === 'Failed to fetch') ||
error?.name === 'AbortError') error?.name === 'AbortError')
// The backend returns { error: "..." } on failures. Surface that message when
// it is there, flagged so callers can tell it apart from our generic fallback.
const errorFromResponse = async (resp, fallbackMessage) => {
if (!resp) return new Error(fallbackMessage)
let serverMessage = null
try {
const body = await resp.json()
if (typeof body?.error === 'string' && body.error.trim() !== '') {
serverMessage = body.error
}
} catch {
// body was empty or not JSON, keep the fallback
}
const error = new Error(serverMessage || fallbackMessage)
error.isServerMessage = Boolean(serverMessage)
return error
}
const buildOfflineChore = task => ({ const buildOfflineChore = task => ({
...task, ...task,
id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9), id: 'temp_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9),
@@ -201,7 +219,7 @@ export const useCreateChore = () => {
try { try {
const resp = await CreateChore(newTask) const resp = await CreateChore(newTask)
if (!resp || !resp.ok) { if (!resp || !resp.ok) {
throw new Error('Failed to create chore') throw await errorFromResponse(resp, 'Failed to create chore')
} }
const createdChore = await resp.json() const createdChore = await resp.json()
if (!createdChore) { if (!createdChore) {
@@ -254,7 +272,7 @@ export const useUpdateChore = () => {
try { try {
const resp = await SaveChore(updatedChore) const resp = await SaveChore(updatedChore)
if (!resp || !resp.ok) { if (!resp || !resp.ok) {
throw new Error('Failed to save chore') throw await errorFromResponse(resp, 'Failed to save chore')
} }
const updatedChoreRes = await resp.json() const updatedChoreRes = await resp.json()
if (!updatedChoreRes) { if (!updatedChoreRes) {

View File

@@ -415,7 +415,9 @@ const ChoreEdit = () => {
console.error('Failed to save chore:', error) console.error('Failed to save chore:', error)
showError({ showError({
title: 'Save Failed', title: 'Save Failed',
message: 'Failed to save chore, please try again.', message: error?.isServerMessage
? error.message
: 'Failed to save chore, please try again.',
}) })
}) })
} }
@@ -634,13 +636,14 @@ const ChoreEdit = () => {
if (anyone || assignableTo.length === 0) { if (anyone || assignableTo.length === 0) {
setAssignStrategy('no_assignee') setAssignStrategy('no_assignee')
setAssignedTo(null) setAssignedTo(null)
} else { } else if (assignStrategy === 'no_assignee') {
if (!assignableTo.some(a => a.userId === assignedTo)) { // user explicitly picked no_assignee while having assignees, keep it
setAssignedTo(assignableTo[0].userId) // but there is nobody currently assigned
} if (assignedTo !== null) {
if (assignStrategy === 'no_assignee') { setAssignedTo(null)
setAssignStrategy(ASSIGN_STRATEGIES[2]) // default to least_completed
} }
} else if (!assignableTo.some(a => a.userId === assignedTo)) {
setAssignedTo(assignableTo[0].userId)
} }
}, [assignStrategy, assignedTo, assignableTo, anyone]) }, [assignStrategy, assignedTo, assignableTo, anyone])
@@ -1259,7 +1262,12 @@ const ChoreEdit = () => {
{!anyone && assignableTo.length > 1 && ( {!anyone && assignableTo.length > 1 && (
<> <>
<Box mb={3}> <Box
mb={3}
sx={{
display: assignStrategy === 'no_assignee' ? 'none' : 'block',
}}
>
<Typography level='h4'>Currently Assigned To</Typography> <Typography level='h4'>Currently Assigned To</Typography>
<Typography level='body-md'> <Typography level='body-md'>
Who is assigned the next due? Who is assigned the next due?

View File

@@ -432,6 +432,16 @@ const ArchivedTasks = () => {
setSelectedChores(newSelection) setSelectedChores(newSelection)
} }
// Press-and-hold on a task card enters multi-select with that task picked
const enterMultiSelectWithChore = choreId => {
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId]))
return
}
toggleChoreSelection(choreId)
}
const selectAllVisibleChores = () => { const selectAllVisibleChores = () => {
if (finalChores.length > 0) { if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id))) setSelectedChores(new Set(finalChores.map(c => c.id)))
@@ -1051,6 +1061,7 @@ const ArchivedTasks = () => {
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores} selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection} toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/> />
</List> </List>
</Box> </Box>

View File

@@ -108,27 +108,29 @@ const ChoreCard = ({
{getDueDateChipText(chore.nextDueDate, chore, timeFormat)} {getDueDateChipText(chore.nextDueDate, chore, timeFormat)}
</Chip> </Chip>
<Chip {!['once', 'no_repeat'].includes(chore.frequencyType) && (
variant='soft' <Chip
sx={{ variant='soft'
position: 'relative', sx={{
top: 10, position: 'relative',
zIndex: 3, top: 10,
ml: 0.4, zIndex: 3,
left: 10, ml: 0.4,
}} left: 10,
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{getFrequencyIcon(chore)} <div
{getRecurrentChipText(chore)} style={{
</div> display: 'flex',
</Chip> alignItems: 'center',
justifyContent: 'center',
}}
>
{getFrequencyIcon(chore)}
{getRecurrentChipText(chore)}
</div>
</Chip>
)}
<Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}> <Box sx={{ position: 'absolute', top: 10, right: 10, zIndex: 3 }}>
<PendingBadge commands={pendingCmds} /> <PendingBadge commands={pendingCmds} />

View File

@@ -18,9 +18,60 @@ import {
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Typography } from '@mui/joy' import { Box, Typography } from '@mui/joy'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useLongPress } from '../../hooks/useLongPress'
import ChoreCard from './ChoreCard' import ChoreCard from './ChoreCard'
import CompactChoreCard from './CompactChoreCard' import CompactChoreCard from './CompactChoreCard'
/**
* One swipeable row. Owns the press-and-hold gesture (multi-select), which
* can't live in the render loop because it needs a hook.
*/
const ChoreSwipeableItem = ({
trailingActions,
onClick,
onLongPress,
longPressEnabled,
children,
// SwipeableList clones its children to inject list-level config
// (listType, fullSwipe, thresholds…), so it has to be passed through.
...listProps
}) => {
const { handlers: longPressHandlers, cancel: cancelLongPress } = useLongPress(
onLongPress,
{ enabled: longPressEnabled },
)
// The swipe list owns the gesture the moment it recognizes a drag — a hold
// that turned into a swipe must not also open multi-select.
const handleSwipeStart = () => {
cancelLongPress()
}
return (
<SwipeableListItem
{...listProps}
trailingActions={trailingActions}
onClick={onClick}
onSwipeStart={handleSwipeStart}
onSwipeProgress={cancelLongPress}
>
<Box
{...longPressHandlers}
sx={{
width: '100%',
// Keep a long press from selecting the task text / popping the
// native callout on mobile
userSelect: 'none',
WebkitUserSelect: 'none',
WebkitTouchCallout: 'none',
}}
>
{children}
</Box>
</SwipeableListItem>
)
}
const ChoreListView = ({ const ChoreListView = ({
chores, chores,
viewMode, viewMode,
@@ -34,6 +85,7 @@ const ChoreListView = ({
userProfile, userProfile,
isOfficialInstance, isOfficialInstance,
toggleMultiSelectMode, toggleMultiSelectMode,
onLongPressChore,
showActions = true, showActions = true,
}) => { }) => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -248,7 +300,7 @@ const ChoreListView = ({
return ( return (
<SwipeableList type={ListType.IOS} fullSwipe={false}> <SwipeableList type={ListType.IOS} fullSwipe={false}>
{chores.map(chore => ( {chores.map(chore => (
<SwipeableListItem <ChoreSwipeableItem
key={chore.id} key={chore.id}
trailingActions={getTrailingActions(chore)} trailingActions={getTrailingActions(chore)}
onClick={() => { onClick={() => {
@@ -258,9 +310,11 @@ const ChoreListView = ({
navigate(`/chores/${chore.id}`) navigate(`/chores/${chore.id}`)
} }
}} }}
longPressEnabled={Boolean(onLongPressChore)}
onLongPress={() => onLongPressChore?.(chore.id)}
> >
{renderChoreCard(chore)} {renderChoreCard(chore)}
</SwipeableListItem> </ChoreSwipeableItem>
))} ))}
</SwipeableList> </SwipeableList>
) )

View File

@@ -84,7 +84,9 @@ const CompactChoreCard = ({
const parts = [] const parts = []
// Frequency // Frequency
parts.push(getRecurrentChipText(chore)) if (!['once', 'no_repeat'].includes(chore.frequencyType)) {
parts.push(getRecurrentChipText(chore))
}
// Assignee // Assignee
if (chore.assignedTo) { if (chore.assignedTo) {
@@ -408,7 +410,8 @@ const CompactChoreCard = ({
{/* Line 2: Metadata */} {/* Line 2: Metadata */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.25 }}>
{getFrequencyIcon(chore)} {!['once', 'no_repeat'].includes(chore.frequencyType) &&
getFrequencyIcon(chore)}
<Typography <Typography
level='body-xs' level='body-xs'
color='text.secondary' color='text.secondary'

View File

@@ -121,7 +121,7 @@ const MyChores = () => {
const [confirmModelConfig, setConfirmModelConfig] = useState({}) const [confirmModelConfig, setConfirmModelConfig] = useState({})
const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } = const { selectedProject, projectsWithDefault, setSelectedProjectWithCache } =
useProjectFilter(projects) useProjectFilter(projects, !projectsLoading)
const { const {
searchTerm, searchTerm,
@@ -143,6 +143,7 @@ const MyChores = () => {
selectedChores, selectedChores,
toggleMultiSelectMode, toggleMultiSelectMode,
toggleChoreSelection, toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores, selectAllVisibleChores,
clearSelection, clearSelection,
getSelectedChoresData, getSelectedChoresData,
@@ -366,6 +367,7 @@ const MyChores = () => {
} }
processEffectAsync() processEffectAsync()
// throw new Error('Fake Error to test posthog')
} }
}, [ }, [
membersLoading, membersLoading,
@@ -570,6 +572,7 @@ const MyChores = () => {
handleBulkArchive, handleBulkArchive,
handleBulkDelete, handleBulkDelete,
handleBulkSkip, handleBulkSkip,
handleBulkMoveToProject,
} = useChoreActions({ } = useChoreActions({
chores, chores,
filteredChores, filteredChores,
@@ -865,8 +868,8 @@ const MyChores = () => {
[getFilteredChores], [getFilteredChores],
) )
const updateChores = newChore => { const appendChore = (prev, newChore) => {
let newChores = [...chores, newChore] let newChores = [...prev, newChore]
if (impersonatedUser) { if (impersonatedUser) {
newChores = newChores.filter( newChores = newChores.filter(
@@ -874,8 +877,15 @@ const MyChores = () => {
) )
} }
setChores(newChores) return newChores
setFilteredChores(newChores) }
// Uses functional setState so back-to-back calls (e.g. creating several
// voice-captured tasks in a row) each build on the latest state instead of
// a closure snapshot taken before earlier calls landed.
const updateChores = newChore => {
setChores(prev => appendChore(prev, newChore))
setFilteredChores(prev => appendChore(prev, newChore))
clearQuickFilters() clearQuickFilters()
} }
@@ -1046,12 +1056,22 @@ const MyChores = () => {
<MultiSelectToolbar <MultiSelectToolbar
isVisible={isMultiSelectMode} isVisible={isMultiSelectMode}
selectedCount={selectedChores.size} selectedCount={selectedChores.size}
onSelectAll={selectAllVisibleChores} onSelectAll={() =>
selectAllVisibleChores(
searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores
: null,
choreSections,
openChoreSections,
)
}
onClear={clearSelection} onClear={clearSelection}
onComplete={handleBulkComplete} onComplete={handleBulkComplete}
onSkip={handleBulkSkip} onSkip={handleBulkSkip}
onArchive={handleBulkArchive} onArchive={handleBulkArchive}
onDelete={handleBulkDelete} onDelete={handleBulkDelete}
onMoveToProject={handleBulkMoveToProject}
projects={projects}
showKeyboardShortcuts={showKeyboardShortcuts} showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={ selectAllDisabled={
searchTerm?.length > 0 || hasQuickFilters searchTerm?.length > 0 || hasQuickFilters
@@ -1116,6 +1136,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores} selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection} toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/> />
)} )}
{viewMode === 'calendar' && ( {viewMode === 'calendar' && (
@@ -1293,6 +1314,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores} selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection} toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/> />
)} )}
</Box> </Box>
@@ -1373,6 +1395,7 @@ const MyChores = () => {
isMultiSelectMode={isMultiSelectMode} isMultiSelectMode={isMultiSelectMode}
selectedChores={selectedChores} selectedChores={selectedChores}
toggleChoreSelection={toggleChoreSelection} toggleChoreSelection={toggleChoreSelection}
onLongPressChore={enterMultiSelectWithChore}
/> />
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>

View File

@@ -1,5 +1,9 @@
import { Capacitor } from '@capacitor/core' import { Capacitor } from '@capacitor/core'
import DateModal from '../../Modals/Inputs/DateModal' import DateModal from '../../Modals/Inputs/DateModal'
import DueDatePickerModal, {
combineDueDate,
splitDueDate,
} from '../../components/DueDatePickerModal'
import NudgeModal from '../../Modals/Inputs/NudgeModal' import NudgeModal from '../../Modals/Inputs/NudgeModal'
import SelectModal from '../../Modals/Inputs/SelectModal' import SelectModal from '../../Modals/Inputs/SelectModal'
import TextModal from '../../Modals/Inputs/TextModal' import TextModal from '../../Modals/Inputs/TextModal'
@@ -24,13 +28,16 @@ const ChoreModals = ({
return ( return (
<> <>
{activeModal === 'changeDueDate' && modalChore && ( {activeModal === 'changeDueDate' && modalChore && (
<DateModal <DueDatePickerModal
isOpen={true} open={true}
key={'changeDueDate' + modalChore.id} key={'changeDueDate' + modalChore.id}
current={modalChore.nextDueDate}
title='Change due date' title='Change due date'
{...splitDueDate(modalChore.nextDueDate)}
onClose={onClose} onClose={onClose}
onSave={onChangeDueDate} onApply={parts =>
onChangeDueDate(combineDueDate(parts)?.toISOString() ?? null)
}
onRemove={() => onChangeDueDate(null)}
/> />
)} )}

View File

@@ -5,11 +5,39 @@ import {
Close, Close,
Delete, Delete,
Done, Done,
DriveFileMove,
SelectAll, SelectAll,
SkipNext, SkipNext,
} from '@mui/icons-material' } from '@mui/icons-material'
import { Box, Button, Divider, Typography } from '@mui/joy' import {
Avatar,
Box,
Button,
Divider,
ListItemContent,
ListItemDecorator,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import LABEL_COLORS, {
getTextColorFromBackgroundColor,
} from '../../../utils/Colors'
import { getIconComponent } from '../../../utils/ProjectIcons'
const renderProjectAvatar = (color, icon) => {
const bg = color || LABEL_COLORS[0].value
const IconComponent = getIconComponent(icon || 'FolderOpen')
return (
<Avatar size='sm' sx={{ width: 22, height: 22, backgroundColor: bg }}>
<IconComponent
sx={{ fontSize: 13, color: getTextColorFromBackgroundColor(bg) }}
/>
</Avatar>
)
}
const MultiSelectToolbar = ({ const MultiSelectToolbar = ({
isVisible, isVisible,
@@ -20,9 +48,21 @@ const MultiSelectToolbar = ({
onSkip, onSkip,
onArchive, onArchive,
onDelete, onDelete,
onMoveToProject,
projects = [],
showKeyboardShortcuts, showKeyboardShortcuts,
selectAllDisabled, selectAllDisabled,
}) => { }) => {
const [projectMenuAnchor, setProjectMenuAnchor] = useState(null)
const projectMenuRef = useRef(null)
const closeProjectMenu = () => setProjectMenuAnchor(null)
const handleMoveToProject = project => {
closeProjectMenu()
onMoveToProject?.(project)
}
return ( return (
<Box <Box
sx={{ sx={{
@@ -216,6 +256,63 @@ const MultiSelectToolbar = ({
/> />
)} )}
</Button> </Button>
{onMoveToProject && (
<>
<Button
size='sm'
variant='soft'
color='neutral'
ref={projectMenuRef}
onClick={() =>
setProjectMenuAnchor(prev =>
prev ? null : projectMenuRef.current,
)
}
startDecorator={<DriveFileMove />}
disabled={selectedCount === 0}
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
title='Move selected tasks to a project'
>
Move
</Button>
<Menu
size='md'
anchorEl={projectMenuAnchor}
open={Boolean(projectMenuAnchor)}
onClose={closeProjectMenu}
placement='bottom-end'
>
<MenuItem
onClick={() =>
handleMoveToProject({ id: null, name: 'Default Project' })
}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>Default Project</Typography>
</ListItemContent>
</MenuItem>
{projects.map(project => (
<MenuItem
key={project.id}
onClick={() => handleMoveToProject(project)}
>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>
<Typography level='body-sm'>{project.name}</Typography>
</ListItemContent>
</MenuItem>
))}
</Menu>
</>
)}
<Button <Button
size='sm' size='sm'
variant='soft' variant='soft'

View File

@@ -1155,6 +1155,61 @@ export const useChoreActions = ({
setConfirmModelConfig, setConfirmModelConfig,
]) ])
const handleBulkMoveToProject = useCallback(
async project => {
const selectedData = getSelectedChoresData(chores)
if (selectedData.length === 0) return
const projectId = project?.id ?? null
const movedTasks = []
const failedTasks = []
for (const chore of selectedData) {
try {
const response = await SaveChore({ ...chore, projectId })
if (response.ok) {
movedTasks.push(chore)
} else {
failedTasks.push(chore)
}
} catch (error) {
failedTasks.push(chore)
}
}
if (movedTasks.length > 0) {
const movedIds = new Set(movedTasks.map(c => c.id))
const applyMove = list =>
list.map(c => (movedIds.has(c.id) ? { ...c, projectId } : c))
setChores(applyMove)
setFilteredChores(applyMove)
showSuccess({
title: 'Tasks Moved',
message: `Moved ${movedTasks.length} task${movedTasks.length > 1 ? 's' : ''} to ${project?.name || 'Default Project'}.`,
})
}
if (failedTasks.length > 0) {
showError({
title: 'Some Tasks Failed',
message: `${failedTasks.length} task${failedTasks.length > 1 ? 's' : ''} could not be moved.`,
})
}
refetchChores()
clearSelection()
},
[
chores,
getSelectedChoresData,
setChores,
setFilteredChores,
showSuccess,
showError,
refetchChores,
clearSelection,
],
)
return { return {
handleChoreAction, handleChoreAction,
handleChangeDueDate, handleChangeDueDate,
@@ -1166,5 +1221,6 @@ export const useChoreActions = ({
handleBulkArchive, handleBulkArchive,
handleBulkDelete, handleBulkDelete,
handleBulkSkip, handleBulkSkip,
handleBulkMoveToProject,
} }
} }

View File

@@ -26,6 +26,20 @@ export const useMultiSelect = () => {
[selectedChores], [selectedChores],
) )
// Entry point for press-and-hold on a task card: turn multi-select on (if it
// isn't already) with that task selected.
const enterMultiSelectWithChore = useCallback(
choreId => {
if (!isMultiSelectMode) {
setIsMultiSelectMode(true)
setSelectedChores(new Set([choreId]))
return
}
toggleChoreSelection(choreId)
},
[isMultiSelectMode, toggleChoreSelection],
)
const selectAllVisibleChores = useCallback( const selectAllVisibleChores = useCallback(
(visibleChores, choreSections = [], openChoreSections = {}) => { (visibleChores, choreSections = [], openChoreSections = {}) => {
let choresToSelect = [] let choresToSelect = []
@@ -42,7 +56,9 @@ export const useMultiSelect = () => {
expandedChores.every(chore => selectedChores.has(chore.id)) expandedChores.every(chore => selectedChores.has(chore.id))
if (allExpandedSelected) { if (allExpandedSelected) {
choresToSelect = choreSections.flatMap(section => section.content || []) choresToSelect = choreSections.flatMap(
section => section.content || [],
)
} else { } else {
choresToSelect = expandedChores choresToSelect = expandedChores
} }
@@ -80,6 +96,7 @@ export const useMultiSelect = () => {
selectedChores, selectedChores,
toggleMultiSelectMode, toggleMultiSelectMode,
toggleChoreSelection, toggleChoreSelection,
enterMultiSelectWithChore,
selectAllVisibleChores, selectAllVisibleChores,
clearSelection, clearSelection,
getSelectedChoresData, getSelectedChoresData,

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
export const useProjectFilter = projects => { export const useProjectFilter = (projects, projectsLoaded = false) => {
const [selectedProject, setSelectedProject] = useState(() => { const [selectedProject, setSelectedProject] = useState(() => {
const saved = localStorage.getItem('selectedProject') const saved = localStorage.getItem('selectedProject')
return saved ? JSON.parse(saved) : null return saved ? JSON.parse(saved) : null
@@ -37,6 +37,20 @@ export const useProjectFilter = projects => {
window.history.replaceState({}, '', newUrl) window.history.replaceState({}, '', newUrl)
}, []) }, [])
// The cached selection is a whole project object, so a stale one keeps
// rendering its old name/color even though the project no longer belongs to
// this account (deleted project, or a different user signing in on a device
// where logout didn't get to clear storage). Once the real list has loaded,
// drop any selection that isn't in it.
useEffect(() => {
if (!projectsLoaded) return
if (!selectedProject || selectedProject.id === 'default') return
if (!projects.some(p => p.id === selectedProject.id)) {
setSelectedProjectWithCache(null)
}
}, [projectsLoaded, projects, selectedProject, setSelectedProjectWithCache])
return { return {
selectedProject, selectedProject,
projectsWithDefault, projectsWithDefault,

View File

@@ -222,7 +222,10 @@ const ProjectView = () => {
const { data: userProfile } = useUserProfile() const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false) // false to exclude archived const { data: chores = { res: [] } } = useChores(false) // false to exclude archived
const { data: projectsData = [], isLoading: projectsLoading } = useProjects() const { data: projectsData = [], isLoading: projectsLoading } = useProjects()
const { setSelectedProjectWithCache } = useProjectFilter(projectsData) const { setSelectedProjectWithCache } = useProjectFilter(
projectsData,
!projectsLoading,
)
const navigate = useNavigate() const navigate = useNavigate()
const [userProjects, setUserProjects] = useState([]) const [userProjects, setUserProjects] = useState([])

View File

@@ -4,7 +4,7 @@ import { useMediaQuery } from '@mui/material'
import * as chrono from 'chrono-node' import * as chrono from 'chrono-node'
import moment from 'moment' import moment from 'moment'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { useCreateChore } from '../../queries/ChoreQueries' import { useCreateChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
@@ -43,7 +43,7 @@ import RepeatPickerField from './RepeatPickerField'
import RichTextEditor from './RichTextEditor' import RichTextEditor from './RichTextEditor'
import ScanPanel from './ScanToTask/ScanPanel' import ScanPanel from './ScanToTask/ScanPanel'
import SubTasks from './SubTask' import SubTasks from './SubTask'
import { buildChorePayload } from './VoiceToTask/parseVoiceTask' import { buildChorePayload, parseVoiceTask } from './VoiceToTask/parseVoiceTask'
import VoicePanel from './VoiceToTask/VoicePanel' import VoicePanel from './VoiceToTask/VoicePanel'
const getDefaultNotification = () => { const getDefaultNotification = () => {
const storedDefault = localStorage.getItem('defaultNotificationTemplate') const storedDefault = localStorage.getItem('defaultNotificationTemplate')
@@ -76,6 +76,11 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const { data: userProfile } = useUserProfile() 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( const handleCreateLabel = useCallback(
name => { name => {
const color = const color =
@@ -145,6 +150,19 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
const [llmAvailable, setLlmAvailable] = useState(false) const [llmAvailable, setLlmAvailable] = useState(false)
const [showVoice, setShowVoice] = useState(false) const [showVoice, setShowVoice] = useState(false)
const [voiceAvailable, setVoiceAvailable] = 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)
// 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 { isNativeScanner } = useDocumentScanner()
useEffect(() => { useEffect(() => {
@@ -671,6 +689,7 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
// Multiple voice-captured tasks: they were reviewed as cards in the panel, // Multiple voice-captured tasks: they were reviewed as cards in the panel,
// so create them all directly. // so create them all directly.
const handleVoiceCreateMany = async parsedTasks => { const handleVoiceCreateMany = async parsedTasks => {
setCreatingVoiceTasks(true)
const notificationTemplates = getDefaultNotification() const notificationTemplates = getDefaultNotification()
for (const parsed of parsedTasks) { for (const parsed of parsedTasks) {
const chore = buildChorePayload(parsed, { const chore = buildChorePayload(parsed, {
@@ -694,13 +713,40 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
console.error('Error creating voice task:', error) console.error('Error creating voice task:', error)
} }
} }
setCreatingVoiceTasks(false)
handleCloseModal(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 => { const handleCloseModal = forceRefetch => {
onClose(forceRefetch) onClose(forceRefetch)
setShowScan(false) setShowScan(false)
setShowVoice(false) setShowVoice(false)
setVoiceState({ segments: [], isListening: false })
setScanState({ phase: 'idle', primaryAction: null })
setCreatingVoiceTasks(false)
setTaskText('') setTaskText('')
setTaskTitle('') setTaskTitle('')
setDueDate(null) setDueDate(null)
@@ -847,7 +893,38 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
/> />
)} )}
</Button> </Button>
{/* Sub-panels (voice/scan) own their own confirm action */} {showVoice && (
<Button
variant='solid'
color='primary'
loading={creatingVoiceTasks}
disabled={
voiceState.segments.length === 0 || voiceState.isListening
}
onClick={handleVoiceConfirm}
>
{creatingVoiceTasks
? 'Creating…'
: voiceState.segments.length > 1
? `Create ${voiceState.segments.length} Tasks`
: 'Use Task'}
</Button>
)}
{showScan && scanState.primaryAction && (
<Button
variant='solid'
color='primary'
startDecorator={scanState.primaryAction.icon}
onClick={scanState.primaryAction.onClick}
>
{scanState.primaryAction.label}
</Button>
)}
{showScan && scanState.phase === 'processing' && (
<Button variant='solid' color='primary' loading disabled>
Processing
</Button>
)}
{!showScan && !showVoice && ( {!showScan && !showVoice && (
<Button <Button
variant='solid' variant='solid'
@@ -1211,12 +1288,10 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
{showVoice && ( {showVoice && (
<VoicePanel <VoicePanel
open userLabels={voiceLabels}
userLabels={userLabels || []} members={voiceMembers}
members={circleMembers?.res || []}
userProfile={userProfile} userProfile={userProfile}
onUseSingle={handleVoiceSingle} onStateChange={setVoiceState}
onCreateMany={handleVoiceCreateMany}
/> />
)} )}
@@ -1226,10 +1301,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose, initialMode }) => {
autoCapture={scanAutoCapture} autoCapture={scanAutoCapture}
onTaskExtracted={handleTaskExtracted} onTaskExtracted={handleTaskExtracted}
initialImageUrl={pendingPhotoUrl} initialImageUrl={pendingPhotoUrl}
onStateChange={setScanState}
onClose={() => { onClose={() => {
setShowScan(false) setShowScan(false)
setScanAutoCapture(false) setScanAutoCapture(false)
setPendingPhotoUrl(null) setPendingPhotoUrl(null)
setScanState({ phase: 'idle', primaryAction: null })
}} }}
/> />
)} )}

View File

@@ -26,6 +26,9 @@ import {
Avatar, Avatar,
Divider, Divider,
IconButton, IconButton,
List,
ListItem,
ListItemButton,
ListItemContent, ListItemContent,
ListItemDecorator, ListItemDecorator,
Menu, Menu,
@@ -33,8 +36,10 @@ import {
Tooltip, Tooltip,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useMediaQuery } from '@mui/material'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import AppModal from '../../components/common/AppModal'
import LABEL_COLORS, { import LABEL_COLORS, {
getTextColorFromBackgroundColor, getTextColorFromBackgroundColor,
} from '../../utils/Colors' } from '../../utils/Colors'
@@ -64,6 +69,8 @@ const ChoreActionMenu = ({
const menuRef = React.useRef(null) const menuRef = React.useRef(null)
const navigate = useNavigate() const navigate = useNavigate()
const { data: projects = [] } = useProjects() const { data: projects = [] } = useProjects()
// Phone-only condition (matches AddTaskModal.jsx) — tablets/desktop keep the Menu
const isSmallScreen = useMediaQuery(theme => theme.breakpoints.down('sm'))
useEffect(() => { useEffect(() => {
try { try {
@@ -75,11 +82,19 @@ const ChoreActionMenu = ({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (isSmallScreen) {
// AppModal owns its own backdrop/escape close behavior on small screens.
if (anchorEl && onOpen) {
onOpen()
}
return
}
const handleMenuOutsideClick = event => { const handleMenuOutsideClick = event => {
if ( if (
anchorEl && anchorEl &&
!anchorEl.contains(event.target) && !anchorEl.contains(event.target) &&
!menuRef.current.contains(event.target) !menuRef.current?.contains(event.target)
) { ) {
handleMenuClose() handleMenuClose()
} }
@@ -92,7 +107,7 @@ const ChoreActionMenu = ({
return () => { return () => {
document.removeEventListener('mousedown', handleMenuOutsideClick) document.removeEventListener('mousedown', handleMenuOutsideClick)
} }
}, [anchorEl, onOpen]) }, [anchorEl, onOpen, isSmallScreen])
const handleMenuOpen = event => { const handleMenuOpen = event => {
event.stopPropagation() event.stopPropagation()
@@ -229,6 +244,255 @@ const ChoreActionMenu = ({
) )
} }
// Shared action list, rendered as MenuItems on large screens and as a
// ListItemButton list inside an AppModal sheet on small screens.
const actionItems = [
{
key: 'completeNote',
icon: <NoteAdd />,
label: 'Complete with note',
onClick: () => {
onCompleteWithNote?.()
handleMenuClose()
},
},
{
key: 'completePast',
icon: <Update />,
label: 'Complete in past',
onClick: () => {
onCompleteWithPastDate?.()
handleMenuClose()
},
},
{
key: 'skip',
icon: <SwitchAccessShortcut />,
label: 'Skip to next due date',
onClick: handleSkip,
},
{
key: 'delegate',
icon: <RecordVoiceOver />,
label: 'Delegate to someone else',
onClick: () => {
onChangeAssignee?.()
handleMenuClose()
},
},
isOfficialInstance && {
key: 'nudge',
icon: <Notifications />,
label: 'Send nudge',
onClick: () => {
onNudge?.()
handleMenuClose()
},
},
{ key: 'divider-1', type: 'divider' },
{
key: 'history',
icon: <ManageSearch />,
label: 'History',
onClick: handleHistory,
},
{ key: 'divider-2', type: 'divider' },
{ key: 'quickSchedule', type: 'quickSchedule' },
{ key: 'divider-3', type: 'divider' },
{
key: 'changeDueDate',
icon: <MoreTime />,
label: 'Change due date',
onClick: () => {
onChangeDueDate?.()
handleMenuClose()
},
},
{
key: 'writeNfc',
icon: <Nfc />,
label: 'Write to NFC',
onClick: () => {
onWriteNFC?.()
handleMenuClose()
},
},
{ key: 'edit', icon: <Edit />, label: 'Edit', onClick: handleEdit },
{ key: 'clone', icon: <CopyAll />, label: 'Clone', onClick: handleClone },
{ key: 'view', icon: <ViewCarousel />, label: 'View', onClick: handleView },
{
key: 'archive',
icon: chore.isActive ? <Archive /> : <Unarchive />,
label: chore.isActive ? 'Archive' : 'Unarchive',
onClick: handleArchive,
color: 'neutral',
},
projects.length > 0 && {
key: 'moveToProject',
icon: <DriveFileMove />,
label: 'Move to project',
onClick: () => setShowProjectPicker(true),
},
{ key: 'divider-4', type: 'divider' },
{
key: 'delete',
icon: <Delete />,
label: 'Delete',
onClick: handleDelete,
color: 'danger',
},
].filter(Boolean)
const quickScheduleButtons = (
<>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('today')
}}
>
<Today />
</IconButton>
</Tooltip>
<Tooltip title='Tomorrow' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('tomorrow')
}}
>
<WbSunny />
</IconButton>
</Tooltip>
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
color='neutral'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
}}
>
<Cancel />
</IconButton>
</Tooltip>
</>
)
const quickScheduleRowSx = {
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
px: 1.5,
py: 1,
}
const renderMenuActionItems = () =>
actionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') {
return (
<MenuItem
key={item.key}
sx={{
...quickScheduleRowSx,
cursor: 'default',
'&:hover': { backgroundColor: 'transparent' },
}}
onClick={e => e.stopPropagation()}
>
{quickScheduleButtons}
</MenuItem>
)
}
return (
<MenuItem
key={item.key}
color={item.color}
onClick={e => {
e.stopPropagation()
item.onClick()
}}
>
{item.icon}
{item.label}
</MenuItem>
)
})
const renderModalActionItems = () =>
actionItems.map(item => {
if (item.type === 'divider') return <Divider key={item.key} />
if (item.type === 'quickSchedule') {
return (
<ListItem key={item.key} sx={quickScheduleRowSx}>
{quickScheduleButtons}
</ListItem>
)
}
return (
<ListItem key={item.key}>
<ListItemButton color={item.color} onClick={() => item.onClick()}>
<ListItemDecorator>{item.icon}</ListItemDecorator>
<ListItemContent>{item.label}</ListItemContent>
</ListItemButton>
</ListItem>
)
})
const renderModalProjectPicker = () => (
<List>
<ListItem>
<ListItemButton
onClick={() =>
handleMoveToProject({ id: null, name: 'Default Project' })
}
>
<ListItemDecorator>
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator>
<ListItemContent>Default Project</ListItemContent>
</ListItemButton>
</ListItem>
{projects.map(project => (
<ListItem key={project.id}>
<ListItemButton onClick={() => handleMoveToProject(project)}>
<ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)}
</ListItemDecorator>
<ListItemContent>{project.name}</ListItemContent>
</ListItemButton>
</ListItem>
))}
</List>
)
return ( return (
<> <>
<IconButton <IconButton
@@ -249,280 +513,95 @@ const ChoreActionMenu = ({
<MoreVert /> <MoreVert />
</IconButton> </IconButton>
<Menu {isSmallScreen ? (
size='md' <AppModal
ref={menuRef} open={Boolean(anchorEl)}
anchorEl={anchorEl} onClose={handleMenuClose}
open={Boolean(anchorEl)} title={showProjectPicker ? 'Move to project' : chore?.name}
onClose={handleMenuClose} mobilePresentation='sheet'
sx={{ showHandle
position: 'absolute', contentSx={{ px: 0, pb: 1 }}
top: '100%', >
left: '50%', {showProjectPicker && (
}}
>
{showProjectPicker ? (
<>
<MenuItem <MenuItem
onClick={e => { onClick={() => setShowProjectPicker(false)}
e.stopPropagation() sx={{ gap: 1, mx: 2, mb: 1 }}
setShowProjectPicker(false)
}}
sx={{ gap: 1 }}
> >
<ArrowBack fontSize='small' /> <ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}> <Typography level='body-sm' fontWeight={600}>
Move to project Back
</Typography> </Typography>
</MenuItem> </MenuItem>
<Divider /> )}
<MenuItem <List sx={{ '--ListItem-radius': '8px', px: 1 }}>
onClick={e => { {showProjectPicker
e.stopPropagation() ? renderModalProjectPicker()
handleMoveToProject({ id: null, name: 'Default Project' }) : renderModalActionItems()}
}} </List>
> </AppModal>
<ListItemDecorator> ) : (
{renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')} <Menu
</ListItemDecorator> size='md'
<ListItemContent> ref={menuRef}
<Typography level='body-sm'>Default Project</Typography> anchorEl={anchorEl}
</ListItemContent> open={Boolean(anchorEl)}
</MenuItem> onClose={handleMenuClose}
{projects.map(project => ( sx={{
position: 'absolute',
top: '100%',
left: '50%',
}}
>
{showProjectPicker ? (
<>
<MenuItem <MenuItem
key={project.id}
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
handleMoveToProject(project) setShowProjectPicker(false)
}}
sx={{ gap: 1 }}
>
<ArrowBack fontSize='small' />
<Typography level='body-sm' fontWeight={600}>
Move to project
</Typography>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleMoveToProject({ id: null, name: 'Default Project' })
}} }}
> >
<ListItemDecorator> <ListItemDecorator>
{renderProjectAvatar(project.color, project.icon)} {renderProjectAvatar(LABEL_COLORS[0].value, 'FolderOpen')}
</ListItemDecorator> </ListItemDecorator>
<ListItemContent> <ListItemContent>
<Typography level='body-sm'>{project.name}</Typography> <Typography level='body-sm'>Default Project</Typography>
</ListItemContent> </ListItemContent>
</MenuItem> </MenuItem>
))} {projects.map(project => (
</> <MenuItem
) : ( key={project.id}
<>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithNote?.()
handleMenuClose()
}}
>
<NoteAdd />
Complete with note
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onCompleteWithPastDate?.()
handleMenuClose()
}}
>
<Update />
Complete in past
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleSkip()
}}
>
<SwitchAccessShortcut />
Skip to next due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeAssignee?.()
handleMenuClose()
}}
>
<RecordVoiceOver />
Delegate to someone else
</MenuItem>
{isOfficialInstance && (
<MenuItem
onClick={e => {
e.stopPropagation()
onNudge?.()
handleMenuClose()
}}
>
<Notifications />
Send nudge
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleHistory()
}}
>
<ManageSearch />
History
</MenuItem>
<Divider />
<MenuItem
sx={{
display: 'flex',
justifyContent: 'space-around',
alignItems: 'center',
gap: 1,
cursor: 'default',
'&:hover': {
backgroundColor: 'transparent',
},
}}
onClick={e => e.stopPropagation()}
>
<Tooltip title='Today' placement='top'>
<IconButton
size='sm'
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
handleQuickSchedule('today') handleMoveToProject(project)
}} }}
> >
<Today /> <ListItemDecorator>
</IconButton> {renderProjectAvatar(project.color, project.icon)}
</Tooltip> </ListItemDecorator>
<Tooltip title='Tomorrow' placement='top'> <ListItemContent>
<IconButton <Typography level='body-sm'>{project.name}</Typography>
size='sm' </ListItemContent>
onClick={e => { </MenuItem>
e.stopPropagation() ))}
handleQuickSchedule('tomorrow') </>
}} ) : (
> renderMenuActionItems()
<WbSunny /> )}
</IconButton> </Menu>
</Tooltip> )}
<Tooltip title='Weekend' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('weekend')
}}
>
<Weekend />
</IconButton>
</Tooltip>
<Tooltip title='Next week' placement='top'>
<IconButton
size='sm'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('next-week')
}}
>
<NextWeek />
</IconButton>
</Tooltip>
<Tooltip title='Remove due date' placement='top'>
<IconButton
size='sm'
color='neutral'
onClick={e => {
e.stopPropagation()
handleQuickSchedule('remove')
}}
>
<Cancel />
</IconButton>
</Tooltip>
</MenuItem>
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
onChangeDueDate?.()
handleMenuClose()
}}
>
<MoreTime />
Change due date
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
onWriteNFC?.()
handleMenuClose()
}}
>
<Nfc />
Write to NFC
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleEdit()
}}
>
<Edit />
Edit
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleClone()
}}
>
<CopyAll />
Clone
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleView()
}}
>
<ViewCarousel />
View
</MenuItem>
<MenuItem
onClick={e => {
e.stopPropagation()
handleArchive()
}}
color='neutral'
>
{chore.isActive ? <Archive /> : <Unarchive />}
{chore.isActive ? 'Archive' : 'Unarchive'}
</MenuItem>
{projects.length > 0 && (
<MenuItem
onClick={e => {
e.stopPropagation()
setShowProjectPicker(true)
}}
>
<DriveFileMove />
Move to project
</MenuItem>
)}
<Divider />
<MenuItem
onClick={e => {
e.stopPropagation()
handleDelete()
}}
color='danger'
>
<Delete />
Delete
</MenuItem>
</>
)}
</Menu>
</> </>
) )
} }

View File

@@ -1,32 +1,8 @@
import { import { CalendarMonth, Close } from '@mui/icons-material'
Bedtime, import { Box, Button, IconButton, Typography } from '@mui/joy'
CalendarMonth,
Close,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
IconButton,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useEffect, useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import Calendar from 'react-calendar' import DueDatePickerModal from './DueDatePickerModal'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
const DueDatePickerField = ({ const DueDatePickerField = ({
dueDateOnly, dueDateOnly,
@@ -40,106 +16,17 @@ const DueDatePickerField = ({
size = 'sm', size = 'sm',
}) => { }) => {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply const handleSave = ({
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly) dueDateOnly: nextDate,
const [localDueTime, setLocalDueTime] = useState(dueTime) dueTime: nextTime,
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime) useCustomTime: nextUseCustomTime,
}) => {
// Sync local state from props whenever the modal opens onDueDateChange?.({ target: { value: nextDate || '' } })
useEffect(() => { onUseCustomTimeChange?.(nextUseCustomTime)
if (isOpen) { onDueTimeChange?.({
setLocalDueDateOnly(dueDateOnly) target: { value: nextUseCustomTime && nextTime ? nextTime : '' },
setLocalDueTime(dueTime) })
setLocalUseCustomTime(useCustomTime)
}
}, [isOpen, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const handleQuickSchedule = option => {
const date = getQuickScheduleDate(option)
setLocalDueDateOnly(date.toISOString().split('T')[0])
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(new Date().toISOString().split('T')[0])
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onDueDateChange?.({ target: { value: localDueDateOnly || '' } })
onUseCustomTimeChange?.(localUseCustomTime)
if (localUseCustomTime && localDueTime) {
onDueTimeChange?.({ target: { value: localDueTime } })
} else {
onDueTimeChange?.({ target: { value: '' } })
}
setIsOpen(false) setIsOpen(false)
} }
@@ -227,385 +114,22 @@ const DueDatePickerField = ({
)} )}
</Box> </Box>
<ResponsiveModal <DueDatePickerModal
open={isOpen} open={isOpen}
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
title='Due Date' dueDateOnly={dueDateOnly}
fullWidth={false} dueTime={dueTime}
footer={ useCustomTime={useCustomTime}
<ModalActions onApply={handleSave}
tertiary={ onRemove={
hasDueDate onClear
? { ? () => {
label: 'Remove', onClear()
color: 'danger', setIsOpen(false)
onClick: () => { }
onClear?.() : undefined
setIsOpen(false)
},
}
: undefined
}
secondary={{ label: 'Cancel', onClick: () => setIsOpen(false) }}
primary={{ label: 'Apply', onClick: handleSave }}
/>
} }
> />
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = getQuickScheduleDate(opt.key)
.toISOString()
.split('T')[0]
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly
? new Date(`${localDueDateOnly}T00:00:00`)
: null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
</> </>
) )
} }

View File

@@ -0,0 +1,544 @@
import {
Bedtime,
EventNote,
LightMode,
NextWeek,
NightsStay,
Today,
WbSunny,
WbTwilight,
Weekend,
} from '@mui/icons-material'
import {
Box,
Button,
Checkbox,
Input,
List,
ListItem,
Typography,
} from '@mui/joy'
import moment from 'moment'
import { useEffect, useState } from 'react'
import Calendar from 'react-calendar'
import ModalActions from '../../components/common/ModalActions'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
// Split a date-ish value (ISO string / Date) into the parts this picker edits.
export const splitDueDate = value => {
if (!value) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const m = moment(value)
if (!m.isValid()) {
return { dueDateOnly: null, dueTime: null, useCustomTime: false }
}
const time = m.format('HH:mm')
return {
dueDateOnly: m.format('YYYY-MM-DD'),
dueTime: time,
// Midnight is how a date-only value round-trips, so treat it as "anytime"
useCustomTime: time !== '00:00',
}
}
// Inverse of splitDueDate — returns a Date, or null when there is no due date.
export const combineDueDate = ({ dueDateOnly, dueTime, useCustomTime }) => {
if (!dueDateOnly) return null
const time = useCustomTime && dueTime ? dueTime : '00:00'
return moment(`${dueDateOnly} ${time}`, 'YYYY-MM-DD HH:mm').toDate()
}
export const getQuickScheduleDate = option => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
switch (option) {
case 'today':
return today
case 'tomorrow': {
const tomorrow = new Date(today)
tomorrow.setDate(today.getDate() + 1)
return tomorrow
}
case 'weekend': {
const weekend = new Date(today)
const daysUntilSaturday = (6 - today.getDay() + 7) % 7 || 7
weekend.setDate(today.getDate() + daysUntilSaturday)
return weekend
}
case 'next-week': {
const nextWeek = new Date(today)
const daysUntilMonday = (1 - today.getDay() + 7) % 7 || 7
nextWeek.setDate(today.getDate() + daysUntilMonday)
return nextWeek
}
case 'next-month': {
const nextMonth = new Date(today)
nextMonth.setMonth(today.getMonth() + 1)
return nextMonth
}
default:
return today
}
}
const toDateKey = date => moment(date).format('YYYY-MM-DD')
/**
* The shared due-date picker UI (quick dates, quick times, calendar, custom
* time). Used both by DueDatePickerField and by anything that needs to
* reschedule a task — task cards, swipe actions, action menus.
*/
const DueDatePickerModal = ({
open,
onClose,
title = 'Due Date',
dueDateOnly,
dueTime,
useCustomTime,
onApply,
onRemove,
applyLabel = 'Apply',
}) => {
const { ResponsiveModal } = useResponsiveModal()
const { firstDayOfWeek } = useLocalization()
// Local buffered state — only committed on Apply
const [localDueDateOnly, setLocalDueDateOnly] = useState(dueDateOnly)
const [localDueTime, setLocalDueTime] = useState(dueTime)
const [localUseCustomTime, setLocalUseCustomTime] = useState(useCustomTime)
// Sync local state from props whenever the modal opens
useEffect(() => {
if (open) {
setLocalDueDateOnly(dueDateOnly)
setLocalDueTime(dueTime)
setLocalUseCustomTime(useCustomTime)
}
}, [open, dueDateOnly, dueTime, useCustomTime])
const calendarType =
firstDayOfWeek === 1
? 'iso8601'
: firstDayOfWeek === 6
? 'islamic'
: 'gregory'
const pillListSx = {
'--List-gap': '8px',
'--ListItem-radius': '20px',
}
const handleQuickSchedule = option => {
setLocalDueDateOnly(toDateKey(getQuickScheduleDate(option)))
}
const handleQuickTime = timeStr => {
// Tap the active chip again to deselect it
if (localUseCustomTime && localDueTime === timeStr) {
setLocalUseCustomTime(false)
setLocalDueTime(null)
return
}
if (!localDueDateOnly) {
setLocalDueDateOnly(toDateKey(new Date()))
}
setLocalUseCustomTime(true)
setLocalDueTime(timeStr)
}
const handleCalendarChange = selected => {
if (!selected || Array.isArray(selected)) return
setLocalDueDateOnly(moment(selected).format('YYYY-MM-DD'))
}
const handleLocalTimeInputChange = e => {
setLocalUseCustomTime(true)
setLocalDueTime(e.target.value)
}
const handleSave = () => {
onApply?.({
dueDateOnly: localDueDateOnly || null,
dueTime: localUseCustomTime ? localDueTime || null : null,
useCustomTime: Boolean(localUseCustomTime && localDueTime),
})
}
return (
<ResponsiveModal
open={open}
onClose={onClose}
title={title}
fullWidth={false}
footer={
<ModalActions
tertiary={
onRemove && dueDateOnly
? {
label: 'Remove',
color: 'danger',
onClick: onRemove,
}
: undefined
}
secondary={{ label: 'Cancel', onClick: onClose }}
primary={{ label: applyLabel, onClick: handleSave }}
/>
}
>
<Box sx={{ fontFamily: 'var(--joy-fontFamily-body)', maxWidth: 360 }}>
{/* Date shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick date
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
key: 'today',
label: 'Today',
icon: <Today sx={{ fontSize: 14 }} />,
},
{
key: 'tomorrow',
label: 'Tomorrow',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
key: 'weekend',
label: 'Weekend',
icon: <Weekend sx={{ fontSize: 14 }} />,
},
{
key: 'next-week',
label: 'Next week',
icon: <NextWeek sx={{ fontSize: 14 }} />,
},
{
key: 'next-month',
label: 'Next month',
icon: <EventNote sx={{ fontSize: 14 }} />,
},
].map(opt => {
const dateStr = toDateKey(getQuickScheduleDate(opt.key))
return (
<ListItem key={opt.key}>
<Checkbox
checked={localDueDateOnly === dateStr}
onClick={() => handleQuickSchedule(opt.key)}
overlay
disableIcon
variant='soft'
label={
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
}}
>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
)
})}
</List>
{/* Time shortcuts */}
<Typography
level='body-xs'
sx={{
mb: 0.75,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Quick time
</Typography>
<List orientation='horizontal' wrap sx={{ ...pillListSx, mb: 1.5 }}>
{[
{
time: '09:00',
label: 'Morning',
icon: <LightMode sx={{ fontSize: 14 }} />,
},
{
time: '12:00',
label: 'Noon',
icon: <WbSunny sx={{ fontSize: 14 }} />,
},
{
time: '15:00',
label: 'Afternoon',
icon: <WbTwilight sx={{ fontSize: 14 }} />,
},
{
time: '18:00',
label: 'Evening',
icon: <NightsStay sx={{ fontSize: 14 }} />,
},
{
time: '22:00',
label: 'Night',
icon: <Bedtime sx={{ fontSize: 14 }} />,
},
].map(opt => (
<ListItem key={opt.time}>
<Checkbox
checked={localUseCustomTime && localDueTime === opt.time}
onClick={() => handleQuickTime(opt.time)}
overlay
disableIcon
variant='soft'
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{opt.icon}
{opt.label}
</Box>
}
/>
</ListItem>
))}
</List>
<Box
sx={{
mb: 1.5,
borderRadius: 'md',
border: '1px solid',
borderColor: 'neutral.outlinedBorder',
bgcolor: 'background.surface',
p: 1,
// Fix the height so switching views (month/year/decade) doesn't
// cause layout shift — month view with 6 rows is the tallest.
minHeight: 300,
display: 'flex',
flexDirection: 'column',
'& .react-calendar': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__viewContainer': {
flex: 1,
display: 'flex',
flexDirection: 'column',
},
'& .react-calendar__month-view, & .react-calendar__year-view, & .react-calendar__decade-view, & .react-calendar__century-view':
{
flex: 1,
},
// Navigation row
'& .react-calendar__navigation': {
display: 'flex',
alignItems: 'center',
gap: '4px',
mb: 1,
},
// All nav buttons — large tap targets
'& .react-calendar__navigation button': {
background: 'none',
border: 'none',
borderRadius: '8px',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
minHeight: '40px',
minWidth: '40px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px',
transition: 'background 0.15s',
'&:hover': {
backgroundColor: 'var(--joy-palette-neutral-softBg)',
},
'&:disabled': {
opacity: 0.35,
cursor: 'default',
},
},
// Label button (month/year text) takes remaining space
'& .react-calendar__navigation__label': {
flex: 1,
fontSize: '0.9rem',
fontWeight: 700,
letterSpacing: '0.01em',
},
// Prev/next arrow buttons — slightly larger icon feel
'& .react-calendar__navigation__prev-button, & .react-calendar__navigation__next-button':
{
fontSize: '1.75rem',
},
'& .react-calendar__navigation__prev2-button, & .react-calendar__navigation__next2-button':
{
fontSize: '1.4rem',
},
// Weekday headers
'& .react-calendar__month-view__weekdays__weekday': {
fontSize: '0.7rem',
fontWeight: 600,
color: 'var(--joy-palette-text-tertiary)',
textAlign: 'center',
padding: '4px 0',
textTransform: 'uppercase',
letterSpacing: '0.04em',
},
'& .react-calendar__month-view__weekdays__weekday abbr': {
textDecoration: 'none',
},
// All tiles — shared base
'& .react-calendar__tile': {
border: 'none',
background: 'none',
color: 'var(--joy-palette-text-primary)',
fontFamily: 'var(--joy-fontFamily-body)',
fontSize: '0.8rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'background 0.15s',
'&:hover': {
background: 'var(--joy-palette-neutral-softBg)',
},
},
// Day tiles only — circular
'& .react-calendar__month-view__days .react-calendar__tile': {
aspectRatio: '1',
borderRadius: '50%',
},
// Month tiles (year view) — pill shape, no huge circle
'& .react-calendar__year-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Year tiles (decade view) — pill shape
'& .react-calendar__decade-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
// Century tiles — pill shape
'& .react-calendar__century-view .react-calendar__tile': {
borderRadius: '8px',
padding: '10px 4px',
fontSize: '0.875rem',
},
'& .react-calendar__tile--now': {
border:
'1.5px solid var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidBg) !important',
fontWeight: 700,
background: 'none !important',
},
'& .react-calendar__tile--active, & .react-calendar__tile--active:hover':
{
background: 'var(--joy-palette-primary-solidBg) !important',
color: 'var(--joy-palette-primary-solidColor) !important',
fontWeight: 700,
},
'& .react-calendar__month-view__days__day--neighboringMonth': {
color: 'var(--joy-palette-text-tertiary)',
},
'& .react-calendar__month-view__days': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
'& .react-calendar__month-view__weekdays': {
display: 'grid !important',
gridTemplateColumns: 'repeat(7, 1fr) !important',
},
}}
>
<Calendar
value={
localDueDateOnly ? new Date(`${localDueDateOnly}T00:00:00`) : null
}
calendarType={calendarType}
onChange={handleCalendarChange}
formatShortWeekday={(locale, date) =>
['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'][date.getDay()]
}
formatMonth={(locale, date) =>
[
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
][date.getMonth()]
}
/>
</Box>
<Typography
level='body-xs'
sx={{
mb: 0.5,
mt: 0.5,
color: 'text.tertiary',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}
>
Custom time
</Typography>
<Input
type='time'
size='sm'
value={localUseCustomTime ? localDueTime || '' : ''}
disabled={!localDueDateOnly}
onChange={handleLocalTimeInputChange}
sx={{ maxWidth: 200, mb: 1 }}
slotProps={{ input: { style: { fontFamily: 'inherit' } } }}
/>
<Box sx={{ display: 'flex', gap: 0.75, mb: 0.5 }}>
<Button
size='sm'
variant={!localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(false)}
>
Anytime
</Button>
<Button
size='sm'
variant={localUseCustomTime ? 'soft' : 'plain'}
color='neutral'
disabled={!localDueDateOnly}
onClick={() => setLocalUseCustomTime(true)}
>
Specific time
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default DueDatePickerModal

View File

@@ -12,7 +12,7 @@ import {
LinearProgress, LinearProgress,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useEffect } from 'react' import { useCallback, useEffect, useMemo } from 'react'
import { useScanToTask } from './useScanToTask' import { useScanToTask } from './useScanToTask'
/** /**
@@ -20,8 +20,20 @@ import { useScanToTask } from './useScanToTask'
* *
* Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose] * Flow: capture → (auto) processing → done [calls onTaskExtracted + onClose]
* → error [retake or cancel] * → error [retake or cancel]
*
* The primary action (Capture / Scan Document / Retake) lives in the modal
* footer alongside Cancel — the panel reports it up through onStateChange
* rather than rendering its own button row. Upload stays inline because it
* belongs to the capture surface and drives a hidden input in this subtree.
*/ */
const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCapture }) => { const ScanPanel = ({
open,
onTaskExtracted,
onClose,
onStateChange,
initialImageUrl,
autoCapture,
}) => {
const { const {
isNativeScanner, isNativeScanner,
phase, phase,
@@ -76,6 +88,51 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [phase, taskResult]) }, [phase, taskResult])
const openFilePicker = useCallback(
() => fileInputRef.current?.click(),
[fileInputRef],
)
// The one action the footer renders for the current phase; null while
// processing (nothing to do but wait) and when done (the panel closes)
const primaryAction = useMemo(() => {
if (phase === 'capture') {
if (isNativeScanner) {
return {
label: 'Scan Document',
icon: <DocumentScanner />,
onClick: handleNativeScan,
}
}
if (cameraAvailable) {
return { label: 'Capture', icon: <CameraAlt />, onClick: capture }
}
// No camera on this device — Upload is the only way forward, so it
// graduates from the inline secondary to the footer's primary
return {
label: 'Upload Photo',
icon: <PhotoCamera />,
onClick: openFilePicker,
}
}
if (phase === 'error') {
return { label: 'Retake', icon: <Replay />, onClick: retake }
}
return null
}, [
phase,
isNativeScanner,
cameraAvailable,
capture,
handleNativeScan,
retake,
openFilePicker,
])
useEffect(() => {
onStateChange?.({ phase, primaryAction })
}, [phase, primaryAction, onStateChange])
if (!open) return null if (!open) return null
const isProcessing = phase === 'processing' const isProcessing = phase === 'processing'
@@ -111,7 +168,10 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
<DocumentScanner <DocumentScanner
sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }} sx={{ fontSize: 56, color: 'white', opacity: 0.5, mb: 1 }}
/> />
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}> <Typography
level='body-sm'
sx={{ color: 'white', opacity: 0.6 }}
>
Tap &quot;Scan Document&quot; to open the scanner Tap &quot;Scan Document&quot; to open the scanner
</Typography> </Typography>
</Box> </Box>
@@ -137,65 +197,38 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
<CameraAlt <CameraAlt
sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }} sx={{ fontSize: 48, color: 'white', opacity: 0.4, mb: 1 }}
/> />
<Typography level='body-sm' sx={{ color: 'white', opacity: 0.6 }}> <Typography
level='body-sm'
sx={{ color: 'white', opacity: 0.6 }}
>
Camera not available use Upload instead Camera not available use Upload instead
</Typography> </Typography>
</Box> </Box>
)} )}
</Box> </Box>
<Box {/* Hidden when Upload is already the footer's primary action */}
sx={{ {(isNativeScanner || cameraAvailable) && (
px: 1.5, <Box
py: 1, sx={{
display: 'flex', px: 1.5,
alignItems: 'center', py: 1,
gap: 1, display: 'flex',
}} alignItems: 'center',
> gap: 1,
<Button }}
size='sm'
variant='plain'
color='neutral'
startDecorator={<PhotoCamera fontSize='small' />}
onClick={() => fileInputRef.current?.click()}
> >
Upload <Button
</Button> size='sm'
<input variant='plain'
ref={fileInputRef} color='neutral'
type='file' startDecorator={<PhotoCamera fontSize='small' />}
accept='image/*' onClick={openFilePicker}
style={{ display: 'none' }} >
onChange={handleFileSelect} Upload
/> </Button>
<Box sx={{ ml: 'auto', display: 'flex', gap: 1 }}>
{isNativeScanner ? (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<DocumentScanner fontSize='small' />}
onClick={handleNativeScan}
>
Scan Document
</Button>
) : (
cameraAvailable && (
<Button
size='sm'
variant='solid'
color='primary'
startDecorator={<CameraAlt fontSize='small' />}
onClick={capture}
>
Capture
</Button>
)
)}
</Box> </Box>
</Box> )}
</> </>
)} )}
@@ -280,24 +313,22 @@ const ScanPanel = ({ open, onTaskExtracted, onClose, initialImageUrl, autoCaptur
}} }}
/> />
)} )}
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1 }}>
<WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} /> <WarningAmber color='warning' sx={{ mt: 0.25, flexShrink: 0 }} />
<Typography level='body-sm'>{errorMsg}</Typography> <Typography level='body-sm'>{errorMsg}</Typography>
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Replay fontSize='small' />}
onClick={retake}
>
Retake
</Button>
</Box>
</Box> </Box>
)} )}
{/* Kept outside the phase branches so the footer's Upload action can
reach it even when no capture surface is rendered */}
<input
ref={fileInputRef}
type='file'
accept='image/*'
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<canvas ref={canvasRef} style={{ display: 'none' }} /> <canvas ref={canvasRef} style={{ display: 'none' }} />
</Box> </Box>
) )

View File

@@ -401,18 +401,18 @@ const TaskPreviewCard = ({
/** /**
* Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal. * Inline voice-to-task panel. Mounts inside AddTaskModal — no second modal.
* *
* Opens straight into hands-free listening. Pauses and spoken separators * Mounted only while voice capture is active, and opens straight into
* ("also") split the transcript into task cards; tapping a card opens inline * hands-free listening. Pauses and spoken separators ("also") split the
* pickers whose edits override the parsed values. A single captured task * transcript into task cards; tapping a card opens inline pickers whose edits
* lands in the smart input for review; multiple are created directly. * override the parsed values. The confirm action lives in the modal footer
* alongside Cancel — this panel only reports its state up through
* onStateChange so the modal can label and enable that button.
*/ */
const VoicePanel = ({ const VoicePanel = ({
open,
userLabels = [], userLabels = [],
members = [], members = [],
userProfile, userProfile,
onUseSingle, onStateChange,
onCreateMany,
}) => { }) => {
const { const {
phase, phase,
@@ -427,8 +427,6 @@ const VoicePanel = ({
patchSegment, patchSegment,
isNative, isNative,
} = useVoiceToTask({ members, userLabels }) } = useVoiceToTask({ members, userLabels })
const [creating, setCreating] = useState(false)
const autoStartedRef = useRef(false)
const segmentsScrollRef = useRef(null) const segmentsScrollRef = useRef(null)
const parseCtx = useMemo( const parseCtx = useMemo(
@@ -441,14 +439,12 @@ const VoicePanel = ({
[partialText, parseCtx], [partialText, parseCtx],
) )
// Start capturing the moment the panel opens — the mic tap that opened it // Start capturing the moment the panel mounts — the mic tap that opened it
// is the only tap needed // is the only tap needed. startHandsFree no-ops if already listening.
useEffect(() => { useEffect(() => {
if (open && !autoStartedRef.current) { startHandsFree()
autoStartedRef.current = true // eslint-disable-next-line react-hooks/exhaustive-deps
startHandsFree() }, [])
}
}, [open, startHandsFree])
// Keep the newest captured task visible as more are added // Keep the newest captured task visible as more are added
useEffect(() => { useEffect(() => {
@@ -456,24 +452,14 @@ const VoicePanel = ({
if (el) el.scrollTop = el.scrollHeight if (el) el.scrollTop = el.scrollHeight
}, [segments.length]) }, [segments.length])
if (!open) return null
const isListening = phase === 'listening' const isListening = phase === 'listening'
const showActions = segments.length > 0 && !isListening && !creating
const mergedTask = segment => ({ // The confirm action lives in the modal footer, so report the raw segments
...parseVoiceTask(segment.text, parseCtx), // and whether the mic is live — that's all it needs to label and enable the
...(segment.overrides || {}), // button. It parses the segments itself when the user confirms.
}) useEffect(() => {
onStateChange?.({ segments, isListening })
const handleCreateAll = async () => { }, [segments, isListening, onStateChange])
setCreating(true)
try {
await onCreateMany(segments.map(mergedTask))
} finally {
setCreating(false)
}
}
const micCaption = isListening const micCaption = isListening
? isLocked ? isLocked
@@ -642,47 +628,6 @@ const VoicePanel = ({
</Typography> </Typography>
</Box> </Box>
)} )}
{/* ── Footer — dismissing is the modal's Cancel; this owns confirm only ── */}
{(creating || showActions) && (
<Box
sx={{
px: 1.5,
py: 1,
display: 'flex',
justifyContent: 'flex-end',
gap: 1,
borderTop: '1px solid',
borderColor: 'divider',
}}
>
{creating ? (
<Button size='sm' variant='solid' color='primary' loading>
Creating
</Button>
) : segments.length === 1 ? (
<Button
size='sm'
variant='solid'
color='primary'
onClick={() =>
onUseSingle(segments[0].text, segments[0].overrides || {})
}
>
Use Task
</Button>
) : (
<Button
size='sm'
variant='solid'
color='primary'
onClick={handleCreateAll}
>
Create {segments.length} Tasks
</Button>
)}
</Box>
)}
</Box> </Box>
) )
} }