feat: Refactor Filter Section and Add Filter View

- Removed unused props and components from FilterSection.
- Introduced FilterView component to manage and display saved filters.
- Implemented swipe functionality for filter actions (edit, delete, pin).
- Enhanced AdvancedFilterBuilder with description and color selection.
- Deleted obsolete SaveFilterModal component.
- Updated NavBar to include a link to the new Filters page.
- Adjusted chore filter hooks for improved functionality and organization.
This commit is contained in:
Mo Tarbin
2026-01-26 17:27:27 -05:00
parent 2b8482cb06
commit ad45bafd62
13 changed files with 1103 additions and 434 deletions

View File

@@ -30,6 +30,7 @@ import PaymentCancelledView from '../views/Payments/PaymentFailView'
import PaymentSuccessView from '../views/Payments/PaymentSuccessView'
import PrivacyPolicyView from '../views/PrivacyPolicy/PrivacyPolicyView'
import ProjectView from '../views/Projects/ProjectView'
import FilterView from '../views/Filters/FilterView'
import APITokenSettings from '../views/Settings/APITokenSettings'
import MFASettings from '../views/Settings/MFASettings'
import NotificationSetting from '../views/Settings/NotificationSetting'
@@ -233,6 +234,10 @@ const Router = createBrowserRouter([
path: 'projects/',
element: <ProjectView />,
},
{
path: 'filters/',
element: <FilterView />,
},
{
path: '*',
element: <NotFound />,

View File

@@ -26,6 +26,10 @@ const LABEL_COLORS = [
{ name: 'Sand', value: '#d7ccc8' },
]
export const FILTER_COLORS = [
...LABEL_COLORS.filter(color => color.name !== 'Default'),
]
export const COLORS = {
salmon: '#ff7961',
teal: '#26a69a',

View File

@@ -52,6 +52,8 @@ export const saveFilter = (filter) => {
const newFilter = {
id: generateFilterId(),
name: filter.name,
description: filter.description || '',
color: filter.color || null,
icon: filter.icon || null,
conditions: filter.conditions,
operator: filter.operator || 'AND',

View File

@@ -34,7 +34,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useChores } from '../../queries/ChoreQueries'
import { useNotification } from '../../service/NotificationProvider'
import { TASK_COLOR } from '../../utils/Colors'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import { useLabels } from '../Labels/LabelQueries'
@@ -60,7 +59,6 @@ import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import SaveFilterModal from '../Modals/Inputs/SaveFilterModal'
import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
@@ -179,7 +177,6 @@ const MyChores = () => {
projectsWithDefault,
)
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false)
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
@@ -736,6 +733,10 @@ const MyChores = () => {
setFilteredChores(chores)
setSearchFilter('All')
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
mouseClickHandler={handleMenuOutsideClick}
/>
@@ -1100,7 +1101,7 @@ const MyChores = () => {
{viewMode === 'calendar' && (
<>
{/* Summary Chips when no date selected */}
<Box
{/* <Box
sx={{
mt: 1,
mb: 1,
@@ -1213,7 +1214,7 @@ const MyChores = () => {
Pending Approval
</Chip>
)}
</Box>
</Box> */}
{/* Calendar Monthly View */}
<Box sx={{ mb: 2 }}>
{isLargeScreen ? (
@@ -1467,38 +1468,6 @@ const MyChores = () => {
onClose={closeModal}
/>
{/* Save Filter Modal */}
<SaveFilterModal
isOpen={showSaveFilterModal}
onClose={() => setShowSaveFilterModal(false)}
onSave={filter => {
saveFilter(filter)
showSuccess({
title: 'Filter Saved',
message: `"${filter.name}" has been saved successfully`,
})
}}
filterData={createFilterFromCurrentState({
selectedProject,
selectedChoreFilter,
searchFilter,
})}
previewChores={
activeFilterId ? customFilteredChores : searchFilteredChores
}
previewCount={
activeFilterId
? customFilteredChores.length
: searchFilteredChores.length
}
previewOverdueCount={
(activeFilterId ? customFilteredChores : searchFilteredChores).filter(
chore =>
chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
).length
}
/>
{/* Advanced Filter Builder */}
<AdvancedFilterBuilder
isOpen={showAdvancedFilterBuilder}
@@ -1511,6 +1480,8 @@ const MyChores = () => {
// Update existing filter
updateFilter(filter.id, {
name: filter.name,
description: filter.description,
color: filter.color,
conditions: filter.conditions,
operator: filter.operator,
})

View File

@@ -23,6 +23,7 @@ const SortAndGrouping = ({
isActive,
useChips,
title,
onCreateNewFilter,
}) => {
const [anchorEl, setAnchorEl] = useState(null)
const menuRef = useRef(null)
@@ -201,6 +202,22 @@ const SortAndGrouping = ({
/>
<Typography level='body-sm'>Assigned to others</Typography>
</MenuItem>
<Divider />
<MenuItem
key={`${k}-custom-filter`}
onClick={() => {
onCreateNewFilter()
handleMenuClose()
// TODO: Open advanced filter builder
}}
>
<Typography level='body-sm' fontWeight='md' color='primary'>
Create Custom Filter...
</Typography>
</MenuItem>
{/*
// i need this but i think it have a bad UX and confusing so commenting it for now
<MenuItem

View File

@@ -1,6 +1,15 @@
import { Delete, Edit, Star, StarBorder, Warning } from '@mui/icons-material'
import {
Delete,
Edit,
Settings,
Star,
StarBorder,
Warning,
} from '@mui/icons-material'
import { Box, Chip, Menu, MenuItem, Tooltip, Typography } from '@mui/joy'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { getTextColorFromBackgroundColor } from '../../../utils/Colors'
const CustomFilterChips = ({
filters = [],
@@ -10,6 +19,7 @@ const CustomFilterChips = ({
onFilterPin,
onFilterEdit,
}) => {
const navigate = useNavigate()
const [menuAnchor, setMenuAnchor] = useState(null)
const [selectedFilter, setSelectedFilter] = useState(null)
@@ -73,6 +83,10 @@ const CustomFilterChips = ({
{sortedFilters.map(filter => {
const isActive = activeFilterId === filter.id
const hasWarning = !filter.isValid
const hasCustomColor = !!filter.color && !hasWarning
const textColor = hasCustomColor
? getTextColorFromBackgroundColor(filter.color)
: undefined
return (
<Tooltip
@@ -80,67 +94,129 @@ const CustomFilterChips = ({
title={
hasWarning
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
: `${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
: `${filter.description ? filter.description + ' - ' : ''}${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
}
placement='bottom'
>
<Chip
variant={isActive ? 'solid' : 'soft'}
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
size='lg'
onClick={() => !hasWarning && onFilterClick(filter.id)}
onContextMenu={e => handleContextMenu(e, filter)}
sx={{
cursor: hasWarning ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
px: 1.5,
py: 0.5,
opacity: hasWarning ? 0.7 : 1,
}}
startDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{filter.isPinned && (
<Star sx={{ fontSize: '0.9rem', color: 'warning.500' }} />
)}
<Chip
size='sm'
variant='solid'
color={
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
}
>
{filter.count}
</Chip>
</Box>
}
endDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
{!hasWarning && filter.overdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{filter.overdueCount}
</Chip>
)}
</Box>
}
>
<Typography
level='body-sm'
fontWeight={isActive ? 'md' : 'normal'}
<div onClick={() => !hasWarning && onFilterClick(filter.id)}>
<Chip
variant='solid'
size='lg'
onContextMenu={e => handleContextMenu(e, filter)}
sx={{
whiteSpace: 'nowrap',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
cursor: hasWarning ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
px: 1.5,
py: 0.5,
opacity: hasWarning ? 0.7 : isActive ? 1 : 0.85,
...(hasCustomColor && {
backgroundColor: `${filter.color} !important`,
color: `${textColor} !important`,
'&:hover': {
backgroundColor: `${filter.color} !important`,
filter: 'brightness(0.95)',
opacity: 1,
},
}),
}}
startDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{filter.isPinned && (
<Star
sx={{
fontSize: '0.9rem',
color: hasCustomColor ? textColor : 'warning.500',
}}
/>
)}
<Chip
size='sm'
variant='solid'
sx={{
...(hasCustomColor
? {
bgcolor:
textColor === '#FFFFFF'
? '#00000040'
: '#FFFFFF40',
color: textColor,
border: `1px solid ${textColor}30`,
}
: {}),
}}
color={
hasCustomColor
? undefined
: hasWarning
? 'warning'
: isActive
? 'primary'
: 'neutral'
}
>
{filter.count}
</Chip>
</Box>
}
endDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
{!hasWarning && filter.overdueCount > 0 && (
<Chip
size='sm'
variant='solid'
sx={{
...(hasCustomColor
? {
bgcolor: '#ff4444',
color: '#FFFFFF',
border: `1px solid ${textColor}30`,
}
: {}),
}}
color={hasCustomColor ? undefined : 'danger'}
>
{filter.overdueCount}
</Chip>
)}
</Box>
}
>
{filter.name}
</Typography>
</Chip>
<Typography
level='body-sm'
fontWeight={isActive ? 'md' : 'normal'}
sx={{
whiteSpace: 'nowrap',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
...(hasCustomColor && {
color: textColor,
}),
}}
>
{filter.name}
</Typography>
</Chip>
</div>
</Tooltip>
)
})}
<Chip
variant='outlined'
size='lg'
startDecorator={<Settings />}
sx={{ cursor: 'pointer' }}
onClick={e => {
e.preventDefault()
e.stopPropagation()
navigate('/filters')
}}
>
Manage Filters
</Chip>
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}

View File

@@ -1,19 +1,14 @@
import { Add, CancelRounded } from '@mui/icons-material'
import { Box, Button, Chip, IconButton } from '@mui/joy'
import { Box } from '@mui/joy'
import CustomFilterChips from './CustomFilterChips'
const FilterSection = ({
savedFilters,
activeFilterId,
activeFilter,
hasProjectConditions,
onFilterClick,
onFilterDelete,
onFilterPin,
onFilterEdit,
onClearActiveFilter,
onCreateAdvancedFilter,
updateFilterUrl,
}) => {
return (
<>
@@ -31,53 +26,7 @@ const FilterSection = ({
</Box>
)}
{/* Create Advanced Filter Button */}
{!activeFilterId && (
<Box
sx={{
mt: 1,
display: 'flex',
gap: 1,
justifyContent: 'flex-start',
}}
>
<Button
size='sm'
variant='outlined'
color='primary'
startDecorator={<Add />}
onClick={onCreateAdvancedFilter}
>
Create Advanced Filter
</Button>
</Box>
)}
{/* Active Custom Filter Display */}
{activeFilter && (
<Box sx={{ mt: 1 }}>
<Chip
color='primary'
variant='soft'
size='lg'
endDecorator={
<IconButton size='sm' onClick={onClearActiveFilter}>
<CancelRounded />
</IconButton>
}
>
Filter: {activeFilter.name} ({activeFilter.count} tasks
{activeFilter.overdueCount > 0 &&
`, ${activeFilter.overdueCount} overdue`}
)
{hasProjectConditions && (
<Chip size='sm' sx={{ ml: 1 }} variant='solid' color='primary'>
Cross-Project
</Chip>
)}
</Chip>
</Box>
)}
</>
)
}

View File

@@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react'
import Fuse from 'fuse.js'
import { filterByProject, ChoreFilters } from '../../../utils/Chores'
import { useCallback, useMemo, useState } from 'react'
import { ChoreFilters, filterByProject } from '../../../utils/Chores'
export const useChoreFilters = ({
chores,

View File

@@ -1,20 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useUserProfile } from '../../../queries/UserQueries'
import {
applyFilter,
getFilterCount,
getFilterOverdueCount,
validateFilter,
} from '../../../utils/FilterEngine'
import {
deleteFilter as deleteFilterStorage,
getFilterById,
getSavedFilters,
saveFilter as saveFilterStorage,
toggleFilterPin,
trackFilterUsage,
updateFilter as updateFilterStorage,
} from '../../../utils/CustomFilterStorage'
import {
applyFilter,
getFilterCount,
getFilterOverdueCount,
validateFilter,
} from '../../../utils/FilterEngine'
export const useCustomFilters = (chores, membersData, labels, projects) => {
const { data: userProfile } = useUserProfile()
@@ -52,13 +51,15 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
? getFilterOverdueCount(chores, filter, context)
: 0
return {
const result = {
...filter,
count,
overdueCount,
isValid: validation.isValid,
validationIssues: validation.issues,
}
return result
})
}, [savedFilters, chores, context])

View File

@@ -0,0 +1,823 @@
import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import {
Avatar,
Box,
Chip,
CircularProgress,
Container,
IconButton,
Stack,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Add, FilterAlt, Star, StarBorder, Task } from '@mui/icons-material'
import { useChores } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
deleteFilter,
getSavedFilters,
saveFilter,
toggleFilterPin,
updateFilter,
} from '../../utils/CustomFilterStorage'
import { getFilterCount, getFilterOverdueCount } from '../../utils/FilterEngine'
import { getSafeBottomStyles } from '../../utils/SafeAreaUtils'
import { useLabels } from '../Labels/LabelQueries'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import { useProjects } from '../Projects/ProjectQueries'
const FilterCard = ({
filter,
onEditClick,
onDeleteClick,
onPinClick,
taskCount = 0,
overdueCount = 0,
}) => {
const navigate = useNavigate()
// 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 = 200 // Increased to fit pin + edit + delete
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)
}, 800)
setHoverTimer(timer)
}
const handleMouseLeave = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
if (!isSwipeRevealed) {
const hideTimer = setTimeout(() => {
resetSwipe()
}, 300)
setHoverTimer(hideTimer)
}
}
const handleActionAreaMouseEnter = () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}
const handleActionAreaMouseLeave = () => {
if (isSwipeRevealed) {
resetSwipe()
}
}
// Clean up timer on unmount
useEffect(() => {
return () => {
if (hoverTimer) {
clearTimeout(hoverTimer)
}
}
}, [hoverTimer])
// Get condition labels for display
const getConditionSummary = () => {
if (!filter.conditions || filter.conditions.length === 0) {
return 'No conditions'
}
if (filter.conditions.length === 1) {
return '1 condition'
}
return `${filter.conditions.length} conditions`
}
return (
<Box key={filter.id + '-filter-box'}>
<Box
sx={{
position: 'relative',
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
'&:last-child': {
borderBottom: 'none',
},
}}
onMouseLeave={() => {
if (hoverTimer) {
clearTimeout(hoverTimer)
setHoverTimer(null)
}
}}
>
{/* 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}
onMouseLeave={handleActionAreaMouseLeave}
>
<IconButton
variant='soft'
color='warning'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onPinClick(filter.id)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
{filter.isPinned ? (
<Star sx={{ fontSize: 16 }} />
) : (
<StarBorder sx={{ fontSize: 16 }} />
)}
</IconButton>
<IconButton
variant='soft'
color='neutral'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onEditClick(filter)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
<EditIcon sx={{ fontSize: 16 }} />
</IconButton>
<IconButton
variant='soft'
color='danger'
size='sm'
onClick={e => {
e.stopPropagation()
resetSwipe()
onDeleteClick(filter.id)
}}
sx={{
width: 40,
height: 40,
mx: 0.5,
}}
>
<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
}
// Navigate to MyChores with filter applied via URL param
navigate(`/chores?filterId=${encodeURIComponent(filter.id)}`)
}}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
{/* Right drag area */}
<Box
sx={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '20px',
cursor: 'grab',
zIndex: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
opacity: isSwipeRevealed ? 0 : 0.3,
transition: 'opacity 0.2s ease',
pointerEvents: isSwipeRevealed ? 'none' : 'auto',
'&:hover': {
opacity: isSwipeRevealed ? 0 : 0.7,
},
'&:active': {
cursor: 'grabbing',
},
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{/* Drag indicator dots */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 0.25,
}}
>
{[...Array(3)].map((_, i) => (
<Box
key={i}
sx={{
width: 3,
height: 3,
borderRadius: '50%',
bgcolor: 'text.tertiary',
}}
/>
))}
</Box>
</Box>
{/* Filter Icon */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
mr: 2,
flexShrink: 0,
}}
>
<Avatar
size='sm'
sx={{
width: 32,
height: 32,
bgcolor: filter.color || 'neutral.500',
border: '2px solid',
borderColor: filter.isPinned
? 'warning.300'
: 'background.surface',
boxShadow: filter.isPinned
? '0 0 0 1px var(--joy-palette-warning-300)'
: 'sm',
}}
>
<FilterAlt
sx={{
fontSize: 16,
color: 'white',
}}
/>
</Avatar>
</Box>
{/* Content - Center */}
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
}}
>
{/* Filter Name */}
<Box
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 0.25 }}
>
<Typography
level='title-sm'
sx={{
fontWeight: 600,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{filter.name}
</Typography>
{filter.isPinned && (
<Star
sx={{
fontSize: 14,
color: 'warning.500',
}}
/>
)}
</Box>
{/* Filter Info */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
flexWrap: 'wrap',
}}
>
{filter.description && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '150px',
}}
>
{filter.description}
</Typography>
)}
<Chip
size='sm'
variant='soft'
startDecorator={<Task />}
color={overdueCount > 0 ? 'danger' : 'primary'}
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor:
overdueCount > 0 ? 'danger.softBg' : 'primary.softBg',
color: overdueCount > 0 ? 'danger.500' : 'primary.500',
}}
>
{taskCount} tasks
</Chip>
{overdueCount > 0 && (
<Chip
size='sm'
variant='solid'
color='danger'
sx={{
fontSize: 10,
height: 18,
px: 0.75,
}}
>
{overdueCount} overdue
</Chip>
)}
<Chip
size='sm'
variant='soft'
startDecorator={<FilterAlt />}
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor: 'neutral.softBg',
color: 'neutral.600',
}}
>
{getConditionSummary()}
</Chip>
{filter.usageCount > 0 && (
<Chip
size='sm'
variant='soft'
sx={{
fontSize: 10,
height: 18,
px: 0.75,
bgcolor: 'success.softBg',
color: 'success.600',
}}
>
Used {filter.usageCount}x
</Chip>
)}
</Box>
</Box>
</Box>
</Box>
</Box>
)
}
const FilterView = () => {
const { data: userProfile } = useUserProfile()
const { data: chores = { res: [] } } = useChores(false)
const { data: labels = [] } = useLabels()
const { data: projects = [] } = useProjects()
const { data: membersData } = useCircleMembers()
const [savedFilters, setSavedFilters] = useState([])
const [filterCounts, setFilterCounts] = useState({})
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const [confirmationModel, setConfirmationModel] = useState({})
const [isLoading, setIsLoading] = useState(true)
// Load filters
const loadFilters = () => {
try {
const filters = getSavedFilters()
// Sort: pinned first, then by usage count, then by last used
const sortedFilters = filters.sort((a, b) => {
if (a.isPinned !== b.isPinned) {
return a.isPinned ? -1 : 1
}
if ((b.usageCount || 0) !== (a.usageCount || 0)) {
return (b.usageCount || 0) - (a.usageCount || 0)
}
if (a.lastUsedAt && b.lastUsedAt) {
return new Date(b.lastUsedAt) - new Date(a.lastUsedAt)
}
return new Date(b.createdAt) - new Date(a.createdAt)
})
setSavedFilters(sortedFilters)
} catch (error) {
console.error('Error loading filters:', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
loadFilters()
}, [])
// Calculate task counts for each filter
useEffect(() => {
if (chores && chores.res && savedFilters.length > 0) {
const choresList = chores.res
const counts = {}
const context = {
userId: userProfile?.id,
members: membersData?.res || [],
labels: labels || [],
projects: projects || [],
}
savedFilters.forEach(filter => {
try {
const count = getFilterCount(choresList, filter, context)
const overdueCount = getFilterOverdueCount(
choresList,
filter,
context,
)
counts[filter.id] = { count, overdueCount }
} catch (error) {
console.error(
`Error calculating count for filter ${filter.id}:`,
error,
)
counts[filter.id] = { count: 0, overdueCount: 0 }
}
})
setFilterCounts(counts)
}
}, [
chores,
savedFilters,
userProfile?.id,
labels,
projects,
membersData?.res,
])
const handleAddFilter = () => {
setEditingFilter(null)
setShowAdvancedFilterBuilder(true)
}
const handleEditFilter = filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}
const handleDeleteClicked = id => {
const filter = savedFilters.find(f => f.id === id)
setConfirmationModel({
isOpen: true,
title: 'Delete Filter',
message: `Are you sure you want to delete "${filter?.name}"? This cannot be undone.`,
confirmText: 'Delete',
color: 'danger',
cancelText: 'Cancel',
onClose: confirmed => {
if (confirmed === true) {
handleDeleteFilter(id)
}
setConfirmationModel({})
},
})
}
const handleDeleteFilter = id => {
try {
deleteFilter(id)
// if it's the selected filter, we might want to clear it
localStorage.getItem('selectedChoreFilter') === id &&
localStorage.removeItem('selectedChoreFilter')
loadFilters()
} catch (error) {
console.error('Error deleting filter:', error)
}
}
const handlePinFilter = id => {
try {
toggleFilterPin(id)
loadFilters()
} catch (error) {
console.error('Error pinning filter:', error)
}
}
const handleSaveFilter = filterData => {
try {
if (editingFilter) {
// Update existing filter
updateFilter(editingFilter.id, filterData)
} else {
// Save new filter
saveFilter(filterData)
}
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
loadFilters()
} catch (error) {
console.error('Error saving filter:', error)
}
}
if (isLoading) {
return (
<Box
display='flex'
justifyContent='center'
alignItems='center'
height='100vh'
>
<CircularProgress />
</Box>
)
}
return (
<Container maxWidth='md' sx={{ px: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}>
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Save your favorite filter combinations for quick access. Create
custom views to organize and find tasks faster.
</Typography>
</Stack>
</Box>
<Box
sx={{
overflow: 'hidden',
}}
>
{savedFilters.length === 0 ? (
<Box
sx={{
p: 4,
textAlign: 'center',
}}
>
<FilterAlt
sx={{
fontSize: 48,
color: 'neutral.300',
mb: 2,
}}
/>
<Typography
level='title-lg'
sx={{ mb: 1, color: 'text.secondary' }}
>
No saved filters yet
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary', mb: 2 }}>
Create custom filters to quickly access your most used chore
</Typography>
</Box>
) : (
savedFilters.map(filter => (
<FilterCard
key={filter.id}
filter={filter}
onEditClick={handleEditFilter}
onDeleteClick={handleDeleteClicked}
onPinClick={handlePinFilter}
taskCount={filterCounts[filter.id]?.count || 0}
overdueCount={filterCounts[filter.id]?.overdueCount || 0}
/>
))
)}
</Box>
{showAdvancedFilterBuilder && (
<AdvancedFilterBuilder
isOpen={showAdvancedFilterBuilder}
onClose={() => {
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
}}
onSave={handleSaveFilter}
members={membersData?.res || []}
labels={labels || []}
projects={projects || []}
allChores={chores?.res || []}
userProfile={userProfile}
editingFilter={editingFilter}
/>
)}
<Box
sx={{
...getSafeBottomStyles({ bottom: 0, padding: 16 }),
left: 10,
display: 'flex',
justifyContent: 'flex-end',
gap: 2,
'z-index': 1000,
}}
>
<IconButton
color='primary'
variant='solid'
sx={{
borderRadius: '50%',
width: 50,
height: 50,
}}
onClick={handleAddFilter}
>
<Add />
</IconButton>
</Box>
<ConfirmationModal config={confirmationModel} />
</Container>
)
}
export default FilterView

View File

@@ -9,10 +9,12 @@ import {
ListItem,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { FILTER_COLORS } from '../../../utils/Colors'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
@@ -30,19 +32,36 @@ const AdvancedFilterBuilder = ({
}) => {
const { ResponsiveModal } = useResponsiveModal()
const [filterName, setFilterName] = useState('')
const [filterDescription, setFilterDescription] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [error, setError] = useState('')
const [existedFilters] = useState(() => {
const storedFilters = localStorage.getItem('customFilters')
return storedFilters ? JSON.parse(storedFilters) : []
})
// Initialize state when editing a filter
useEffect(() => {
if (editingFilter) {
setFilterName(editingFilter.name)
setFilterDescription(editingFilter.description || '')
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
setConditions(editingFilter.conditions || [])
setError('')
} else {
setFilterName('')
setFilterDescription('')
// find color no filter has it :
const potentialColor = FILTER_COLORS.find(
color => !existedFilters.some(filter => filter.color === color.value),
)
setFilterColor(
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
)
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
}
@@ -127,6 +146,8 @@ const AdvancedFilterBuilder = ({
const filterData = {
name: filterName.trim(),
description: filterDescription.trim(),
color: filterColor,
conditions: validConditions,
operator: 'AND',
}
@@ -375,11 +396,27 @@ const AdvancedFilterBuilder = ({
}
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md'>
<Typography level='h4' sx={{ mb: 2 }}>
{editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
</Typography>
<ResponsiveModal
open={isOpen}
onClose={onClose}
size='lg'
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
footer={
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
>
{editingFilter ? 'Update Filter' : 'Save Filter'}
</Button>
</Box>
}
>
<Box
sx={{
display: 'flex',
@@ -409,6 +446,61 @@ const AdvancedFilterBuilder = ({
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Description (Optional)
</Typography>
<Textarea
placeholder='Optional description for this filter...'
value={filterDescription}
onChange={e => setFilterDescription(e.target.value)}
minRows={2}
maxRows={3}
/>
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Color
</Typography>
<Select
value={filterColor}
onChange={(_, value) => value && setFilterColor(value)}
renderValue={selected => (
<Typography
startDecorator={
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
background: selected.value,
}}
/>
}
>
{selected.label}
</Typography>
)}
>
{FILTER_COLORS.map(color => (
<Option key={color.value} value={color.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
background: color.value,
}}
/>
<Typography>{color.name}</Typography>
</Box>
</Option>
))}
</Select>
</Box>
<Box
sx={{
flex: 1,
@@ -572,20 +664,6 @@ const AdvancedFilterBuilder = ({
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
>
{editingFilter ? 'Update Filter' : 'Save Filter'}
</Button>
</Box>
</Box>
</ResponsiveModal>
)

View File

@@ -1,263 +0,0 @@
import { Save, Star, StarBorder } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Input,
Modal,
ModalClose,
ModalDialog,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import CompactChoreCard from '../../Chores/CompactChoreCard'
const SaveFilterModal = ({
isOpen,
onClose,
onSave,
filterData,
previewChores = [],
previewCount = 0,
previewOverdueCount = 0,
}) => {
const [filterName, setFilterName] = useState('')
const [isPinned, setIsPinned] = useState(false)
const [error, setError] = useState('')
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
if (filterNameExists(filterName.trim())) {
setError('A filter with this name already exists')
return
}
const newFilter = {
...filterData,
name: filterName.trim(),
isPinned,
}
onSave(newFilter)
onClose()
}
const getConditionLabel = condition => {
switch (condition.type) {
case 'assignee':
if (condition.value === 'me') return 'Assigned to me'
if (condition.value === 'others') return 'Assigned to others'
return 'Specific assignee'
case 'createdBy':
if (condition.value === 'me') return 'Created by me'
return 'Created by specific user'
case 'priority':
return `Priority ${condition.value}`
case 'status':
return condition.value === 3 ? 'Pending approval' : `Status ${condition.value}`
case 'dueDate':
if (condition.operator === 'isOverdue') return 'Overdue'
if (condition.operator === 'isDueToday') return 'Due today'
if (condition.operator === 'isDueThisWeek') return 'Due this week'
if (condition.operator === 'hasNoDueDate') return 'No due date'
return 'Due date condition'
case 'label':
return 'Has label'
case 'project':
if (condition.value === 'default') return 'Default project'
return 'Specific project'
default:
return condition.type
}
}
return (
<Modal open={isOpen} onClose={onClose}>
<ModalDialog
sx={{
maxWidth: 500,
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
}}
>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Save Filter
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Name
</Typography>
<Input
placeholder='e.g., My High Priority Tasks'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error}
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{filterData.conditions.length === 0 ? (
<Typography level='body-sm' color='neutral'>
No filters applied
</Typography>
) : (
filterData.conditions.map((condition, index) => (
<Chip
key={index}
variant='soft'
color='neutral'
size='sm'
>
{getConditionLabel(condition)}
</Chip>
))
)}
</Box>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
p: 1,
borderRadius: 'sm',
'&:hover': {
bgcolor: 'background.level1',
},
}}
onClick={() => setIsPinned(!isPinned)}
>
{isPinned ? (
<Star color='warning' />
) : (
<StarBorder color='neutral' />
)}
<Box>
<Typography level='body-sm' fontWeight='md'>
Pin this filter
</Typography>
<Typography level='body-xs' color='neutral'>
Pinned filters appear first in the list
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
disabled={!filterName.trim() || filterData.conditions.length === 0}
>
Save Filter
</Button>
</Box>
</Box>
</ModalDialog>
</Modal>
)
}
export default SaveFilterModal

View File

@@ -2,6 +2,7 @@ import { Capacitor } from '@capacitor/core'
import {
Archive,
ArrowBack,
FilterAlt,
FolderOpen,
History,
Inbox,
@@ -60,6 +61,11 @@ const links = [
label: 'Projects',
icon: <FolderOpen />,
},
{
to: 'filters',
label: 'Filters',
icon: <FilterAlt />,
},
{
to: 'activities',
label: 'Activities',