import { CancelScheduleSend, Check, Delete, Edit, HourglassEmpty, Notifications, Pause, PlayArrow, Repeat, Schedule, ThumbDown, ThumbUp, TimesOneMobiledata, Webhook, } from '@mui/icons-material' import { Box, Button, Checkbox, Chip, CircularProgress, IconButton, Snackbar, Typography, } from '@mui/joy' import moment from 'moment' import React from 'react' import { useNavigate } from 'react-router-dom' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useNotification } from '../../service/NotificationProvider' import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getPriorityColor, getTextColorFromBackgroundColor, } from '../../utils/Colors.jsx' import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import { ApproveChore, DeleteChore, MarkChoreComplete, NudgeChore, PauseChore, RejectChore, StartChore, UpdateChoreAssignee, UpdateDueDate, } from '../../utils/Fetcher' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import DateModal from '../Modals/Inputs/DateModal' import NudgeModal from '../Modals/Inputs/NudgeModal' import SelectModal from '../Modals/Inputs/SelectModal' import TextModal from '../Modals/Inputs/TextModal' import WriteNFCModal from '../Modals/Inputs/WriteNFCModal' import ChoreActionMenu from '../components/ChoreActionMenu' const CompactChoreCard = ({ chore, performers, onChoreUpdate, onChoreRemove, sx, viewOnly, onChipClick, // Multi-select props isMultiSelectMode = false, isSelected = false, onSelectionToggle, }) => { const [isChangeDueDateModalOpen, setIsChangeDueDateModalOpen] = React.useState(false) const [isCompleteWithPastDateModalOpen, setIsCompleteWithPastDateModalOpen] = React.useState(false) const [isChangeAssigneeModalOpen, setIsChangeAssigneeModalOpen] = React.useState(false) const [isCompleteWithNoteModalOpen, setIsCompleteWithNoteModalOpen] = React.useState(false) const [confirmModelConfig, setConfirmModelConfig] = React.useState({}) const [isNFCModalOpen, setIsNFCModalOpen] = React.useState(false) const [isNudgeModalOpen, setIsNudgeModalOpen] = React.useState(false) const [isOfficialInstance, setIsOfficialInstance] = React.useState(false) const navigate = useNavigate() const [isPendingCompletion, setIsPendingCompletion] = React.useState(false) const [secondsLeftToCancel, setSecondsLeftToCancel] = React.useState(null) const [timeoutId, setTimeoutId] = React.useState(null) const { data: userProfile } = useUserProfile() const { data: circleMembersData } = useCircleMembers() const { impersonatedUser } = useImpersonateUser() const { showError, showNotification } = useNotification() // Swipe functionality state const [swipeTranslateX, setSwipeTranslateX] = React.useState(0) const [isDragging, setIsDragging] = React.useState(false) const [isSwipeRevealed, setIsSwipeRevealed] = React.useState(false) const [hoverTimer, setHoverTimer] = React.useState(null) const [isTouchDevice, setIsTouchDevice] = React.useState(false) const swipeThreshold = 80 // Minimum swipe distance to reveal actions const maxSwipeDistance = 260 // Maximum swipe distance const dragStartX = React.useRef(0) const cardRef = React.useRef(null) // Detect if device supports touch React.useEffect(() => { const checkTouchDevice = () => { setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0) } checkTouchDevice() // Check if this is the official donetick.com instance try { setIsOfficialInstance(isOfficialDonetickInstanceSync()) } catch (error) { console.warn('Error checking instance type:', error) setIsOfficialInstance(false) } }, []) // Swipe gesture handlers const handleTouchStart = e => { if (isMultiSelectMode || viewOnly) return dragStartX.current = e.touches[0].clientX setIsDragging(true) } const handleTouchMove = e => { if (isMultiSelectMode || viewOnly || !isDragging) return const currentX = e.touches[0].clientX const deltaX = currentX - dragStartX.current if (isSwipeRevealed) { // When actions are revealed, allow right swipe to hide if (deltaX > 0) { const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) setSwipeTranslateX(clampedDelta) } } else { // When actions are hidden, allow left swipe to reveal if (deltaX < 0) { const clampedDelta = Math.max(deltaX, -maxSwipeDistance) setSwipeTranslateX(clampedDelta) } } } const handleTouchEnd = () => { if (isMultiSelectMode || viewOnly || !isDragging) return setIsDragging(false) if (isSwipeRevealed) { // When actions are revealed, check if user swiped right enough to hide if (swipeTranslateX > -swipeThreshold) { setSwipeTranslateX(0) setIsSwipeRevealed(false) } else { // Snap back to revealed position setSwipeTranslateX(-maxSwipeDistance) } } else { // When actions are hidden, check if user swiped left enough to reveal if (Math.abs(swipeTranslateX) > swipeThreshold) { setSwipeTranslateX(-maxSwipeDistance) setIsSwipeRevealed(true) } else { setSwipeTranslateX(0) setIsSwipeRevealed(false) } } } const handleMouseDown = e => { if (isMultiSelectMode || viewOnly) return dragStartX.current = e.clientX setIsDragging(true) } const handleMouseMove = e => { if (isMultiSelectMode || viewOnly || !isDragging) return const currentX = e.clientX const deltaX = currentX - dragStartX.current if (isSwipeRevealed) { // When actions are revealed, allow right swipe to hide if (deltaX > 0) { const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) setSwipeTranslateX(clampedDelta) } } else { // When actions are hidden, allow left swipe to reveal if (deltaX < 0) { const clampedDelta = Math.max(deltaX, -maxSwipeDistance) setSwipeTranslateX(clampedDelta) } } } const handleMouseUp = () => { if (isMultiSelectMode || viewOnly || !isDragging) return setIsDragging(false) if (isSwipeRevealed) { // When actions are revealed, check if user swiped right enough to hide if (swipeTranslateX > -swipeThreshold) { setSwipeTranslateX(0) setIsSwipeRevealed(false) } else { // Snap back to revealed position setSwipeTranslateX(-maxSwipeDistance) } } else { // When actions are hidden, check if user swiped left enough to reveal if (Math.abs(swipeTranslateX) > swipeThreshold) { setSwipeTranslateX(-maxSwipeDistance) setIsSwipeRevealed(true) } else { setSwipeTranslateX(0) setIsSwipeRevealed(false) } } } const resetSwipe = () => { setSwipeTranslateX(0) setIsSwipeRevealed(false) } // Hover functionality for desktop const handleMouseEnter = () => { if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice) return const timer = setTimeout(() => { setSwipeTranslateX(-maxSwipeDistance) setIsSwipeRevealed(true) setHoverTimer(null) }, 1500) setHoverTimer(timer) } const handleMouseLeave = () => { if (isTouchDevice) return if (hoverTimer) { clearTimeout(hoverTimer) setHoverTimer(null) } // Add a small delay before hiding to allow moving to action area if (isSwipeRevealed) { const hideTimer = setTimeout(() => { resetSwipe() }, 300) setHoverTimer(hideTimer) } } const handleActionAreaMouseEnter = () => { if (isTouchDevice) return // Clear any pending timer when entering action area (both show and hide timers) if (hoverTimer) { clearTimeout(hoverTimer) setHoverTimer(null) } } const handleActionAreaMouseLeave = () => { if (isTouchDevice) return // Hide immediately when leaving action area if (isSwipeRevealed) { resetSwipe() } } // Clean up timer on unmount React.useEffect(() => { return () => { if (hoverTimer) { clearTimeout(hoverTimer) } } }, [hoverTimer]) // All the existing handler methods (same as original ChoreCard) const handleDelete = () => { setConfirmModelConfig({ isOpen: true, title: 'Delete Chore', confirmText: 'Delete', cancelText: 'Cancel', message: 'Are you sure you want to delete this chore?', onClose: isConfirmed => { if (isConfirmed === true) { DeleteChore(chore.id).then(response => { if (response.ok) { onChoreRemove(chore) } }) } setConfirmModelConfig({}) }, }) } const handleTaskCompletion = () => { setIsPendingCompletion(true) let seconds = 3 setSecondsLeftToCancel(seconds) const countdownInterval = setInterval(() => { seconds -= 1 setSecondsLeftToCancel(seconds) if (seconds <= 0) { clearInterval(countdownInterval) setIsPendingCompletion(false) } }, 1000) const id = setTimeout(() => { MarkChoreComplete( chore.id, impersonatedUser ? { completedBy: impersonatedUser.userId } : null, null, null, ) .then(resp => { if (resp.ok) { return resp.json().then(data => { onChoreUpdate(data.res, 'completed') }) } }) .then(() => { setIsPendingCompletion(false) clearTimeout(id) clearInterval(countdownInterval) setTimeoutId(null) setSecondsLeftToCancel(null) }) .catch(error => { if (error?.queued) { showError({ title: 'Update Failed', message: 'Request will be reattempt when you are online', }) } else { showError({ title: 'Failed to update', message: error, }) } setIsPendingCompletion(false) clearTimeout(id) clearInterval(countdownInterval) setTimeoutId(null) setSecondsLeftToCancel(null) }) }, 2000) setTimeoutId(id) } const handleChangeDueDate = newDate => { UpdateDueDate(chore.id, newDate).then(response => { if (response.ok) { response.json().then(data => { const newChore = data.res onChoreUpdate(newChore, 'rescheduled') }) } }) } const handleCompleteWithPastDate = newDate => { MarkChoreComplete( chore.id, impersonatedUser ? { completedBy: impersonatedUser.userId } : null, new Date(newDate).toISOString(), null, ).then(response => { if (response.ok) { response.json().then(data => { const newChore = data.res onChoreUpdate(newChore, 'completed') }) } }) } const handleAssigneChange = assigneeId => { UpdateChoreAssignee(chore.id, assigneeId).then(response => { if (response.ok) { response.json().then(data => { const newChore = data.res onChoreUpdate(newChore, 'assigned') }) } }) } const handleCompleteWithNote = note => { MarkChoreComplete( chore.id, impersonatedUser ? { note, completedBy: impersonatedUser.userId } : { note }, null, null, ).then(response => { if (response.ok) { response.json().then(data => { const newChore = data.res onChoreUpdate(newChore, 'completed') }) } }) } const handleApproveChore = () => { resetSwipe() ApproveChore(chore.id).then(response => { if (response.ok) { response.json().then(data => { onChoreUpdate(data.res, 'approved') }) } }) } const handleRejectChore = () => { resetSwipe() RejectChore(chore.id).then(response => { if (response.ok) { response.json().then(data => { onChoreUpdate(data.res, 'rejected') }) } }) } const handleNudge = async ({ choreId, message, notifyAllAssignees }) => { try { const response = await NudgeChore(choreId, { message, notifyAllAssignees, }) if (response.ok) { const data = await response.json() showNotification({ type: 'success', title: 'Nudge Sent!', message: data.message || 'Nudge sent successfully', }) } else { throw new Error('Failed to send nudge') } } catch (error) { showError({ title: 'Failed to Send Nudge', message: error.message || 'Unable to send nudge at this time', }) } finally { setIsNudgeModalOpen(false) resetSwipe() } } // Check if the current user can approve/reject (admin, manager, or task owner) const canApproveReject = () => { if (!circleMembersData?.res || !chore) return false const currentUser = circleMembersData.res.find( member => member.userId === (impersonatedUser?.userId || userProfile?.id), ) // User can approve/reject if they are: // 1. Admin or manager of the circle // 2. Owner/creator of the task return ( currentUser?.role === 'admin' || currentUser?.role === 'manager' || chore.createdBy === (impersonatedUser?.userId || userProfile?.id) ) } // Utility functions const getDueDateText = nextDueDate => { if (chore.nextDueDate === null) return 'No Due Date' // if due in next 48 hours, we should it in this format : Tomorrow 11:00 AM const diff = moment(nextDueDate).diff(moment(), 'hours') if (diff < 48 && diff > 0) { return moment(nextDueDate).calendar().replace(' at', '') } return moment(nextDueDate).fromNow() } const getDueDateColor = nextDueDate => { if (chore.nextDueDate === null) return 'neutral' const diff = moment(nextDueDate).diff(moment(), 'hours') if (diff < 48 && diff > 0) { return 'warning' } if (diff < 0) { return 'danger' } return 'neutral' } const getRecurrentText = chore => { // if chore.frequencyMetadata is type string then parse it otherwise assigned to the metadata: const metadata = typeof chore.frequencyMetadata === 'string' ? JSON.parse(chore.frequencyMetadata) : chore.frequencyMetadata const dayOfMonthSuffix = n => { if (n >= 11 && n <= 13) { return 'th' } switch (n % 10) { case 1: return 'st' case 2: return 'nd' case 3: return 'rd' default: return 'th' } } if (chore.frequencyType === 'once') { return 'Once' } else if (chore.frequencyType === 'trigger') { return 'Trigger' } else if (chore.frequencyType === 'daily') { return 'Daily' } else if (chore.frequencyType === 'adaptive') { return 'Adaptive' } else if (chore.frequencyType === 'weekly') { return 'Weekly' } else if (chore.frequencyType === 'monthly') { return 'Monthly' } else if (chore.frequencyType === 'yearly') { return 'Yearly' } else if (chore.frequencyType === 'days_of_the_week') { let days = metadata.days if (days.length > 4) { const allDays = [ 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', ] const selectedDays = days.map(d => moment().day(d).format('dddd')) const notSelectedDay = allDays.filter( day => !selectedDays.includes(day), ) const notSelectedShortdays = notSelectedDay.map(d => moment().day(d).format('ddd'), ) return `Daily except ${notSelectedShortdays.join(', ')}` } else { days = days.map(d => moment().day(d).format('ddd')) return days.join(', ') } } else if (chore.frequencyType === 'day_of_the_month') { let months = metadata.months if (months.length > 6) { const allMonths = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] const selectedMonths = months.map(m => moment().month(m).format('MMMM')) const notSelectedMonth = allMonths.filter( month => !selectedMonths.includes(month), ) const notSelectedShortMonths = notSelectedMonth.map(m => moment().month(m).format('MMM'), ) let result = `Monthly ${chore.frequency}${dayOfMonthSuffix( chore.frequency, )}` if (notSelectedShortMonths.length > 0) result += ` except ${notSelectedShortMonths.join(', ')}` return result } else { let freqData = metadata const months = freqData.months.map(m => moment().month(m).format('MMM')) return `${chore.frequency}${dayOfMonthSuffix( chore.frequency, )} of ${months.join(', ')}` } } else if (chore.frequencyType === 'interval') { return `Every ${chore.frequency} ${metadata.unit}` } else { return chore.frequencyType } } const getFrequencyIcon = chore => { if (['once', 'no_repeat'].includes(chore.frequencyType)) { return } else if (chore.frequencyType === 'trigger') { return } else { return } } const formatMetadata = () => { const parts = [] // Frequency parts.push(getRecurrentText(chore)) // Assignee (if not current user) if (userProfile && chore.assignedTo !== userProfile.id) { const assignee = performers.find( p => p.id === chore.assignedTo, )?.displayName if (assignee) parts.push(assignee) } // Points if (chore.points > 0) { parts.push(`${chore.points}pts`) } return parts.join(' • ') } const handleChorePause = () => { PauseChore(chore.id).then(response => { if (response.ok) { response.json().then(data => { const newChore = { ...chore, ...data.res, } onChoreUpdate(newChore, 'paused') }) } }) } const handleChoreStart = () => { StartChore(chore.id).then(response => { if (response.ok) { response.json().then(data => { const newChore = { ...chore, ...data.res, } onChoreUpdate(newChore, 'started') }) } }) } return ( {/* Action buttons underneath (revealed on swipe) */} {chore.status === 3 ? ( // Pending approval: Show approve/reject for admins/managers/owners canApproveReject() ? ( <> {/* */} ) : ( ) ) : ( { e.stopPropagation() resetSwipe() if (chore.status === 0 || chore.status === 2) { handleChoreStart() } else { // handleChorePause() handleTaskCompletion() } }} sx={{ width: 40, height: 40, mx: 1, }} > {chore.status !== 1 ? ( ) : ( )} )} { e.stopPropagation() resetSwipe() setIsChangeDueDateModalOpen(true) }} sx={{ width: 40, height: 40, mx: 1, // bgcolor: 'warning.100', // color: 'warning.600', // '&:hover': { // bgcolor: 'warning.200', // }, }} > { e.stopPropagation() resetSwipe() navigate(`/chores/${chore.id}/edit`) }} sx={{ width: 40, height: 40, mx: 1, // bgcolor: 'neutral.100', // color: 'neutral.600', // '&:hover': { // bgcolor: 'neutral.200', // }, }} > {isOfficialInstance && ( { e.stopPropagation() resetSwipe() setIsNudgeModalOpen(true) }} sx={{ width: 40, height: 40, mx: 1, }} > )} { e.stopPropagation() resetSwipe() handleDelete() }} sx={{ width: 40, height: 40, mx: 1, }} > {/* Main card content */} { if (isSwipeRevealed) { resetSwipe() return } if (isMultiSelectMode) { onSelectionToggle() } else { navigate(`/chores/${chore.id}`) } }} onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={handleMouseUp} // onMouseEnter={handleMouseEnter} > {/* Priority bar clickable area */} {chore.priority > 0 && ( { e.stopPropagation() onChipClick({ priority: chore.priority }) }} /> )} {/* Animated transition container for Complete Button / Multi-select checkbox */} {/* Complete Button */} {chore.status === 3 ? ( // Pending approval: Show approve/reject for admins/managers/owners, grayed out for others canApproveReject() ? ( { e.stopPropagation() handleApproveChore() }} sx={{ width: 24, height: 24, borderRadius: '50%', transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, }} > {/* { e.stopPropagation() handleRejectChore() }} sx={{ width: 24, height: 24, borderRadius: '50%', transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, }} > */} ) : ( ) ) : ( { e.stopPropagation() if (chore.status === 0) { handleTaskCompletion() } else if (chore.status === 1) { handleChorePause() } else { handleChoreStart() } }} disabled={isPendingCompletion || notInCompletionWindow(chore)} sx={{ width: 32, height: 32, borderRadius: '50%', transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, '&:disabled': { opacity: 0.5, transform: 'none', }, }} > {isPendingCompletion ? ( ) : chore.status === 0 ? ( ) : chore.status === 1 ? ( ) : ( )} )} {/* Multi-select Checkbox */} e.stopPropagation()} /> {/* Content - Center */} {/* Line 1: Name + Due Date */} {/* Chore Name */} {chore.name} {/* Due Date - Inline with name */} {getDueDateText(chore.nextDueDate)} {/* Line 2: Metadata */} {getFrequencyIcon(chore)} {formatMetadata()} {/* Labels - Priority chip removed, now shown as vertical bar */} {chore.labelsV2?.map(l => (
{ e.stopPropagation() onChipClick({ label: l }) }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation() onChipClick({ label: l }) } }} style={{ cursor: 'pointer', padding: 0, margin: 0, display: 'flex', alignItems: 'center', }} key={`compact-chorecard-${chore.id}-label-${l.id}`} > {l?.name}
))}
{/* Right side - Action Menu with animation */} setIsCompleteWithNoteModalOpen(true)} onCompleteWithPastDate={() => setIsCompleteWithPastDateModalOpen(true) } onChangeAssignee={() => setIsChangeAssigneeModalOpen(true)} onChangeDueDate={() => setIsChangeDueDateModalOpen(true)} onWriteNFC={() => setIsNFCModalOpen(true)} onNudge={() => setIsNudgeModalOpen(true)} onDelete={handleDelete} onMouseEnter={handleMouseEnter} // onMouseLeave={handleMouseLeave} sx={{ width: 32, height: 32, color: 'text.tertiary', flexShrink: 0, '&:hover': { color: 'text.secondary', bgcolor: 'background.level1', }, }} onOpen={() => { handleMouseLeave() }} />
{/* All modals (same as original) */} setIsChangeDueDateModalOpen(false)} onSave={handleChangeDueDate} /> setIsCompleteWithPastDateModalOpen(false)} onSave={handleCompleteWithPastDate} /> setIsChangeAssigneeModalOpen(false)} onSave={selected => handleAssigneChange(selected.id)} /> {confirmModelConfig?.isOpen && ( )} setIsCompleteWithNoteModalOpen(false)} okText={'Complete'} onSave={handleCompleteWithNote} /> setIsNFCModalOpen(false), }} /> setIsNudgeModalOpen(false), onConfirm: handleNudge, }} /> {/* Snackbar for pending completion */} { if (timeoutId) { clearTimeout(timeoutId) setIsPendingCompletion(false) setTimeoutId(null) setSecondsLeftToCancel(null) } }} size='sm' variant='outlined' color='primary' startDecorator={} > Cancel } > Task will be marked as completed in {secondsLeftToCancel} seconds
) } export default CompactChoreCard