refactor: update App component to use QueryClient and improve theme handling; enhance ChoreView and SubTask components with performers data

This commit is contained in:
Mo Tarbin
2025-07-09 21:02:55 -04:00
parent 8b8345d0e6
commit baef35d263
10 changed files with 1183 additions and 298 deletions

View File

@@ -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 (
<div className='min-h-screen'>
<NetworkBanner />
<QueryClientProvider client={queryClient}>
<AuthenticationProvider />
<AuthenticationProvider>
<NotificationProvider>
<AppContent />
</NotificationProvider>
</QueryClientProvider>
</AuthenticationProvider>
</div>
)
}
@@ -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

View File

@@ -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(
<React.StrictMode>
<Contexts />
<Contexts queryClient={queryClient}>
<App />
</Contexts>
</React.StrictMode>,
)

View File

@@ -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

View File

@@ -527,6 +527,7 @@ const ChoreView = () => {
>
<SubTasks
editMode={false}
performers={performers}
tasks={chore.subTasks}
setTasks={tasks => {
setChore({

View File

@@ -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 (
<Card
variant='soft'
sx={{
borderRadius: 'md',
boxShadow: 1,
px: 2,
py: 1,
alignItems: 'center',
...(shouldAnimate && {
animation: 'slideInUp 0.3s ease-out',
}),
'@keyframes slideInUp': {
'0%': {
opacity: 0,
transform: 'translateY(20px) scale(0.95)',
},
'100%': {
opacity: 1,
transform: 'translateY(0) scale(1)',
},
},
transition: 'all 0.3s ease',
}}
>
<Typography
level='h4'
sx={{
fontWeight: 600,
pt: 1,
color: chore.status === 1 ? 'success.main' : 'text.primary',
mb: 0.5,
transition: 'all 0.3s ease',
transform: chore.status === 1 ? 'scale(1.40)' : 'scale(1)',
}}
>
{formatTime(time)}
</Typography>
{/* Status and info section */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.5 }}>
{/* <Chip
variant='solid'
color={
chore.status === 1
? 'success'
: chore.status === 2
? 'warning'
: 'neutral'
}
size='sm'
startDecorator={
chore.status === 1 ? (
<PlayArrow sx={{ fontSize: 14 }} />
) : chore.status === 2 ? (
<Pause sx={{ fontSize: 14 }} />
) : (
<AccessTime sx={{ fontSize: 14 }} />
)
}
>
{chore.status === 1
? 'Active'
: chore.status === 2
? 'Paused'
: 'Idle'}
</Chip> */}
{/* Show start time and user if active */}
{chore.status === 1 && chore.timerUpdatedAt && (
<>
{/* Original start time */}
{chore.startTime && (
<Chip
variant='plain'
color='primary'
size='sm'
startDecorator={<Flag sx={{ fontSize: 14 }} />}
>
{'Started '}
{new Date(chore.startTime).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
{/* Current session start time */}
{chore.timerUpdatedAt !== chore.startTime && (
<Chip
variant='plain'
color='neutral'
size='sm'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
{'Session '}
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
)}
</>
)}
{/* Chips FOr paused : */}
{chore.status === 2 && (
<>
<Chip
variant='solid'
color='warning'
size='sm'
startDecorator={<Schedule sx={{ fontSize: 14 }} />}
>
Paused
</Chip>
<Chip
variant='plain'
color='neutral'
size='sm'
startDecorator={<Flag sx={{ fontSize: 14 }} />}
>
{new Date(chore.timerUpdatedAt).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</Chip>
</>
)}
</Box>
</Card>
)
}
export default TimePassedCard

View File

@@ -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={
<KeyboardShortcutHint shortcut='F' show={showKeyboardShortcuts} />
}
endDecorator={
searchTerm && (
<CancelRounded
onClick={() => {
setSearchTerm('')
setFilteredChores(chores)
}}
/>
)
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
{searchTerm && (
<>
<KeyboardShortcutHint
shortcut='X'
show={showKeyboardShortcuts}
/>
<CancelRounded onClick={handleSearchClose} />
</>
)}
</Box>
}
/>
@@ -981,6 +1018,7 @@ const MyChores = () => {
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint shortcut='S' show={showKeyboardShortcuts} />
</Box>
{/* Search Filter with animation */}
@@ -1202,6 +1240,12 @@ const MyChores = () => {
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
}}
endDecorator={
<KeyboardShortcutHint
shortcut='A'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
All
</Button>
@@ -1220,6 +1264,13 @@ const MyChores = () => {
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
}}
endDecorator={
<KeyboardShortcutHint
withCtrl={false}
shortcut='Esc'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
{selectedChores.size === 0 ? 'Close' : 'Clear'}
</Button>
@@ -1252,6 +1303,12 @@ const MyChores = () => {
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
endDecorator={
<KeyboardShortcutHint
shortcut='Enter'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
Complete
</Button>
@@ -1265,6 +1322,12 @@ const MyChores = () => {
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
endDecorator={
<KeyboardShortcutHint
shortcut='/'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
Skip
</Button>
@@ -1278,6 +1341,12 @@ const MyChores = () => {
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
endDecorator={
<KeyboardShortcutHint
shortcut='X'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
Archive
</Button>
@@ -1292,6 +1361,13 @@ const MyChores = () => {
sx={{
'--Button-paddingInline': { xs: '0.75rem', sm: '1rem' },
}}
endDecorator={
<KeyboardShortcutHint
withShift={true}
shortcut='X'
show={showKeyboardShortcuts && selectedChores.size > 0}
/>
}
>
Delete
</Button>
@@ -1473,6 +1549,12 @@ const MyChores = () => {
variant='outlined'
color='neutral'
startDecorator={<Unarchive />}
endDecorator={
<KeyboardShortcutHint
shortcut='A'
show={showKeyboardShortcuts}
/>
}
>
Show Archived
</Button>
@@ -1550,6 +1632,12 @@ const MyChores = () => {
}}
/>
</IconButton>
<KeyboardShortcutHint
sx={{ position: 'relative', left: -40, top: 30 }}
show={showKeyboardShortcuts}
shortcut='K'
/>
</Box>
<NotificationAccessSnackbar />
{addTaskModalOpen && (

View File

@@ -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 = <CalendarViewDay />
// 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 = <Check />
} else if (
historyEntry.dueDate &&
historyEntry.performedAt < historyEntry.dueDate
) {
text = 'On Time'
color = 'success'
icon = <Check />
/**
* Enhanced completion status chip with better logic and visual design
*/
const getCompletedChip = historyEntry => {
if (historyEntry.status === 0) {
return null
}
if (!historyEntry.dueDate) {
return (
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<CalendarViewDay />}
>
No Due Date
</Chip>
)
}
// if completed after due date then it's late
else if (
historyEntry.dueDate &&
historyEntry.performedAt > historyEntry.dueDate
) {
text = 'Late'
color = 'warning'
icon = <Timelapse />
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 (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
return (
<Chip size='sm' variant='soft' color='primary' startDecorator={<Check />}>
Early
</Chip>
)
} else {
text = 'No Due Date'
color = 'neutral'
icon = <CalendarViewDay />
return (
<Chip
size='sm'
variant='solid'
color='warning'
startDecorator={<Timelapse />}
>
Late
</Chip>
)
}
return (
<Chip startDecorator={icon} color={color}>
{text}
</Chip>
)
}
/**
* 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: <AccessTime />, color: 'primary' }, // Started
1: { icon: <Check />, color: 'success' }, // Completed
2: { icon: <Timelapse />, color: 'danger' }, // Skipped
}
const config = statusMap[historyEntry.status] || statusMap[1]
return (
<Avatar
size='sm'
color={config.color}
variant='solid'
sx={{
width: 24,
height: 24,
'& svg': { fontSize: '14px' },
}}
>
{config.icon}
</Avatar>
)
}
return (
<>
<ListItem sx={{ gap: 1.5, alignItems: 'flex-start' }} onClick={onClick}>
{' '}
{/* Adjusted spacing and alignment */}
<ListItemDecorator>
<Avatar sx={{ mr: 1 }}>
{performers
.find(p => p.userId === historyEntry.completedBy)
?.displayName?.charAt(0) || '?'}
</Avatar>
</ListItemDecorator>
<ListItemContent sx={{ my: 0 }}>
{' '}
{/* Removed vertical margin */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography level='body1' sx={{ fontWeight: 'md' }}>
{historyEntry.performedAt
? moment(historyEntry.performedAt).format(
'ddd MM/DD/yyyy HH:mm',
)
: 'Skipped'}
</Typography>
{getCompletedChip(historyEntry)}
</Box>
<Typography level='body2' color='text.tertiary'>
<Chip>
{
performers.find(p => p.userId === historyEntry.completedBy)
?.displayName
<ListItem
onClick={onClick}
sx={{
cursor: onClick ? 'pointer' : 'default',
py: 1.5,
px: 2,
'&:hover': onClick
? {
backgroundColor: 'background.level1',
}
</Chip>{' '}
completed
{historyEntry.completedBy !== historyEntry.assignedTo && (
<>
{', '}
assigned to{' '}
<Chip>
{
performers.find(p => p.userId === historyEntry.assignedTo)
?.displayName
}
: {},
borderRadius: 'sm',
transition: 'background-color 0.2s',
}}
>
<ListItemContent>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
>
{getStatusAvatar()}
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: 'Skipped'}
</Typography>
<Typography
level='body-xs'
sx={{ fontWeight: 'sm', color: 'text.primary' }}
>
{moment(
historyEntry.performedAt || historyEntry.updatedAt,
).format('MMM DD, h:mm A')}
</Typography>
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
</Box>
</Box>
</Grid>
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
>
{historyEntry.dueDate && (
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', whiteSpace: 'nowrap' }}
>
Due: {moment(historyEntry.dueDate).format('MMM DD')}
</Typography>
)}
</Box>
</Grid>
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
<Chip size='sm' variant='outlined' startDecorator={<Person />}>
{performer?.displayName || 'Unknown'}
</Chip>
</>
)}
</Typography>
{historyEntry.dueDate && (
<Typography level='body2' color='text.tertiary'>
Due: {moment(historyEntry.dueDate).format('ddd MM/DD/yyyy')}
</Typography>
)}
{historyEntry.notes && (
<Typography level='body2' color='text.tertiary'>
Note: {historyEntry.notes}
</Typography>
)}
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<>
<Typography
level='body-xs'
sx={{ color: 'text.tertiary' }}
>
</Typography>
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={<Assignment />}
>
{assignedTo.displayName}
</Chip>
</>
)}
{historyEntry.notes && (
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{ maxWidth: '120px', overflow: 'hidden' }}
>
Note
</Chip>
)}
</Box>
</Grid>
</Grid>
</ListItemContent>
</ListItem>
{index < allHistory.length - 1 && (
<>
<ListDivider component='li'>
{/* time between two completion: */}
{index < allHistory.length - 1 &&
allHistory[index + 1].performedAt && (
<Typography level='body3' color='text.tertiary'>
{formatTimeDifference(
historyEntry.performedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
)}
</ListDivider>
</>
{/* Compact Divider with Time Difference */}
{index < allHistory.length - 1 && allHistory[index + 1].performedAt && (
<ListDivider
component='li'
sx={{
my: 0.5,
}}
>
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
backgroundColor: 'background.surface',
px: 1,
fontSize: '0.75rem',
}}
>
{formatTimeDifference(
historyEntry.performedAt || historyEntry.updatedAt,
allHistory[index + 1].performedAt,
)}{' '}
before
</Typography>
</ListDivider>
)}
</>
)

View File

@@ -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 (
<Box key={label.id + '-compact-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={handleMouseLeave}
>
{/* Action buttons underneath (revealed on swipe) */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: maxSwipeDistance,
display: 'flex',
alignItems: 'center',
boxShadow: 'inset 2px 0 4px rgba(0,0,0,0.06)',
zIndex: 0,
}}
onMouseEnter={handleActionAreaMouseEnter}
>
<IconButton
variant='plain'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(label)
}}
sx={{
width: 40,
height: 40,
mx: 1,
bgcolor: 'primary.100',
color: 'primary.600',
'&:hover': {
bgcolor: 'primary.200',
},
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='plain'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(label.id)
}}
sx={{
width: 40,
height: 40,
mx: 1,
bgcolor: 'danger.100',
color: 'danger.600',
'&:hover': {
bgcolor: 'danger.200',
},
}}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</IconButton>
</Box>
{/* Main card content */}
<Box
ref={cardRef}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
cursor: 'pointer',
position: 'relative',
px: 2,
py: 1.5,
bgcolor: 'background.body',
transform: `translateX(${swipeTranslateX}px)`,
transition: isDragging ? 'none' : 'transform 0.3s ease-out',
zIndex: 1,
'&:hover': {
bgcolor: isSwipeRevealed
? 'background.surface'
: 'background.level1',
boxShadow: isSwipeRevealed ? 'none' : 'sm',
},
}}
onClick={() => {
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 */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: label.color,
border: '2px solid',
borderColor: 'background.surface',
boxShadow: 'sm',
}}
>
<Typography
level='body-xs'
sx={{
color: getTextColorFromBackgroundColor(label.color),
fontWeight: 'bold',
fontSize: 10,
}}
>
{label.name.charAt(0).toUpperCase()}
</Typography>
</Avatar>
</Box>
{/* Content - Center */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{/* Label Name */}
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
mb: 0.25,
}}
>
{label.name}
</Typography>
{/* Color Info */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Chip
size='sm'
variant='soft'
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor: `${label.color}20`,
color: label.color,
border: `1px solid ${label.color}30`,
}}
>
{getColorName(label.color)}
</Chip>
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
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 (
<Container maxWidth='md'>
<div className='flex flex-col gap-2'>
{userLabels.map(label => (
<div
key={label}
className='grid w-full grid-cols-[1fr,auto,auto] rounded-lg border border-zinc-200/80 p-4 shadow-sm dark:bg-zinc-900'
<Container maxWidth='md' sx={{ px: 0 }}>
<Box
sx={{
bgcolor: 'background.body',
border: '1px solid',
borderColor: 'divider',
borderRadius: 'md',
overflow: 'hidden',
}}
>
{userLabels.length === 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
height: '50vh',
}}
>
<Chip
variant='outlined'
color='primary'
size='lg'
sx={{
background: label.color,
borderColor: label.color,
color: getTextColorFromBackgroundColor(label.color),
}}
>
{label.name}
</Chip>
<div className='flex gap-2'>
<Button
size='sm'
variant='soft'
color='neutral'
onClick={() => handleEditLabel(label)}
startDecorator={<EditIcon />}
>
Edit
</Button>
<IconButton
size='sm'
variant='soft'
onClick={() => handleDeleteClicked(label.id)}
color='danger'
>
<DeleteIcon />
</IconButton>
</div>
</div>
<Typography level='title-md' gutterBottom>
No labels available. Add a new label to get started.
</Typography>
</Box>
)}
{userLabels.map(label => (
<LabelCard
key={label.id}
label={label}
onEditClick={handleEditLabel}
onDeleteClick={handleDeleteClicked}
/>
))}
</div>
{userLabels.length === 0 && (
<Typography textAlign='center' mt={2}>
No labels available. Add a new label to get started.
</Typography>
)}
</Box>
{modalOpen && (
<LabelModal

View File

@@ -6,11 +6,14 @@ import {
PlusOne,
ToggleOff,
ToggleOn,
TrendingUp,
Widgets,
} from '@mui/icons-material'
import {
Avatar,
Box,
Button,
Card,
Chip,
Container,
Grid,
@@ -38,6 +41,7 @@ const ThingCard = ({
}) => {
const [isDisabled, setIsDisabled] = useState(false)
const Navigate = useNavigate()
const getThingIcon = type => {
if (type === 'text') {
return <Flip />
@@ -54,6 +58,43 @@ const ThingCard = ({
}
}
const getThingAvatar = () => {
const typeConfig = {
text: { color: 'primary', icon: <Flip /> },
number: { color: 'success', icon: <PlusOne /> },
boolean: {
color: thing.state === 'true' ? 'success' : 'neutral',
icon: thing.state === 'true' ? <ToggleOn /> : <ToggleOff />
},
}
const config = typeConfig[thing?.type] || typeConfig.boolean
return (
<Avatar
size='sm'
color={config.color}
variant='solid'
sx={{
width: 28,
height: 28,
'& svg': { fontSize: '16px' },
}}
>
{config.icon}
</Avatar>
)
}
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 (
<Box
className='rounded-lg border border-zinc-200/80 p-4 shadow-sm'
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
p: 2,
const actionProps = getActionButtonProps()
return (
<Card
variant='outlined'
sx={{
mb: 2,
p: 2,
transition: 'all 0.2s ease-in-out',
cursor: 'pointer',
'&:hover': {
borderColor: 'primary.300',
boxShadow: 'sm',
transform: 'translateY(-1px)',
},
}}
onClick={() => Navigate(`/things/${thing?.id}`)}
>
<Grid container alignItems='center'>
<Grid
item
xs={12}
sm={8}
onClick={() => Navigate(`/things/${thing?.id}`)}
>
<Grid container spacing={2} alignItems='center'>
{/* First Row: Thing Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 1.5,
mb: 1,
}}
>
{getThingAvatar()}
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
level='title-md'
sx={{
fontWeight: 'lg',
color: 'text.primary',
mb: 0.5,
}}
>
{thing?.name}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Chip
size='sm'
variant='soft'
color='neutral'
>
{thing?.type}
</Chip>
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
</Typography>
<Typography level='body-xs' sx={{ color: 'text.secondary' }}>
Current state:
</Typography>
<Chip
size='sm'
variant='solid'
color={thing?.type === 'boolean' && thing?.state === 'true' ? 'success' : 'primary'}
sx={{ fontWeight: 'md' }}
>
{thing?.state}
</Chip>
</Box>
</Box>
</Box>
</Grid>
{/* Second Row: Action Buttons */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
cursor: 'pointer',
}}
onClick={() => Navigate(`/things/${thing?.id}`)}
onClick={(e) => e.stopPropagation()}
>
<Typography level='title-lg'>{thing?.name}</Typography>
<Chip
<Button
variant='solid'
color={actionProps.color}
size='sm'
sx={{
ml: 1,
onClick={() => {
if (thing?.type === 'text') {
onEditClick(thing)
} else {
handleRequestChange(thing)
}
}}
disabled={isDisabled}
startDecorator={getThingIcon(thing?.type)}
sx={{
minWidth: '80px',
fontWeight: 'md',
}}
>
{thing?.type}
</Chip>
</Box>
State: <Chip size='md'>{thing?.state}</Chip>
</Grid>
<Grid
item
xs={12}
sm={4}
container
justifyContent='flex-end'
alignItems='center'
>
<Button
variant='soft'
color='success'
onClick={() => {
if (thing?.type === 'text') {
{actionProps.text}
</Button>
<IconButton
variant='outlined'
color='neutral'
size='sm'
onClick={(e) => {
e.stopPropagation()
onEditClick(thing)
} else {
handleRequestChange(thing)
}
}}
disabled={isDisabled}
startDecorator={getThingIcon(thing?.type)}
>
{thing?.type === 'text'
? 'Change'
: thing?.type === 'number'
? 'Increment'
: 'Toggle'}
</Button>
<IconButton
color='primary'
onClick={() => onEditClick(thing)}
sx={{
borderRadius: '50%',
width: 30,
height: 30,
ml: 1,
transition: 'background-color 0.2s',
'&:hover': { backgroundColor: 'action.hover' },
}}
>
<Edit />
</IconButton>
<IconButton
color='danger'
onClick={() => onDeleteClick(thing)}
sx={{
borderRadius: '50%',
width: 30,
height: 30,
ml: 1,
}}
>
<Delete fontSize='small' />
</IconButton>
}}
sx={{
borderRadius: '50%',
width: 32,
height: 32,
transition: 'all 0.2s',
'&:hover': {
backgroundColor: 'primary.softBg',
borderColor: 'primary.300',
},
}}
>
<Edit fontSize='small' />
</IconButton>
<IconButton
variant='outlined'
color='danger'
size='sm'
onClick={(e) => {
e.stopPropagation()
onDeleteClick(thing)
}}
sx={{
borderRadius: '50%',
width: 32,
height: 32,
transition: 'all 0.2s',
'&:hover': {
backgroundColor: 'danger.softBg',
borderColor: 'danger.300',
},
}}
>
<Delete fontSize='small' />
</IconButton>
</Box>
</Grid>
</Grid>
</Box>
</Card>
)
}

View File

@@ -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) ? (
<Chip>
{
performers.find(p => p.userId === task.completedBy)
.displayName
}
</Chip>
) : null}
</Typography>
)}
</Box>
@@ -281,6 +293,7 @@ function SortableItem({
setTasks={setTasks}
level={level + 1}
editMode={editMode}
performers={performers}
/>
))}
</Box>
@@ -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 && (