import { Check, Delete, Edit, Group, HourglassEmpty, Notifications, Pause, PlayArrow, Repeat, Schedule, ThumbDown, ThumbUp, TimesOneMobiledata, Toll, Webhook, } from '@mui/icons-material' import { Avatar, Box, Card, Checkbox, Chip, Grid, IconButton, 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 { useUserProfile } from '../../queries/UserQueries.jsx' import { notInCompletionWindow } from '../../utils/Chores.jsx' import { getTextColorFromBackgroundColor } from '../../utils/Colors.jsx' import { isOfficialDonetickInstanceSync } from '../../utils/FeatureToggle' import Priorities from '../../utils/Priorities' import ChoreActionMenu from '../components/ChoreActionMenu' const ChoreCard = ({ chore, performers, sx, viewOnly, onChipClick, onAction, // Multi-select props isMultiSelectMode = false, isSelected = false, onSelectionToggle, }) => { const [isOfficialInstance, setIsOfficialInstance] = React.useState(false) const navigate = useNavigate() const { data: userProfile } = useUserProfile() const { impersonatedUser } = useImpersonateUser() // 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) } }, []) // Check if the current user can approve/reject (admin, manager, or task owner) const canApproveReject = () => { if (!performers || !chore) return false const currentUser = performers.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) ) } // 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 - only trigger from action menu const handleMouseEnter = () => { if (isMultiSelectMode || viewOnly || isSwipeRevealed || isTouchDevice) return const timer = setTimeout(() => { setSwipeTranslateX(-maxSwipeDistance) setIsSwipeRevealed(true) setHoverTimer(null) }, 1500) // Match CompactChoreCard delay 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) // Match CompactChoreCard delay 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 (like CompactChoreCard) if (isSwipeRevealed) { resetSwipe() } } // Clean up timer on unmount React.useEffect(() => { return () => { if (hoverTimer) { clearTimeout(hoverTimer) } } }, [hoverTimer]) const getDueDateChipText = 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 'Due ' + moment(nextDueDate).fromNow() } const getDueDateChipColor = 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 getRecurrentChipText = 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 getName = name => { const split = Array.from(chore.name) // if the first character is emoji then remove it from the name if (/\p{Emoji}/u.test(split[0])) { return split.slice(1).join('').trim() } return name } return ( {getDueDateChipText(chore.nextDueDate)}
{getFrequencyIcon(chore)} {getRecurrentChipText(chore)}
{/* Action buttons underneath (revealed on swipe) */} {chore.status === 3 ? ( // Pending approval: Show approve/reject for admins/managers/owners canApproveReject() ? ( <> {/* { e.stopPropagation() resetSwipe() onAction('approve', chore) }} sx={{ width: 40, height: 40, mx: 1, }} > */} { e.stopPropagation() resetSwipe() onAction('reject', chore) }} sx={{ width: 40, height: 40, mx: 1, }} > ) : ( ) ) : ( { e.stopPropagation() resetSwipe() if (chore.status !== 0) { onAction('complete', chore) } else { onAction('start', chore) } }} sx={{ width: 40, height: 40, mx: 1, }} > {chore.status !== 0 ? ( ) : ( )} )} { e.stopPropagation() resetSwipe() onAction('changeDueDate', chore) }} sx={{ width: 40, height: 40, mx: 1, }} > { e.stopPropagation() resetSwipe() navigate(`/chores/${chore.id}/edit`) }} sx={{ width: 40, height: 40, mx: 1, }} > {isOfficialInstance && ( { e.stopPropagation() resetSwipe() onAction('nudge', chore) }} sx={{ width: 40, height: 40, mx: 1, }} > )} { e.stopPropagation() resetSwipe() onAction('delete', chore) }} sx={{ width: 40, height: 40, mx: 1, }} > {/* Multi-select checkbox */} {isMultiSelectMode && ( e.stopPropagation()} /> )} { if (isMultiSelectMode) { onSelectionToggle() } else { navigate(`/chores/${chore.id}`) } }} > {/* Box in top right with Chip showing next due date */} {Array.from(chore.name)[0]} {getName(chore.name)} {chore.assignedTo && chore.assignedTo !== userProfile?.id && ( p.userId === chore.assignedTo, )?.image } /> } > { performers.find(p => p.userId === chore.assignedTo) ?.displayName } )} {chore.assignedTo === null && ( }> Anyone )} {chore.priority > 0 && ( p.value === chore.priority)?.icon } onClick={e => { e.stopPropagation() onChipClick({ priority: chore.priority }) }} > P{chore.priority} )} {/* show points chip if there is points assigned */} {chore.points > 0 && ( } > {chore.points} )} {chore.labelsV2?.map((l, index) => { return (
{ e.stopPropagation() onChipClick({ label: l }) }} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation() onChipClick({ label: l }) } }} style={{ display: 'inline-block', cursor: 'pointer' }} // Make the wrapper clickable key={`chorecard-${chore.id}-label-${l.id}`} > { // e.stopPropagation() // onChipClick({ label: l }) // }} // startDecorator={getIconForLabel(label)} > {l?.name}
) })}
{/* {chore.nextDueDate === null ? '--' : 'Due ' + moment(chore.nextDueDate).fromNow()} */}
{/* */} {chore.status === 3 ? ( // Pending approval: Show approve/reject for admins/managers/owners, grayed out for others canApproveReject() ? ( { e.stopPropagation() onAction('approve', chore) }} sx={{ borderRadius: '50%', minWidth: 50, height: 50, zIndex: 1, transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, '&:disabled': { opacity: 0.5, transform: 'none', }, }} > {/* { e.stopPropagation() onAction('reject', chore) }} sx={{ borderRadius: '50%', minWidth: 40, height: 40, zIndex: 1, transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, }} > */} ) : ( ) ) : ( { e.stopPropagation() switch (chore.status) { case 0: // Not started onAction('complete', chore) break case 1: // In progress onAction('pause', chore) break case 2: // Paused onAction('start', chore) break default: break } }} disabled={notInCompletionWindow(chore)} sx={{ borderRadius: '50%', minWidth: 50, height: 50, zIndex: 1, transition: 'all 0.2s ease', '&:hover': { transform: 'scale(1.05)', }, '&:active': { transform: 'scale(0.95)', }, '&:disabled': { opacity: 0.5, transform: 'none', }, }} >
{chore.status === 0 ? ( ) : chore.status === 1 ? ( ) : ( )}
)} onAction('completeWithNote', chore)} onCompleteWithPastDate={() => onAction('completeWithPastDate', chore) } onAction={type => onAction(type, chore)} onChangeAssignee={() => onAction('changeAssignee', chore)} onChangeDueDate={() => onAction('changeDueDate', chore)} onWriteNFC={() => onAction('writeNFC', chore)} onNudge={() => onAction('nudge', chore)} onDelete={() => onAction('delete', chore)} onMouseEnter={handleMouseEnter} onOpen={() => { // Clear any pending hide timer when menu opens if (hoverTimer) { clearTimeout(hoverTimer) setHoverTimer(null) } }} />
) } export default ChoreCard