Add BottomSheetModal component and implement calendar view in MyChores

This commit is contained in:
Mo Tarbin
2025-08-21 00:34:00 -04:00
parent 71d5887bcc
commit 0102ce2144
4 changed files with 426 additions and 77 deletions

View File

@@ -0,0 +1,224 @@
import { Close } from '@mui/icons-material'
import { IconButton, Modal, Sheet, Typography } from '@mui/joy'
import { forwardRef, useEffect, useState } from 'react'
import { Z_INDEX } from '../../constants/zIndex'
const BottomSheetModal = forwardRef(
(
{
open,
onClose,
children,
title,
height = 'auto',
maxHeight = '90vh',
expandedHeight = '95vh',
backdropBlur = true,
showHandle = true,
showCloseButton = true,
...props
},
ref,
) => {
const [isExpanded, setIsExpanded] = useState(false)
const [isClosing, setIsClosing] = useState(false)
const [internalOpen, setInternalOpen] = useState(open)
// Handle opening
useEffect(() => {
if (open) {
setInternalOpen(true)
setIsClosing(false)
}
}, [open])
// Handle closing with animation
useEffect(() => {
if (!open && internalOpen) {
setIsClosing(true)
// Wait for animation to complete before hiding modal
const timer = setTimeout(() => {
setInternalOpen(false)
setIsClosing(false)
setIsExpanded(false)
}, 250) // Match transition duration
return () => clearTimeout(timer)
}
}, [open, internalOpen])
// Handle toggle expansion
const handleToggleExpansion = () => {
setIsExpanded(prev => !prev)
}
// Close on escape key
useEffect(() => {
const handleEscape = event => {
if (event.key === 'Escape' && internalOpen) {
onClose?.()
}
}
if (internalOpen) {
document.addEventListener('keydown', handleEscape)
// Prevent body scroll when modal is open
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEscape)
document.body.style.overflow = 'unset'
}
}, [internalOpen, onClose])
// Calculate current height
const currentHeight = isExpanded ? expandedHeight : height
return (
<Modal
open={internalOpen}
onClose={onClose}
sx={{
'& .MuiModal-backdrop': {
backdropFilter: backdropBlur ? 'blur(3px)' : 'none',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
},
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
}}
keepMounted
{...props}
>
<Sheet
ref={ref}
sx={{
zIndex: Z_INDEX.MODAL_CONTENT,
width: '100%',
height: currentHeight,
maxHeight: isExpanded ? expandedHeight : maxHeight,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
p: 0,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
transition:
'height 0.3s cubic-bezier(0.32, 0.72, 0, 1), max-height 0.3s cubic-bezier(0.32, 0.72, 0, 1), transform 0.3s cubic-bezier(0.32, 0.72, 0, 1)',
transform:
open && !isClosing ? 'translateY(0)' : 'translateY(100%)',
// Handle safe area on mobile devices
paddingBottom: 'env(safe-area-inset-bottom)',
}}
>
{/* Header Section with drag handle, title, and close button */}
<div
style={{
display: 'flex',
flexDirection: 'column',
backgroundColor: 'inherit',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
position: 'relative',
}}
>
{/* Close button positioned absolutely in top-right */}
{showCloseButton && (
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={onClose}
sx={{
position: 'absolute',
top: 8,
right: 16,
zIndex: 1,
borderRadius: '50%',
width: 32,
height: 32,
backgroundColor: 'neutral.softBg',
color: 'neutral.softColor',
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
transform: 'scale(1.05)',
},
transition: 'all 0.2s ease',
flexShrink: 0,
}}
>
<Close fontSize='small' />
</IconButton>
)}
{/* Drag Handle */}
{showHandle && (
<div
style={{
display: 'flex',
justifyContent: 'center',
padding: '12px 0 8px 0',
cursor: 'pointer',
userSelect: 'none',
}}
onClick={handleToggleExpansion}
title={isExpanded ? 'Collapse' : 'Expand'}
>
<div
style={{
width: 30,
height: 4,
borderRadius: 2,
backgroundColor: 'var(--joy-palette-neutral-300)',
transition: 'background-color 0.2s ease',
}}
/>
</div>
)}
{/* Title Row */}
{title && (
<div
style={{
display: 'flex',
alignItems: 'center',
padding: showHandle ? '0 20px 16px 20px' : '16px 20px 16px 20px',
paddingRight: showCloseButton ? '60px' : '20px', // Add space for close button
minHeight: 24,
}}
>
<Typography
level='title-lg'
sx={{
fontWeight: 600,
flex: 1,
}}
>
{title}
</Typography>
</div>
)}
</div>
{/* Content area */}
<div
style={{
flex: 1,
overflow: 'auto',
padding: '0 20px 20px 20px',
minHeight: 0, // Important for flex child with overflow
}}
>
{children}
</div>
</Sheet>
</Modal>
)
},
)
BottomSheetModal.displayName = 'BottomSheetModal'
export default BottomSheetModal

