feat: enhance AdvancedFilterBuilder with new filtering options and UI improvements

- Introduced new filter options for due dates, points, and chore statuses.
- Refactored condition management to use selections for better state handling.
- Improved UI components for filter conditions, including chips for selection.
- Added a bottom sheet modal for filter creation and editing.
- Enhanced user experience with clear actions and previews for selected filters.
This commit is contained in:
Mo Tarbin
2026-06-12 21:31:17 -04:00
parent 4944ee6078
commit fe22bb1035
5 changed files with 1344 additions and 1053 deletions

View File

@@ -0,0 +1,502 @@
import { Check, Close, FilterList, Tune } from '@mui/icons-material'
import {
Avatar,
Badge,
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
/**
* Reusable filter bar component.
*
* Props:
* filterDefs - array of filter definitions:
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
* icon, options?, defaultValue?, filterFn }
* options item: { value, label, color?, icon?, avatar? }
* defaultValue: if the active value equals this, no chip is shown
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
* activeFilters - current filter state object { [id]: value }
* onSetFilter - (filterId, value | null) => void
* onClearAll - () => void
* resultCount - optional number shown in "Show N results" button
* totalCount - optional total for "N of M" label
*/
// ── Date range presets (no moment dependency — pure Date) ────────────────────
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
const DATE_RANGE_PRESETS = [
{
value: 'today',
label: 'Today',
getRange: () => {
const t = d(new Date())
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'yesterday',
label: 'Yesterday',
getRange: () => {
const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-week',
label: 'This Week',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-7-days',
label: 'Last 7 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-month',
label: 'This Month',
getRange: () => {
const n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-30-days',
label: 'Last 30 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-3-months',
label: 'Last 3 Months',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
]
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
const fmtDisplayDate = iso => {
if (!iso) return null
const dt = new Date(iso)
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({
filterDefs,
activeFilters,
onSetFilter,
onClearAll,
resultCount,
totalCount,
}) => {
const [isOpen, setIsOpen] = useState(false)
// ── Active count ───────────────────────────────────────────────────────────
const activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return false
if (def.defaultValue !== undefined && value === def.defaultValue) return false
if (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to)
return true
}).length
const hasActive = activeFilterCount > 0
// ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return null
if (def.type === 'single-select') {
if (def.defaultValue !== undefined && value === def.defaultValue) return null
return def.options?.find(o => o.value === value)?.label ?? def.label
}
if (def.type === 'boolean') return def.label
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label
}
return `${def.label} (${value.length})`
}
if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null
if (value.preset) {
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
}
const from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to)
if (from && to) return `${from} ${to}`
if (from) return `From ${from}`
if (to) return `Until ${to}`
return null
}
return null
}
// ── Handlers ───────────────────────────────────────────────────────────────
const handleMultiToggle = (defId, optValue) => {
const current = activeFilters[defId] || []
const next = current.includes(optValue)
? current.filter(v => v !== optValue)
: [...current, optValue]
onSetFilter(defId, next.length > 0 ? next : null)
}
const handleSingleToggle = (defId, optValue) => {
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
}
const handleBoolToggle = defId => {
onSetFilter(defId, activeFilters[defId] ? null : true)
}
const handleDateRangePreset = (defId, presetValue) => {
const current = activeFilters[defId] || {}
if (current.preset === presetValue) {
onSetFilter(defId, null)
return
}
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
}
const handleDateRangeInput = (defId, field, dateStr) => {
const current = activeFilters[defId] || {}
if (!dateStr) {
const next = { ...current, preset: null, [field]: null }
onSetFilter(defId, next.from || next.to ? next : null)
} else {
const iso =
field === 'to'
? new Date(dateStr + 'T23:59:59').toISOString()
: new Date(dateStr + 'T00:00:00').toISOString()
onSetFilter(defId, { ...current, preset: null, [field]: iso })
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<Button
size='sm'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{ borderRadius: 'xl', gap: 0.5 }}
>
Filters
</Button>
</Badge>
{(() => {
const activeChips = filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
const MAX_VISIBLE = 2
const visible = activeChips.slice(0, MAX_VISIBLE)
const overflow = activeChips.length - MAX_VISIBLE
return (
<>
{visible.map(({ def, label }) => (
<Chip
key={def.id}
size='md'
variant='soft'
color='primary'
endDecorator={
<Close
sx={{ cursor: 'pointer' }}
onClick={e => {
e.stopPropagation()
onSetFilter(def.id, null)
}}
/>
}
onClick={() => setIsOpen(true)}
sx={{
py: 0.64,
cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size='md'
variant='soft'
color='neutral'
onClick={() => setIsOpen(true)}
sx={{ py: 0.64, cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }}
>
+{overflow} more
</Chip>
)}
</>
)
})()}
{hasActive && (
<Button
size='sm'
variant='plain'
color='neutral'
sx={{ px: 0.5, fontSize: '0.75rem', color: 'text.secondary', minHeight: 0 }}
onClick={onClearAll}
>
Clear all
</Button>
)}
{hasActive && resultCount !== undefined && totalCount !== undefined && (
<Typography level='body-xs' sx={{ color: 'text.tertiary', ml: 'auto', flexShrink: 0 }}>
{resultCount} / {totalCount}
</Typography>
)}
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal
open={isOpen}
onClose={() => setIsOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
{activeFilterCount}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasActive}
onClick={onClearAll}
>
Clear all
</Button>
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
{resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{filterDefs.map((def, idx) => (
<Box key={def.id}>
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{def.icon && (
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
{def.icon}
</Box>
)}
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{def.label}
</Typography>
{/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
{activeFilters[def.id].length} selected
</Chip>
)}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}>
{getActiveChipLabel(def)}
</Chip>
)}
</Box>
{/* multi-select */}
{def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* single-select */}
{def.type === 'single-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = activeFilters[def.id] === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* boolean */}
{def.type === 'boolean' && (
<Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleBoolToggle(def.id)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
>
{def.label}
</Chip>
)}
{/* date-range */}
{def.type === 'date-range' && (() => {
const val = activeFilters[def.id] || {}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box>
)
})()}
</Box>
))}
</Box>
</BottomSheetModal>
</>
)
}
export default FilterBar

