From baef35d2631549a088bf17c61763fe698ce192fc Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Wed, 9 Jul 2025 21:02:55 -0400 Subject: [PATCH] refactor: update App component to use QueryClient and improve theme handling; enhance ChoreView and SubTask components with performers data --- src/App.jsx | 23 +- src/main.jsx | 8 +- src/queries/ChoreQueries.jsx | 2 +- src/views/ChoreEdit/ChoreView.jsx | 1 + src/views/ChoreEdit/TimePassedCard.jsx | 207 ++++++++++++ src/views/Chores/MyChores.jsx | 164 +++++++--- src/views/History/HistoryCard.jsx | 355 +++++++++++++------- src/views/Labels/LabelView.jsx | 434 ++++++++++++++++++++++--- src/views/Things/ThingsView.jsx | 256 ++++++++++----- src/views/components/SubTask.jsx | 31 +- 10 files changed, 1183 insertions(+), 298 deletions(-) create mode 100644 src/views/ChoreEdit/TimePassedCard.jsx diff --git a/src/App.jsx b/src/App.jsx index 314097a..665fd18 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,13 +1,11 @@ import NavBar from '@/views/components/NavBar' import { Button, Typography, useColorScheme } from '@mui/joy' import Tracker from '@openreplay/tracker' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useEffect } from 'react' +import { useCallback, useEffect } from 'react' import { Outlet, useNavigate } from 'react-router-dom' import { useRegisterSW } from 'virtual:pwa-register/react' import { registerCapacitorListeners } from './CapacitorListener' import { ImpersonateUserProvider } from './contexts/ImpersonateUserContext' -import { useResource } from './queries/ResourceQueries' import { AuthenticationProvider } from './service/AuthenticationService' import { NotificationProvider, @@ -15,6 +13,7 @@ import { } from './service/NotificationProvider' import { apiManager } from './utils/TokenManager' import NetworkBanner from './views/components/NetworkBanner' + const add = className => { document.getElementById('root').classList.add(className) } @@ -22,9 +21,9 @@ const add = className => { const remove = className => { document.getElementById('root').classList.remove(className) } + // TODO: Update the interval to at 60 minutes const intervalMS = 5 * 60 * 1000 // 5 minutes -const queryClient = new QueryClient({}) const AppContent = () => { const { showNotification } = useNotification() @@ -85,14 +84,13 @@ const AppContent = () => { } function App() { - const resource = useResource() const navigate = useNavigate() startApiManager(navigate) startOpenReplay() const { mode, systemMode } = useColorScheme() - const setThemeClass = () => { + const setThemeClass = useCallback(() => { const value = JSON.parse(localStorage.getItem('themeMode')) || mode if (value === 'system') { @@ -107,11 +105,11 @@ function App() { } return remove('dark') - } + }, [mode, systemMode]) useEffect(() => { setThemeClass() - }, [mode, systemMode]) + }, [setThemeClass]) useEffect(() => { registerCapacitorListeners() @@ -120,13 +118,11 @@ function App() { return (
- - - + - +
) } @@ -139,7 +135,6 @@ const startOpenReplay = () => { tracker.start() } -export default App const startApiManager = navigate => { apiManager.init() @@ -147,3 +142,5 @@ const startApiManager = navigate => { navigate('/login') }) } + +export default App diff --git a/src/main.jsx b/src/main.jsx index 4e6fcdd..baf76fe 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,16 @@ import React from 'react' import ReactDOM from 'react-dom/client' +import { QueryClient } from '@tanstack/react-query' +import App from './App.jsx' import Contexts from './contexts/Contexts.jsx' import './index.css' +const queryClient = new QueryClient({}) + ReactDOM.createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/src/queries/ChoreQueries.jsx b/src/queries/ChoreQueries.jsx index bdf9c71..be7310f 100644 --- a/src/queries/ChoreQueries.jsx +++ b/src/queries/ChoreQueries.jsx @@ -178,7 +178,7 @@ export const useChoresHistory = (initialLimit, includeMembers) => { export const useChoreDetails = choreId => { return useQuery({ - queryKey: ['chore', choreId], + queryKey: ['choreDetails', choreId], queryFn: async () => { var onlineChore = null diff --git a/src/views/ChoreEdit/ChoreView.jsx b/src/views/ChoreEdit/ChoreView.jsx index 52dda97..9292126 100644 --- a/src/views/ChoreEdit/ChoreView.jsx +++ b/src/views/ChoreEdit/ChoreView.jsx @@ -527,6 +527,7 @@ const ChoreView = () => { > { setChore({ diff --git a/src/views/ChoreEdit/TimePassedCard.jsx b/src/views/ChoreEdit/TimePassedCard.jsx new file mode 100644 index 0000000..5799166 --- /dev/null +++ b/src/views/ChoreEdit/TimePassedCard.jsx @@ -0,0 +1,207 @@ +import { Flag, Schedule } from '@mui/icons-material' +import { Box, Card, Chip, Typography } from '@mui/joy' +import { useEffect, useRef, useState } from 'react' + +const TimePassedCard = ({ chore }) => { + const [time, setTime] = useState(0) + const [shouldAnimate, setShouldAnimate] = useState(false) + const [prevStatus, setPrevStatus] = useState(null) // Initialize as null + const intervalRef = useRef(null) + + // Track status changes to trigger animation + useEffect(() => { + // Only trigger animation if we have a previous status and it changed from 0 to 1 + if (prevStatus !== null && prevStatus === 0 && chore.status === 1) { + setShouldAnimate(true) + // Reset animation after it completes + const timer = setTimeout(() => setShouldAnimate(false), 300) + return () => clearTimeout(timer) + } + setPrevStatus(chore.status) + }, [chore.status, prevStatus]) + + // Single effect to handle both time calculation and timer + useEffect(() => { + // Calculate current time based on chore data + const calculateCurrentTime = () => { + if (chore.timerUpdatedAt && chore.status === 1) { + // Active session: base duration + time since start + return ( + Math.floor( + (Date.now() - new Date(chore.timerUpdatedAt).getTime()) / 1000, + ) + (chore.duration || 0) + ) + } + // Not active: just return accumulated duration + return chore.duration || 0 + } + + // Set initial time + const currentTime = calculateCurrentTime() + setTime(currentTime) + + // Handle timer based on status + if (chore.status === 1) { + // Active: start interval timer + intervalRef.current = setInterval(() => { + setTime(calculateCurrentTime()) + }, 1000) + } else { + // Not active: clear any existing timer + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + + // Cleanup function + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current) + intervalRef.current = null + } + } + }, [chore.status, chore.timerUpdatedAt, chore.duration]) + + const formatTime = seconds => { + const hours = Math.floor(seconds / 3600) + const minutes = Math.floor((seconds % 3600) / 60) + const secs = seconds % 60 + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + return ( + + + {formatTime(time)} + + + {/* Status and info section */} + + {/* + ) : chore.status === 2 ? ( + + ) : ( + + ) + } + > + {chore.status === 1 + ? 'Active' + : chore.status === 2 + ? 'Paused' + : 'Idle'} + */} + + {/* Show start time and user if active */} + {chore.status === 1 && chore.timerUpdatedAt && ( + <> + {/* Original start time */} + {chore.startTime && ( + } + > + {'Started '} + {new Date(chore.startTime).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + + {/* Current session start time */} + {chore.timerUpdatedAt !== chore.startTime && ( + } + > + {'Session '} + {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + )} + + )} + + {/* Chips FOr paused : */} + {chore.status === 2 && ( + <> + } + > + Paused + + } + > + {new Date(chore.timerUpdatedAt).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + })} + + + )} + + + ) +} + +export default TimePassedCard diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index bf82031..345349f 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -51,15 +51,13 @@ import CompactChoreCard from './CompactChoreCard' import IconButtonWithMenu from './IconButtonWithMenu' import MultiSelectHelp from './MultiSelectHelp' +import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores' import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher' import TaskInput from '../components/AddTaskModal' -import { - canScheduleNotification, - scheduleChoreNotification, -} from './LocalNotificationScheduler' +import { canScheduleNotification } from './LocalNotificationScheduler' import NotificationAccessSnackbar from './NotificationAccessSnackbar' import Sidepanel from './Sidepanel' import SortAndGrouping from './SortAndGrouping' @@ -102,40 +100,50 @@ const MyChores = () => { data: choresData, isLoading: choresLoading, refetch: refetchChores, - } = useChores() + } = useChores(false) const { data: membersData, isLoading: membersLoading } = useCircleMembers() // Multi-select state const [isMultiSelectMode, setIsMultiSelectMode] = useState(false) const [selectedChores, setSelectedChores] = useState(new Set()) const [confirmModelConfig, setConfirmModelConfig] = useState({}) - + const [showKeyboardShortcuts, setShowKeyboardShortcuts] = useState(false) useEffect(() => { - if (!choresLoading && !membersLoading && userProfile) { - setPerformers(membersData.res) - const sortedChores = choresData.res.sort(ChoreSorter) - setChores(sortedChores) - setFilteredChores(sortedChores) - const sections = ChoresGrouper( - selectedChoreSection, - sortedChores, - ChoreFilters(userProfile)[selectedChoreFilter], - ) - setChoreSections(sections) - if (localStorage.getItem('openChoreSections') === null) { - setSelectedChoreSectionWithCache(selectedChoreSection) - setOpenChoreSections( - Object.keys(sections).reduce((acc, key) => { - acc[key] = true - return acc - }, {}), + ;(async () => { + if (!choresLoading && !membersLoading && userProfile) { + setPerformers(membersData.res) + const sortedChores = choresData.res.sort(ChoreSorter) + setChores(sortedChores) + setFilteredChores(sortedChores) + const sections = ChoresGrouper( + selectedChoreSection, + sortedChores, + ChoreFilters(userProfile)[selectedChoreFilter], + ) + setChoreSections(sections) + if (localStorage.getItem('openChoreSections') === null) { + setSelectedChoreSectionWithCache(selectedChoreSection) + setOpenChoreSections( + Object.keys(sections).reduce((acc, key) => { + acc[key] = true + return acc + }, {}), + ) + } + console.log( + 'Checking if can schedule notification', + canScheduleNotification(), ) - } - if (canScheduleNotification()) { - scheduleChoreNotification(choresData.res, userProfile, membersData.res) + if (await canScheduleNotification()) { + // scheduleChoreNotification( + // choresData.res, + // userProfile, + // membersData.res, + // ) + } } - } + })() }, [ membersLoading, choresLoading, @@ -164,6 +172,11 @@ const MyChores = () => { // Keyboard shortcuts for multi-select and other actions useEffect(() => { const handleKeyDown = event => { + // if Ctrl/Cmd + / then show keyboard shortcuts modal + if (event.ctrlKey || event.metaKey) { + setShowKeyboardShortcuts(true) + } + // Ctrl/Cmd + K to open task modal if ((event.ctrlKey || event.metaKey) && event.key === 'k') { event.preventDefault() @@ -176,8 +189,13 @@ const MyChores = () => { event.preventDefault() searchInputRef.current?.focus() return + // Ctrl/Cmd + X to close search input + } else if ((event.ctrlKey || event.metaKey) && event.key === 'x') { + event.preventDefault() + if (searchTerm?.length > 0) { + handleSearchClose() + } } - // Ctrl/Cmd + S Toggle Multi-select mode else if ((event.ctrlKey || event.metaKey) && event.key === 's') { event.preventDefault() @@ -299,10 +317,17 @@ const MyChores = () => { } } } + const handleKeyUp = event => { + if (!event.ctrlKey && !event.metaKey) { + setShowKeyboardShortcuts(false) + } + } document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) return () => { document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) } }, [isMultiSelectMode, selectedChores.size]) const setSelectedChoreSectionWithCache = value => { @@ -506,7 +531,7 @@ const MyChores = () => { const fuse = new Fuse( chores.map(c => ({ ...c, - raw_label: c.labelsV2.map(c => c.name).join(' '), + raw_label: c.labelsV2?.map(c => c.name).join(' '), })), searchOptions, ) @@ -526,6 +551,12 @@ const MyChores = () => { setSearchTerm(term) setFilteredChores(fuse.search(term).map(result => result.item)) } + const handleSearchClose = () => { + setSearchTerm('') + setFilteredChores(chores) + // remove the focus from the search input: + setSearchInputFocus(0) + } // Multi-select helper functions const toggleMultiSelectMode = () => { @@ -870,15 +901,21 @@ const MyChores = () => { padding: 1, }} onChange={handleSearchChange} + startDecorator={ + + } endDecorator={ - searchTerm && ( - { - setSearchTerm('') - setFilteredChores(chores) - }} - /> - ) + + {searchTerm && ( + <> + + + + )} + } /> @@ -981,6 +1018,7 @@ const MyChores = () => { > {isMultiSelectMode ? : } + {/* Search Filter with animation */} @@ -1202,6 +1240,12 @@ const MyChores = () => { minWidth: 'auto', '--Button-paddingInline': '0.75rem', }} + endDecorator={ + 0} + /> + } > All @@ -1220,6 +1264,13 @@ const MyChores = () => { minWidth: 'auto', '--Button-paddingInline': '0.75rem', }} + endDecorator={ + 0} + /> + } > {selectedChores.size === 0 ? 'Close' : 'Clear'} @@ -1252,6 +1303,12 @@ const MyChores = () => { sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, }} + endDecorator={ + 0} + /> + } > Complete @@ -1265,6 +1322,12 @@ const MyChores = () => { sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, }} + endDecorator={ + 0} + /> + } > Skip @@ -1278,6 +1341,12 @@ const MyChores = () => { sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, }} + endDecorator={ + 0} + /> + } > Archive @@ -1292,6 +1361,13 @@ const MyChores = () => { sx={{ '--Button-paddingInline': { xs: '0.75rem', sm: '1rem' }, }} + endDecorator={ + 0} + /> + } > Delete @@ -1473,6 +1549,12 @@ const MyChores = () => { variant='outlined' color='neutral' startDecorator={} + endDecorator={ + + } > Show Archived @@ -1550,6 +1632,12 @@ const MyChores = () => { }} /> + + {addTaskModalOpen && ( diff --git a/src/views/History/HistoryCard.jsx b/src/views/History/HistoryCard.jsx index 5203850..53bb7b9 100644 --- a/src/views/History/HistoryCard.jsx +++ b/src/views/History/HistoryCard.jsx @@ -1,59 +1,82 @@ -import { CalendarViewDay, Check, Timelapse } from '@mui/icons-material' +import { + AccessTime, + Assignment, + CalendarViewDay, + Check, + EventNote, + Person, + Timelapse, +} from '@mui/icons-material' import { Avatar, Box, Chip, + Grid, ListDivider, ListItem, ListItemContent, - ListItemDecorator, Typography, } from '@mui/joy' import moment from 'moment' -export const getCompletedChip = historyEntry => { - var text = 'No Due Date' - var color = 'info' - var icon = - // if completed few hours +-6 hours - if ( - historyEntry.dueDate && - historyEntry.performedAt > historyEntry.dueDate - 1000 * 60 * 60 * 6 && - historyEntry.performedAt < historyEntry.dueDate + 1000 * 60 * 60 * 6 - ) { - text = 'On Time' - color = 'success' - icon = - } else if ( - historyEntry.dueDate && - historyEntry.performedAt < historyEntry.dueDate - ) { - text = 'On Time' - color = 'success' - icon = +/** + * Enhanced completion status chip with better logic and visual design + */ +const getCompletedChip = historyEntry => { + if (historyEntry.status === 0) { + return null + } + if (!historyEntry.dueDate) { + return ( + } + > + No Due Date + + ) } - // if completed after due date then it's late - else if ( - historyEntry.dueDate && - historyEntry.performedAt > historyEntry.dueDate - ) { - text = 'Late' - color = 'warning' - icon = + const performedAt = moment(historyEntry.performedAt) + const dueDate = moment(historyEntry.dueDate) + const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds + + if (Math.abs(performedAt - dueDate) <= gracePeriod) { + return ( + } + > + On Time + + ) + } else if (performedAt.isBefore(dueDate)) { + return ( + }> + Early + + ) } else { - text = 'No Due Date' - color = 'neutral' - icon = + return ( + } + > + Late + + ) } - - return ( - - {text} - - ) } +/** + * Compact HistoryCard component with improved UX and 2-row height design + */ const HistoryCard = ({ allHistory, performers, @@ -61,7 +84,10 @@ const HistoryCard = ({ index, onClick, }) => { - function formatTimeDifference(startDate, endDate) { + const performer = performers.find(p => p.userId === historyEntry.completedBy) + const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo) + + const formatTimeDifference = (startDate, endDate) => { const diffInMinutes = moment(startDate).diff(endDate, 'minutes') let timeValue = diffInMinutes let unit = 'minute' @@ -81,86 +107,187 @@ const HistoryCard = ({ return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}` } + const getStatusAvatar = () => { + const statusMap = { + 0: { icon: , color: 'primary' }, // Started + 1: { icon: , color: 'success' }, // Completed + 2: { icon: , color: 'danger' }, // Skipped + } + + const config = statusMap[historyEntry.status] || statusMap[1] + return ( + + {config.icon} + + ) + } + return ( <> - - {' '} - {/* Adjusted spacing and alignment */} - - - {performers - .find(p => p.userId === historyEntry.completedBy) - ?.displayName?.charAt(0) || '?'} - - - - {' '} - {/* Removed vertical margin */} - - - {historyEntry.performedAt - ? moment(historyEntry.performedAt).format( - 'ddd MM/DD/yyyy HH:mm', - ) - : 'Skipped'} - - {getCompletedChip(historyEntry)} - - - - { - performers.find(p => p.userId === historyEntry.completedBy) - ?.displayName + {' '} - completed - {historyEntry.completedBy !== historyEntry.assignedTo && ( - <> - {', '} - assigned to{' '} - - { - performers.find(p => p.userId === historyEntry.assignedTo) - ?.displayName - } + : {}, + borderRadius: 'sm', + transition: 'background-color 0.2s', + }} + > + + + {/* First Row/Column: Status and Time Info */} + + + {getStatusAvatar()} + + + {historyEntry.status === 0 + ? 'In Progress' + : historyEntry.status === 1 + ? 'Completed' + : 'Skipped'} + + + + {moment( + historyEntry.performedAt || historyEntry.updatedAt, + ).format('MMM DD, h:mm A')} + + + + {getCompletedChip(historyEntry)} + + + + + {/* Second Row/Column: Completion Status (right side on desktop) */} + + + {historyEntry.dueDate && ( + + Due: {moment(historyEntry.dueDate).format('MMM DD')} + + )} + + + + {/* Third Row: Performer and Assignment Info */} + + + }> + {performer?.displayName || 'Unknown'} - - )} - - {historyEntry.dueDate && ( - - Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')} - - )} - {historyEntry.notes && ( - - Note: {historyEntry.notes} - - )} + + {historyEntry.completedBy !== historyEntry.assignedTo && + assignedTo && ( + <> + + → + + } + > + {assignedTo.displayName} + + + )} + + {historyEntry.notes && ( + } + sx={{ maxWidth: '120px', overflow: 'hidden' }} + > + Note + + )} + + + - {index < allHistory.length - 1 && ( - <> - - {/* time between two completion: */} - {index < allHistory.length - 1 && - allHistory[index + 1].performedAt && ( - - {formatTimeDifference( - historyEntry.performedAt, - allHistory[index + 1].performedAt, - )}{' '} - before - - )} - - + + {/* Compact Divider with Time Difference */} + {index < allHistory.length - 1 && allHistory[index + 1].performedAt && ( + + + {formatTimeDifference( + historyEntry.performedAt || historyEntry.updatedAt, + allHistory[index + 1].performedAt, + )}{' '} + before + + )} ) diff --git a/src/views/Labels/LabelView.jsx b/src/views/Labels/LabelView.jsx index 81c9b47..10d8278 100644 --- a/src/views/Labels/LabelView.jsx +++ b/src/views/Labels/LabelView.jsx @@ -1,25 +1,375 @@ import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' import { + Avatar, Box, - Button, Chip, CircularProgress, Container, IconButton, Typography, } from '@mui/joy' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import LabelModal from '../Modals/Inputs/LabelModal' // import { useMutation, useQueryClient } from '@tanstack/react-query' import { Add } from '@mui/icons-material' import { useQueryClient } from '@tanstack/react-query' import { getTextColorFromBackgroundColor } from '../../utils/Colors' +import LABEL_COLORS from '../../utils/Colors' import { DeleteLabel } from '../../utils/Fetcher' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' import { useLabels } from './LabelQueries' +const LabelCard = ({ label, onEditClick, onDeleteClick }) => { + // Helper function to get color name from hex value + const getColorName = hexValue => { + const colorObj = LABEL_COLORS.find( + color => color.value.toLowerCase() === hexValue.toLowerCase(), + ) + return colorObj ? colorObj.name : hexValue + } + + // Swipe functionality state + const [swipeTranslateX, setSwipeTranslateX] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const [isSwipeRevealed, setIsSwipeRevealed] = useState(false) + const [hoverTimer, setHoverTimer] = useState(null) + const swipeThreshold = 80 + const maxSwipeDistance = 160 + const dragStartX = useRef(0) + const cardRef = useRef(null) + + // Swipe gesture handlers + const handleTouchStart = e => { + dragStartX.current = e.touches[0].clientX + setIsDragging(true) + } + + const handleTouchMove = e => { + if (!isDragging) return + + const currentX = e.touches[0].clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleTouchEnd = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + if (Math.abs(swipeTranslateX) > swipeThreshold) { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + } else { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } + } + } + + const handleMouseDown = e => { + dragStartX.current = e.clientX + setIsDragging(true) + } + + const handleMouseMove = e => { + if (!isDragging) return + + const currentX = e.clientX + const deltaX = currentX - dragStartX.current + + if (isSwipeRevealed) { + if (deltaX > 0) { + const clampedDelta = Math.min(deltaX - maxSwipeDistance, 0) + setSwipeTranslateX(clampedDelta) + } + } else { + if (deltaX < 0) { + const clampedDelta = Math.max(deltaX, -maxSwipeDistance) + setSwipeTranslateX(clampedDelta) + } + } + } + + const handleMouseUp = () => { + if (!isDragging) return + setIsDragging(false) + + if (isSwipeRevealed) { + if (swipeTranslateX > -swipeThreshold) { + setSwipeTranslateX(0) + setIsSwipeRevealed(false) + } else { + setSwipeTranslateX(-maxSwipeDistance) + } + } else { + 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 (isSwipeRevealed) return + const timer = setTimeout(() => { + setSwipeTranslateX(-maxSwipeDistance) + setIsSwipeRevealed(true) + setHoverTimer(null) + }, 1500) + setHoverTimer(timer) + } + + const handleMouseLeave = () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + if (isSwipeRevealed) { + resetSwipe() + } + } + + const handleActionAreaMouseEnter = () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + setHoverTimer(null) + } + } + + // Clean up timer on unmount + useEffect(() => { + return () => { + if (hoverTimer) { + clearTimeout(hoverTimer) + } + } + }, [hoverTimer]) + + return ( + + + {/* Action buttons underneath (revealed on swipe) */} + + { + e.stopPropagation() + resetSwipe() + onEditClick(label) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + bgcolor: 'primary.100', + color: 'primary.600', + '&:hover': { + bgcolor: 'primary.200', + }, + }} + > + + + + { + e.stopPropagation() + resetSwipe() + onDeleteClick(label.id) + }} + sx={{ + width: 40, + height: 40, + mx: 1, + bgcolor: 'danger.100', + color: 'danger.600', + '&:hover': { + bgcolor: 'danger.200', + }, + }} + > + + + + + {/* Main card content */} + { + if (isSwipeRevealed) { + resetSwipe() + return + } + // Optional: Navigate to label details or edit directly + onEditClick(label) + }} + onTouchStart={handleTouchStart} + onTouchMove={handleTouchMove} + onTouchEnd={handleTouchEnd} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseEnter={handleMouseEnter} + > + {/* Color Avatar */} + + + + {label.name.charAt(0).toUpperCase()} + + + + + {/* Content - Center */} + + {/* Label Name */} + + {label.name} + + + {/* Color Info */} + + + {getColorName(label.color)} + + + + + + + ) +} + const LabelView = () => { const { data: labels, isLabelsLoading, isError } = useLabels() @@ -61,7 +411,7 @@ const LabelView = () => { } const handleDeleteLabel = id => { - DeleteLabel(id).then(res => { + DeleteLabel(id).then(() => { const updatedLabels = userLabels.filter(label => label.id !== id) setUserLabels(updatedLabels) @@ -106,54 +456,40 @@ const LabelView = () => { } return ( - -
- {userLabels.map(label => ( -
+ + {userLabels.length === 0 && ( + - - {label.name} - - -
- - handleDeleteClicked(label.id)} - color='danger' - > - - -
-
+ + No labels available. Add a new label to get started. + + + )} + {userLabels.map(label => ( + ))} -
- - {userLabels.length === 0 && ( - - No labels available. Add a new label to get started. - - )} + {modalOpen && ( { const [isDisabled, setIsDisabled] = useState(false) const Navigate = useNavigate() + const getThingIcon = type => { if (type === 'text') { return @@ -54,6 +58,43 @@ const ThingCard = ({ } } + const getThingAvatar = () => { + const typeConfig = { + text: { color: 'primary', icon: }, + number: { color: 'success', icon: }, + boolean: { + color: thing.state === 'true' ? 'success' : 'neutral', + icon: thing.state === 'true' ? : + }, + } + + const config = typeConfig[thing?.type] || typeConfig.boolean + return ( + + {config.icon} + + ) + } + + const getActionButtonProps = () => { + const buttonConfig = { + text: { text: 'Change', color: 'primary' }, + number: { text: 'Increment', color: 'success' }, + boolean: { text: 'Toggle', color: 'warning' }, + } + + return buttonConfig[thing?.type] || buttonConfig.boolean + } + const handleRequestChange = thing => { setIsDisabled(true) onStateChangeRequest(thing) @@ -62,103 +103,158 @@ const ThingCard = ({ }, 2000) } - return ( - Navigate(`/things/${thing?.id}`)} > - - Navigate(`/things/${thing?.id}`)} - > + + {/* First Row: Thing Info */} + + {getThingAvatar()} + + + + {thing?.name} + + + + + {thing?.type} + + + + • + + + + Current state: + + + + {thing?.state} + + + + + + + {/* Second Row: Action Buttons */} + + Navigate(`/things/${thing?.id}`)} + onClick={(e) => e.stopPropagation()} > - {thing?.name} - { + if (thing?.type === 'text') { + onEditClick(thing) + } else { + handleRequestChange(thing) + } + }} + disabled={isDisabled} + startDecorator={getThingIcon(thing?.type)} + sx={{ + minWidth: '80px', + fontWeight: 'md', }} > - {thing?.type} - - - State: {thing?.state} - - - + + { + e.stopPropagation() onEditClick(thing) - } else { - handleRequestChange(thing) - } - }} - disabled={isDisabled} - startDecorator={getThingIcon(thing?.type)} - > - {thing?.type === 'text' - ? 'Change' - : thing?.type === 'number' - ? 'Increment' - : 'Toggle'} - - onEditClick(thing)} - sx={{ - borderRadius: '50%', - width: 30, - height: 30, - ml: 1, - transition: 'background-color 0.2s', - '&:hover': { backgroundColor: 'action.hover' }, - }} - > - - - onDeleteClick(thing)} - sx={{ - borderRadius: '50%', - width: 30, - height: 30, - ml: 1, - }} - > - - + }} + sx={{ + borderRadius: '50%', + width: 32, + height: 32, + transition: 'all 0.2s', + '&:hover': { + backgroundColor: 'primary.softBg', + borderColor: 'primary.300', + }, + }} + > + + + + { + e.stopPropagation() + onDeleteClick(thing) + }} + sx={{ + borderRadius: '50%', + width: 32, + height: 32, + transition: 'all 0.2s', + '&:hover': { + backgroundColor: 'danger.softBg', + borderColor: 'danger.300', + }, + }} + > + + + - + ) } diff --git a/src/views/components/SubTask.jsx b/src/views/components/SubTask.jsx index ae214bf..9bf13ec 100644 --- a/src/views/components/SubTask.jsx +++ b/src/views/components/SubTask.jsx @@ -24,6 +24,7 @@ import { import { Box, Checkbox, + Chip, IconButton, Input, List, @@ -31,6 +32,7 @@ import { Typography, } from '@mui/joy' import { useState } from 'react' +import { useUserProfile } from '../../queries/UserQueries' import { CompleteSubTask } from '../../utils/Fetcher' function SortableItem({ @@ -43,10 +45,12 @@ function SortableItem({ setTasks, level = 0, editMode, + performers = [], }) { const { attributes, listeners, setNodeRef, transform, transition } = useSortable({ id: task.id, + data: { completedAt: task.completedAt, completedBy: task.completedBy }, // Add touch sensor options for better mobile scrolling options: { activationConstraint: { @@ -206,6 +210,14 @@ function SortableItem({ }} > {new Date(task.completedAt).toLocaleString()} + {performers.find(p => p.userId === task.completedBy) ? ( + + { + performers.find(p => p.userId === task.completedBy) + .displayName + } + + ) : null} )} @@ -281,6 +293,7 @@ function SortableItem({ setTasks={setTasks} level={level + 1} editMode={editMode} + performers={performers} /> ))} @@ -289,8 +302,15 @@ function SortableItem({ ) } -const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => { +const SubTasks = ({ + editMode = true, + choreId = 0, + tasks = [], + setTasks, + performers, +}) => { const [newTask, setNewTask] = useState('') + const { data: userProfile } = useUserProfile() const topLevelTasks = tasks.filter(task => task.parentId === null) @@ -313,7 +333,13 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => { // Update the task const updatedTasks = tasks.map(task => - task.id === taskId ? { ...task, completedAt: newCompletedAt } : task, + task.id === taskId + ? { + ...task, + completedAt: newCompletedAt, + completedBy: userProfile?.id, + } + : task, ) // If completing a task, also complete all child tasks @@ -469,6 +495,7 @@ const SubTasks = ({ editMode = true, choreId = 0, tasks = [], setTasks }) => { allTasks={tasks} setTasks={setTasks} editMode={editMode} + performers={performers} /> ))} {editMode && (