View File

@@ -2,6 +2,7 @@ import {
Add, Add,
Archive, Archive,
Bolt, Bolt,
CalendarMonth,
CancelRounded, CancelRounded,
CheckBox, CheckBox,
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
@@ -51,12 +52,15 @@ import CompactChoreCard from './CompactChoreCard'
import IconButtonWithMenu from './IconButtonWithMenu' import IconButtonWithMenu from './IconButtonWithMenu'
import MultiSelectHelp from './MultiSelectHelp' import MultiSelectHelp from './MultiSelectHelp'
import { useMediaQuery } from '@mui/material'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx' import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores' import { ChoreFilters, ChoresGrouper, ChoreSorter } from '../../utils/Chores'
import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher' import { DeleteChore, MarkChoreComplete, SkipChore } from '../../utils/Fetcher'
import TaskInput from '../components/AddTaskModal' import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import { import {
canScheduleNotification, canScheduleNotification,
scheduleChoreNotification, scheduleChoreNotification,
@@ -68,6 +72,7 @@ import SortAndGrouping from './SortAndGrouping'
const MyChores = () => { const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
useUserProfile() useUserProfile()
const isLargeScreen = useMediaQuery(theme => theme.breakpoints.up('md'))
const { showSuccess, showError, showWarning } = useNotification() const { showSuccess, showError, showWarning } = useNotification()
const { impersonatedUser } = useImpersonateUser() const { impersonatedUser } = useImpersonateUser()
const [chores, setChores] = useState([]) const [chores, setChores] = useState([])
@@ -93,9 +98,10 @@ const MyChores = () => {
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
const [performers, setPerformers] = useState([]) const [performers, setPerformers] = useState([])
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [isCompactView, setIsCompactView] = useState( const [viewMode, setViewMode] = useState(
localStorage.getItem('choreCardViewMode') === 'compact', localStorage.getItem('choreCardViewMode') || 'default',
) )
const [selectedCalendarDate, setSelectedCalendarDate] = useState(new Date())
const menuRef = useRef(null) const menuRef = useRef(null)
const Navigate = useNavigate() const Navigate = useNavigate()
const { data: userLabels, isLoading: userLabelsLoading } = useLabels() const { data: userLabels, isLoading: userLabelsLoading } = useLabels()
@@ -385,17 +391,27 @@ const MyChores = () => {
const setSelectedChoreFilterWithCache = value => { const setSelectedChoreFilterWithCache = value => {
setSelectedChoreFilter(value) setSelectedChoreFilter(value)
localStorage.setItem('selectedChoreFilter', value) localStorage.setItem('selectedChoreFilter', value)
// Clear selected calendar date when filters change
setSelectedCalendarDate(null)
} }
const toggleViewMode = () => { const toggleViewMode = () => {
const newMode = !isCompactView const modes = ['default', 'compact', 'calendar']
setIsCompactView(newMode) const currentIndex = modes.indexOf(viewMode)
localStorage.setItem('choreCardViewMode', newMode ? 'compact' : 'default') const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
// Clear selected calendar date when switching away from calendar view
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
} }
// Helper function to render the appropriate card component // Helper function to render the appropriate card component
const renderChoreCard = (chore, key) => { const renderChoreCard = (chore, key) => {
const CardComponent = isCompactView ? CompactChoreCard : ChoreCard const CardComponent = viewMode === 'compact' ? CompactChoreCard : ChoreCard
return ( return (
<CardComponent <CardComponent
key={key || chore.id} key={key || chore.id}
@@ -413,6 +429,25 @@ const MyChores = () => {
) )
} }
// Helper function to get filtered chores for display
const getFilteredChores = () => {
if (searchTerm?.length > 0 || searchFilter !== 'All') {
return filteredChores
}
return chores.filter(ChoreFilters(userProfile)[selectedChoreFilter])
}
// Helper function to get chores for a specific date
const getChoresForDate = date => {
const filteredChores = getFilteredChores()
return filteredChores.filter(chore => {
if (!chore.nextDueDate) return false
const choreDate = new Date(chore.nextDueDate).toLocaleDateString()
const selectedDate = date.toLocaleDateString()
return choreDate === selectedDate
})
}
const updateChores = newChore => { const updateChores = newChore => {
const newChores = chores const newChores = chores
newChores.push(newChore) newChores.push(newChore)
@@ -463,6 +498,8 @@ const MyChores = () => {
setFilteredChores(priorityFiltered) setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority) setSearchFilter('Priority: ' + priority)
} }
// Clear selected calendar date when filters change
setSelectedCalendarDate(null)
} }
const handleChoreUpdated = (updatedChore, event) => { const handleChoreUpdated = (updatedChore, event) => {
@@ -607,18 +644,24 @@ const MyChores = () => {
if (search === '') { if (search === '') {
setFilteredChores(chores) setFilteredChores(chores)
setSearchTerm('') setSearchTerm('')
// Clear selected calendar date when search changes
setSelectedCalendarDate(null)
return return
} }
const term = search.toLowerCase() const term = search.toLowerCase()
setSearchTerm(term) setSearchTerm(term)
setFilteredChores(fuse.search(term).map(result => result.item)) setFilteredChores(fuse.search(term).map(result => result.item))
// Clear selected calendar date when search changes
setSelectedCalendarDate(null)
} }
const handleSearchClose = () => { const handleSearchClose = () => {
setSearchTerm('') setSearchTerm('')
setFilteredChores(chores) setFilteredChores(chores)
// remove the focus from the search input: // remove the focus from the search input:
setSearchInputFocus(0) setSearchInputFocus(0)
// Clear selected calendar date when search closes
setSelectedCalendarDate(null)
} }
// Multi-select helper functions // Multi-select helper functions
@@ -1065,10 +1108,20 @@ const MyChores = () => {
}} }}
onClick={toggleViewMode} onClick={toggleViewMode}
title={ title={
isCompactView ? 'Switch to Card View' : 'Switch to Compact View' viewMode === 'default'
? 'Switch to Compact View'
: viewMode === 'compact'
? 'Switch to Calendar View'
: 'Switch to Card View'
} }
> >
{isCompactView ? <ViewModule /> : <ViewAgenda />} {viewMode === 'default' ? (
<ViewAgenda />
) : viewMode === 'compact' ? (
<CalendarMonth />
) : (
<ViewModule />
)}
</IconButton> </IconButton>
{/* Multi-select Toggle Button */} {/* Multi-select Toggle Button */}
@@ -1579,79 +1632,153 @@ const MyChores = () => {
</Box> </Box>
)} )}
{(searchTerm?.length > 0 || searchFilter !== 'All') && {(searchTerm?.length > 0 || searchFilter !== 'All') &&
viewMode !== 'calendar' &&
filteredChores.map(chore => filteredChores.map(chore =>
renderChoreCard(chore, `filtered-${chore.id}`), renderChoreCard(chore, `filtered-${chore.id}`),
)} )}
{searchTerm.length === 0 && searchFilter === 'All' && ( {viewMode === 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider> <>
{choreSections.map((section, index) => { {/* Calendar Monthly View */}
if (section.content.length === 0) return null <Box sx={{ mb: 2 }}>
return ( {isLargeScreen ? (
<Accordion <CalendarDual
key={section.name + index} chores={getFilteredChores()}
sx={{ onDateChange={date => {
my: 0, setSelectedCalendarDate(date)
px: 0,
}} }}
expanded={Boolean(openChoreSections[index])} />
> ) : (
<Divider orientation='horizontal'> <div className='calendar-dual'>
<Chip <CalendarMonthly
variant='soft' chores={getFilteredChores()}
color='neutral' onDateChange={date => {
size='md' setSelectedCalendarDate(date)
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
delete newOpenChoreSections[index]
setOpenChoreSectionsWithCache(newOpenChoreSections)
} else {
setOpenChoreSectionsWithCache({
...openChoreSections,
[index]: true,
})
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
['& > *']: {
// px: 0.5,
px: 0.5,
// pr: 0,
},
}} }}
> />
{section.content?.map(chore => renderChoreCard(chore))} </div>
</AccordionDetails> )}
</Accordion> </Box>
)
})} {/* Selected Date Tasks */}
</AccordionGroup> {selectedCalendarDate && (
<Box sx={{ mt: 2 }}>
<Typography level='title-md' gutterBottom>
Tasks for {selectedCalendarDate.toLocaleDateString()}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
maxHeight: '400px',
overflowY: 'auto',
}}
>
{getChoresForDate(selectedCalendarDate).length === 0 ? (
<Typography
level='body-sm'
sx={{
textAlign: 'center',
py: 2,
color: 'text.tertiary',
}}
>
No tasks scheduled for this date
</Typography>
) : (
getChoresForDate(selectedCalendarDate).map(chore => (
<CompactChoreCard
key={`calendar-${chore.id}`}
chore={chore}
onChoreUpdate={handleChoreUpdated}
onChoreRemove={handleChoreDeleted}
performers={performers}
userLabels={userLabels}
onChipClick={handleLabelFiltering}
// Multi-select props
isMultiSelectMode={isMultiSelectMode}
isSelected={selectedChores.has(chore.id)}
onSelectionToggle={() => toggleChoreSelection(chore.id)}
/>
))
)}
</Box>
</Box>
)}
</>
)} )}
{searchTerm.length === 0 &&
searchFilter === 'All' &&
viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => {
if (section.content.length === 0) return null
return (
<Accordion
key={section.name + index}
sx={{
my: 0,
px: 0,
}}
expanded={Boolean(openChoreSections[index])}
>
<Divider orientation='horizontal'>
<Chip
variant='soft'
color='neutral'
size='md'
onClick={() => {
if (openChoreSections[index]) {
const newOpenChoreSections = {
...openChoreSections,
}
delete newOpenChoreSections[index]
setOpenChoreSectionsWithCache(newOpenChoreSections)
} else {
setOpenChoreSectionsWithCache({
...openChoreSections,
[index]: true,
})
}
}}
endDecorator={
openChoreSections[index] ? (
<ExpandCircleDown
color='primary'
sx={{ transform: 'rotate(180deg)' }}
/>
) : (
<ExpandCircleDown color='primary' />
)
}
startDecorator={
<>
<Chip color='primary' size='sm' variant='soft'>
{section?.content?.length}
</Chip>
</>
}
>
{section.name}
</Chip>
</Divider>
<AccordionDetails
sx={{
flexDirection: 'column',
['& > *']: {
// px: 0.5,
px: 0.5,
// pr: 0,
},
}}
>
{section.content?.map(chore => renderChoreCard(chore))}
</AccordionDetails>
</Accordion>
)
})}
</AccordionGroup>
)}
<Box <Box
sx={{ sx={{
// center the button // center the button

View File

@@ -1,7 +1,6 @@
import { Box, Typography } from '@mui/joy' import { Box, Typography } from '@mui/joy'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import Calendar from 'react-calendar' import Calendar from 'react-calendar'
// import 'react-calendar/dist/Calendar.css'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors' import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'

View File

@@ -2,7 +2,6 @@ import { CalendarMonth } from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy' import { Avatar, Box, Chip, Grid, Typography } from '@mui/joy'
import moment from 'moment' import moment from 'moment'
import { useState } from 'react' import { useState } from 'react'
// import 'react-calendar/dist/Calendar.css'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { getPriorityColor, TASK_COLOR } from '../../utils/Colors' import { getPriorityColor, TASK_COLOR } from '../../utils/Colors'