59
src/hooks/useFilter.js Normal file
View File

@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
/**
* Generic client-side filter hook.
*
* @param {Array} data - the full list to filter
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
*
* Each filterDef must include:
* id - unique string key
* type - 'multi-select' | 'boolean'
* filterFn - (item, filterValue) => boolean
*/
export const useFilter = (data, filterDefs) => {
const [activeFilters, setActiveFilters] = useState({})
const setFilter = (filterId, value) => {
setActiveFilters(prev => {
const isEmpty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0)
if (isEmpty) {
const { [filterId]: _removed, ...rest } = prev
return rest
}
return { ...prev, [filterId]: value }
})
}
const clearAll = () => setActiveFilters({})
const filteredData = useMemo(() => {
if (!data) return []
if (!Object.keys(activeFilters).length) return data
return data.filter(item =>
filterDefs.every(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return true
if (Array.isArray(value) && value.length === 0) return true
return def.filterFn(item, value)
}),
)
}, [data, activeFilters, filterDefs])
const activeFilterCount = Object.keys(activeFilters).length
return {
filteredData,
activeFilters,
setFilter,
clearAll,
activeFilterCount,
hasActiveFilters: activeFilterCount > 0,
}
}

View File

@@ -4,6 +4,9 @@ import {
CheckBoxOutlineBlank,
Close,
Delete,
Label,
Person,
PriorityHigh,
SelectAll,
Unarchive,
ViewAgenda,
@@ -21,14 +24,17 @@ import {
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import ChoreCard from './ChoreCard'
@@ -61,6 +67,95 @@ const ArchivedTasks = () => {
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
// Unique labels present across all archived chores
const availableLabels = useMemo(() => {
const seen = {}
archivedChores.forEach(c => {
c.labelsV2?.forEach(l => { seen[l.id] = l })
})
return Object.values(seen)
}, [archivedChores])
const filterDefs = useMemo(
() => [
{
id: 'assignee',
label: 'Assignee',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.assignedTo),
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(availableLabels.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Label />,
options: availableLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'archivedAt',
label: 'Archived Date',
type: 'date-range',
icon: <Archive />,
filterFn: (item, value) => {
const date = new Date(item.updatedAt)
if (value.from && date < new Date(value.from)) return false
if (value.to && date > new Date(value.to)) return false
return true
},
},
],
[performers, availableLabels],
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
hasActiveFilters,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
@@ -280,11 +375,8 @@ const ArchivedTasks = () => {
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id)))
}
}
@@ -605,6 +697,15 @@ const ArchivedTasks = () => {
</Box>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={finalChores.length}
totalCount={filteredChores.length}
/>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
@@ -677,7 +778,7 @@ const ArchivedTasks = () => {
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
disabled={selectedChores.size === finalChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
@@ -808,7 +909,7 @@ const ArchivedTasks = () => {
)}
{/* Content */}
{filteredChores.length === 0 ? (
{finalChores.length === 0 ? (
<Box
sx={{
display: 'flex',
@@ -818,42 +919,43 @@ const ArchivedTasks = () => {
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
{searchTerm || hasActiveFilters
? 'No archived tasks found'
: 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: 'Archived tasks will appear here when you archive them from the main task list'}
</Typography>
{searchTerm && (
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
Clear search
</Button>
)}
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{finalChores.length} archived task
{finalChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
<ChoreListView
chores={filteredChores}
chores={finalChores}
// viewOnly={true}
showActions={false}
viewMode={viewMode}

View File

@@ -2,12 +2,10 @@ import {
Add,
Bolt,
CalendarMonth,
CancelRounded,
CheckBox,
CheckBoxOutlineBlank,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
Sort,
Style,
@@ -24,9 +22,6 @@ import {
Container,
Divider,
IconButton,
List,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
@@ -42,7 +37,9 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
@@ -107,7 +104,6 @@ const MyChores = () => {
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [showSearchFilter, setShowSearchFilter] = useState(false)
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
@@ -135,15 +131,12 @@ const MyChores = () => {
const {
searchTerm,
searchFilter,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm,
setSearchFilter,
setSelectedChoreFilterWithCache,
clearFilters,
} = useChoreFilters({
chores,
selectedProject,
@@ -193,6 +186,103 @@ const MyChores = () => {
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const quickFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Due Date',
type: 'single-select',
icon: <CalendarMonth />,
options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' },
{ value: 'Due Later', label: 'Due Later' },
{ value: 'No Due Date', label: 'No Due Date' },
{ value: 'Pending Approval', label: 'Pending Approval' },
],
filterFn: (item, value) => {
const now = new Date()
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
switch (value) {
case 'Overdue':
return d !== null && d < now
case 'Due today':
return d !== null && d.toDateString() === now.toDateString()
case 'Due in week':
return (
d !== null &&
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
d > now
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
case 'Pending Approval':
return item.status === 3
default:
return true
}
},
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
],
[userLabels],
)
const {
filteredData: quickFilteredChores,
activeFilters: quickFilters,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
if (!choresData?.res) {
return []
@@ -222,9 +312,9 @@ const MyChores = () => {
if (tempFilter || activeFilterId) {
// Advanced/custom filter active
choresToGroup = customFilteredChores
} else if (searchFilter !== 'All') {
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
choresToGroup = filteredChores
} else if (hasQuickFilters) {
// Quick filter active (Due date, Priority, Labels)
choresToGroup = quickFilteredChores
} else if (!selectedProject || selectedProject.id === 'default') {
// No project selected or default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
@@ -244,11 +334,11 @@ const MyChores = () => {
return sections
}, [
chores,
filteredChores,
quickFilteredChores,
customFilteredChores,
tempFilter,
activeFilterId,
searchFilter,
hasQuickFilters,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
@@ -412,7 +502,7 @@ const MyChores = () => {
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
if (oldFilter && !hasQuickFilters && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
@@ -423,12 +513,8 @@ const MyChores = () => {
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName && FILTERS[filterName]) {
const filtered = FILTERS[filterName](
selectedProject ? projectFilteredChores : chores,
)
setFilteredChores(filtered)
setSearchFilter(filterName)
if (filterName) {
setQuickFilter('status', filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
@@ -436,7 +522,7 @@ const MyChores = () => {
}, [
searchParams,
chores,
searchFilter,
hasQuickFilters,
activeFilterId,
savedFilters,
applyCustomFilter,
@@ -444,8 +530,7 @@ const MyChores = () => {
clearActiveFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
setFilteredChores,
setQuickFilter,
setViewMode,
setSelectedCalendarDate,
])
@@ -510,13 +595,47 @@ const MyChores = () => {
clearSelection,
})
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: projectFilteredChores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchTerm,
])
const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
choreSections,
openChoreSections,
handlers: {
@@ -567,25 +686,10 @@ const MyChores = () => {
const handleLabelFiltering = chipClicked => {
clearActiveFilter()
const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) {
const label = chipClicked.label
const labelFiltered = baseChores.filter(chore =>
chore.labelsV2.some(
l => l.id === label.id && l.created_by === label.created_by,
),
)
setFilteredChores(labelFiltered)
setSearchFilter('Label: ' + label.name)
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
const priority = chipClicked.priority
const priorityFiltered = baseChores.filter(
chore => chore.priority === priority,
)
setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority)
setQuickFilter('priority', [chipClicked.priority])
}
setSelectedCalendarDate(null)
}
@@ -637,8 +741,8 @@ const MyChores = () => {
const handleSearchChange = e => {
clearActiveFilter()
if (searchFilter !== 'All') {
setSearchFilter('All')
if (hasQuickFilters) {
clearQuickFilters()
}
const search = e.target.value
if (search === '') {
@@ -754,42 +858,6 @@ const MyChores = () => {
// )
// }
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
let baseChores = projectFilteredChores
if (searchTerm?.length > 0 || searchFilter !== 'All') {
if (searchTerm?.length > 0) {
const projectFilteredForSearch = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(projectFilteredForSearch, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
} else if (searchFilter !== 'All') {
return filteredChores
}
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
projectFilteredChores,
searchTerm,
searchFilter,
filteredChores,
])
const getChoresForDate = useCallback(
date => {
const filteredChoresData = getFilteredChores
@@ -814,7 +882,7 @@ const MyChores = () => {
setChores(newChores)
setFilteredChores(newChores)
setSearchFilter('All')
clearQuickFilters()
}
// Show error state when API is unreachable
@@ -902,7 +970,6 @@ const MyChores = () => {
value={searchTerm}
onChange={handleSearchChange}
onClose={handleSearchClose}
onFocus={() => setShowSearchFilter(true)}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
@@ -924,7 +991,7 @@ const MyChores = () => {
onItemSelect={selected => {
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
setSearchFilter('All')
clearQuickFilters()
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
@@ -1008,163 +1075,22 @@ const MyChores = () => {
</Box>
</Box>
{/* Search Filter with animation */}
<Box
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: showSearchFilter ? '150px' : '0',
opacity: showSearchFilter ? 1 : 0,
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
marginBottom: showSearchFilter ? 1 : 0,
{/* Quick Filters */}
<FilterBar
filterDefs={quickFilterDefs}
activeFilters={quickFilters}
onSetFilter={(id, value) => {
clearActiveFilter()
setQuickFilter(id, value)
setSelectedCalendarDate(null)
}}
>
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
label={' Priority'}
k={'icon-menu-priority-filter'}
icon={<PriorityHigh />}
options={Priorities}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={searchFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
k={'icon-menu-labels-filter'}
label={' Labels'}
icon={<Style />}
options={userLabels}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={searchFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<Button
onClick={handleFilterMenuOpen}
variant='outlined'
startDecorator={<Grain />}
color={
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{' Other'}
</Button>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const baseChores = selectedProject
? projectFilteredChores
: chores
const filteredChores =
filterFunction.length === 2
? filterFunction(baseChores, userProfile?.id)
: filterFunction(baseChores)
setFilteredChores(filteredChores)
setSearchFilter(filter)
handleFilterMenuClose()
// Update URL with legacy filter parameter
const filterMap = {
'No Due Date': 'unplanned',
Overdue: 'overdue',
'Due today': 'today',
'Due in week': 'week',
'Due Later': 'later',
'Pending Approval': 'pending',
}
const urlFilter = filterMap[filter]
if (urlFilter) {
updateFilterUrl('filter', urlFilter)
}
}}
>
{filter}
<Chip
color={searchFilter === filter ? 'primary' : 'neutral'}
>
{(() => {
const baseChores = selectedProject
? projectFilteredChores
: chores
return FILTERS[filter].length === 2
? FILTERS[filter](baseChores, userProfile?.id)
.length
: FILTERS[filter](baseChores).length
})()}
</Chip>
</MenuItem>
))}
{searchFilter.startsWith('Label: ') ||
(searchFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
</div>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setShowSearchFilter(false)
setSearchTerm('')
setFilteredChores(chores)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
<CancelRounded />
</IconButton>
</div>
</Box>
onClearAll={() => {
clearQuickFilters()
updateFilterUrl(null, null)
}}
resultCount={hasQuickFilters ? getFilteredChores.length : undefined}
totalCount={hasQuickFilters ? projectFilteredChores.length : undefined}
/>
{/* Custom Filters Section */}
<FilterSection
@@ -1177,7 +1103,7 @@ const MyChores = () => {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
@@ -1218,41 +1144,15 @@ const MyChores = () => {
onDelete={handleBulkDelete}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || searchFilter !== 'All'
? selectedChores.size === filteredChores.length
searchTerm?.length > 0 || hasQuickFilters
? selectedChores.size === getFilteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
/>
{/* Additional Filters Display */}
{searchFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={searchFilter}
onDelete={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Additional Filter: {searchFilter}
</Chip>
)}
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
@@ -1280,10 +1180,9 @@ const MyChores = () => {
<>
<Button
onClick={() => {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
// reset project and filters :
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
@@ -1735,59 +1634,4 @@ const MyChores = () => {
)
}
const FILTERS = {
All: function (chores) {
return chores
},
Overdue: function (chores) {
return chores.filter(chore => {
if (chore.nextDueDate === null) return false
return new Date(chore.nextDueDate) < new Date()
})
},
'Due today': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
)
})
},
'Due in week': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
)
})
},
'Due Later': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
)
})
},
'Created By Me': function (chores, userID) {
return chores.filter(chore => {
return chore.createdBy === userID
})
},
'Assigned To Me': function (chores, userID) {
return chores.filter(chore => {
return chore.assignedTo === userID
})
},
'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})
},
'Pending Approval': function (chores) {
return chores.filter(chore => {
return chore.status === 3
})
},
}
export default MyChores

File diff suppressed because it is too large Load Diff