feat: Implement advanced filtering in FilterBar and integrate ActiveFilterChips component

- Refactored FilterBar to utilize ActiveFilterChips for displaying active filters.
- Added selectable chip styles and improved hover effects for better UX.
- Enhanced filter handling with new props for managing active filters and results count.
- Introduced new hooks and state management for custom filters in MyChores and UserActivities.
- Updated ChoreToolbar to support new filter functionalities and improved UI components.
- Added client-side filtering capabilities in UserActivities with dynamic filter definitions.
This commit is contained in:
Mo Tarbin
2026-07-02 20:24:05 -04:00
parent 8e5c31f7f3
commit 165c76bb69
6 changed files with 603 additions and 394 deletions

View File

@@ -1,4 +1,4 @@
import { Check, Close, FilterList, Tune } from '@mui/icons-material'
import { Check, FilterList, Tune } from '@mui/icons-material'
import {
Avatar,
Badge,
@@ -11,6 +11,7 @@ import {
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
import ActiveFilterChips from './filter/ActiveFilterChips'
/**
* Reusable filter bar component.
@@ -134,6 +135,47 @@ const FilterBar = ({
const hasActive = activeFilterCount > 0
const selectableChipSx = {
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
alignItems: 'center',
'& .MuiChip-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
}
const sectionBadgeChipSx = {
ml: 'auto',
fontSize: '0.7rem',
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
const modalCountChipSx = {
ml: 0.5,
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
// ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => {
@@ -223,85 +265,47 @@ const FilterBar = ({
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Button
size='sm'
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{ borderRadius: 'xl', gap: 0.5 }}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
{(() => {
const activeChips = filterDefs
<ActiveFilterChips
chips={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>
)}
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
@@ -313,7 +317,7 @@ const FilterBar = ({
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
{activeFilterCount}
</Chip>
)}
@@ -356,20 +360,20 @@ const FilterBar = ({
{/* 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 }}>
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{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 }}>
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{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 }}>
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{getActiveChipLabel(def)}
</Chip>
)}
@@ -393,7 +397,7 @@ const FilterBar = ({
) : (opt.icon ?? null)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
sx={selectableChipSx}
>
{opt.label}
</Chip>
@@ -420,7 +424,7 @@ const FilterBar = ({
) : (opt.icon ?? null)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }}
sx={selectableChipSx}
>
{opt.label}
</Chip>
@@ -436,7 +440,7 @@ const FilterBar = ({
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 } }}
sx={selectableChipSx}
>
{def.label}
</Chip>
@@ -458,7 +462,7 @@ const FilterBar = ({
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 } }}
sx={selectableChipSx}
>
{preset.label}
</Chip>

View File

@@ -0,0 +1,131 @@
import { Close } from '@mui/icons-material'
import { Box, Button, Chip, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
resultCount,
totalCount,
maxVisible = 2,
chipSize = 'md',
clearButtonSize = 'sm',
clearButtonSx,
containerSx,
chipSx,
overflowChipSx,
resultSx,
}) => {
if (!chips.length) {
return null
}
const visible = chips.slice(0, maxVisible)
const overflow = chips.length - maxVisible
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
...containerSx,
}}
>
{visible.map(({ key, label, onClear, color = 'primary' }) => (
<Chip
key={key}
size={chipSize}
variant='soft'
color={color}
endDecorator={
<Close
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
onClick={e => {
e.stopPropagation()
onClear?.()
}}
/>
}
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
alignItems: 'center',
'& .MuiChip-endDecorator': {
display: 'flex',
alignItems: 'center',
ml: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
...chipSx,
}}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size={chipSize}
variant='soft'
color='neutral'
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
'&:hover': { opacity: 0.85 },
...overflowChipSx,
}}
>
+{overflow} more
</Chip>
)}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
ml: 'auto',
flexShrink: 0,
...resultSx,
}}
>
{resultCount} / {totalCount}
</Typography>
)}
{onClearAll && (
<Button
size={clearButtonSize}
variant='plain'
color='neutral'
onClick={onClearAll}
sx={{
px: 0.5,
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
color: 'text.secondary',
minHeight: 0,
flexShrink: 0,
...clearButtonSx,
}}
>
Clear all
</Button>
)}
</Box>
)
}
export default ActiveFilterChips

View File

@@ -951,9 +951,11 @@ const MyChores = () => {
labels={userLabels || []}
projects={projectsWithDefault}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',

View File

@@ -23,9 +23,7 @@ import {
Check,
CheckBox,
CheckBoxOutlineBlank,
Close,
FilterList,
FolderOpen,
Save,
Sort,
Tune,
@@ -46,17 +44,23 @@ import {
MenuItem,
Typography,
} from '@mui/joy'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import FilterBuilderContent, {
CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from './FilterBuilderContent'
import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
// ─── sub-components for the Display sheet ────────────────────────────────────
@@ -133,9 +137,11 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
* labels user labels for Labels section
* projects projects list (projectsWithDefault) for Projects section + Display sheet
* tempFilter current temp filter object { conditions, operator } or null
* tempFilterMeta metadata for temp filter, including saved-filter edit source when applicable
* applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter
* updateFilter (filterId, filterData) => Promise — updates an existing saved filter
* onFilterSaved (name) => void — called after successful save (for notifications)
*
* -- Result counts --
@@ -176,9 +182,11 @@ const ChoreToolbar = ({
labels = [],
projects = [],
tempFilter,
tempFilterMeta,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
onFilterSaved,
// result counts
resultCount,
@@ -219,7 +227,9 @@ const ChoreToolbar = ({
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ─────────────────────────────────────────────────────────────
@@ -232,46 +242,166 @@ const ChoreToolbar = ({
const inlineChips = []
if (savedFilterActive) {
const sf = savedFilters.find(f => f.id === activeFilterId)
if (sf) {
inlineChips.push({
key: '__saved',
label: sf.name,
onClear: () => onSavedFilterClick?.(activeFilterId),
})
const getConditionChipLabel = condition => {
if (!condition?.type) return 'Filter'
const typeLabels = {
assignee: 'Assignee',
createdBy: 'Created By',
status: 'Status',
priority: 'Priority',
label: 'Labels',
project: 'Project',
dueDate: 'Due Date',
points: 'Points',
}
} else if (tempConditionCount > 0) {
const typeLabel = typeLabels[condition.type] || 'Filter'
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
if (condition.type === 'dueDate') {
const dueDateLabel =
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
'Custom'
return `${typeLabel}: ${dueDateLabel}`
}
if (condition.type === 'points') {
const pointsOp =
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
condition.operator ||
''
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
}
const rawValues = Array.isArray(condition.value)
? condition.value
: condition.value != null
? [condition.value]
: []
const resolveLabel = value => {
if (condition.type === 'assignee' || condition.type === 'createdBy') {
const member = members.find(m => m.userId === value)
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
}
if (condition.type === 'label') {
return labels.find(l => l.id === value)?.name || String(value)
}
if (condition.type === 'project') {
if (value === 'default') return 'Default Project'
return projects.find(p => p.id === value)?.name || String(value)
}
return String(value)
}
if (rawValues.length === 0) {
return `${prefix}${typeLabel}`
}
if (rawValues.length === 1) {
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
}
return `${prefix}${typeLabel} (${rawValues.length})`
}
const clearConditionAtIndex = index => {
const nextConditions = (tempFilter?.conditions || []).filter(
(_condition, conditionIndex) => conditionIndex !== index,
)
if (nextConditions.length === 0) {
setLocalSelections(defaultSelections())
clearTempFilter?.()
return
}
const nextFilter = {
...tempFilter,
operator: tempFilter?.operator || 'AND',
conditions: nextConditions,
}
setLocalSelections(conditionsToSelections(nextConditions))
applyTempFilter?.(nextFilter)
}
const activeSavedFilter = savedFilterActive
? savedFilters.find(f => f.id === activeFilterId)
: null
const activeChipConditions = savedFilterActive
? activeSavedFilter?.conditions || []
: tempFilter?.conditions || []
activeChipConditions.forEach((condition, index) => {
inlineChips.push({
key: '__temp',
label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`,
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: getConditionChipLabel(condition),
onClear: () => {
setLocalSelections(defaultSelections())
clearTempFilter?.()
if (savedFilterActive) {
onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
},
})
}
})
// ── open filter sheet ────────────────────────────────────────────────────────
const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) {
const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
null
setEditingSavedFilter(
sourceFilter ||
(tempFilterMeta.sourceFilterId
? {
id: tempFilterMeta.sourceFilterId,
name: tempFilterMeta.sourceFilterName,
description: tempFilterMeta.sourceFilterDescription,
color: tempFilterMeta.sourceFilterColor,
}
: null),
)
} else {
setEditingSavedFilter(null)
}
} else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId)
setEditingSavedFilter(sf || null)
setLocalSelections(
sf?.conditions
? conditionsToSelections(sf.conditions)
: defaultSelections(),
)
} else {
setEditingSavedFilter(null)
setLocalSelections(defaultSelections())
}
setSavingFilter(false)
setSaveFilterName('')
setSaveMenuAnchorEl(null)
setFilterSheetOpen(true)
}
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
// ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => {
@@ -279,7 +409,20 @@ const ChoreToolbar = ({
const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next)
if (conditions.length > 0) {
applyTempFilter?.({ conditions, operator: 'AND' })
applyTempFilter?.(
{ conditions, operator: 'AND' },
editingSavedFilter
? {
name: editingSavedFilter.name,
description: editingSavedFilter.description,
sourceFilterId: editingSavedFilter.id,
sourceFilterName: editingSavedFilter.name,
sourceFilterDescription: editingSavedFilter.description,
sourceFilterColor: editingSavedFilter.color,
isEditingSavedFilter: true,
}
: null,
)
} else {
clearTempFilter?.()
}
@@ -310,6 +453,31 @@ const ChoreToolbar = ({
setFilterSheetOpen(false)
}
const handleUpdateFilter = () => {
if (!editingSavedFilter?.id || !updateFilter) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
updateFilter(
editingSavedFilter.id,
{
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
},
)?.then?.(() => {
clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name)
})
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}
// ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
@@ -341,13 +509,6 @@ const ChoreToolbar = ({
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
]
// Only show project in Display sheet when no advanced filter is active
const showProjectInDisplay =
!filterActive &&
projects.filter(p => p.id !== 'default').length > 0
const activeConditions = selectionsToConditions(localSelections)
return (
<>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
@@ -387,6 +548,15 @@ const ChoreToolbar = ({
</IconButton>
</Badge>
{/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
@@ -431,74 +601,29 @@ const ChoreToolbar = ({
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}
onClearAll={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
>
{inlineChips.map(({ key, label, onClear }) => (
<Chip
key={key}
size='sm'
variant='soft'
color='primary'
endDecorator={
<Close
sx={{ fontSize: 12, cursor: 'pointer' }}
onClick={e => {
e.stopPropagation()
onClear()
}}
/>
}
onClick={openFilterSheet}
sx={{ cursor: 'pointer', flexShrink: 0 }}
>
{label}
</Chip>
))}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{ color: 'text.tertiary', ml: 'auto', flexShrink: 0 }}
>
{resultCount} / {totalCount}
</Typography>
)}
<Button
size='sm'
variant='plain'
color='neutral'
sx={{
px: 0.5,
fontSize: '0.72rem',
color: 'text.tertiary',
minHeight: 0,
flexShrink: 0,
}}
onClick={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
>
Clear all
</Button>
</Box>
resultCount={resultCount}
totalCount={totalCount}
maxVisible={2}
chipSize='md'
clearButtonSize='sm'
clearButtonSx={{ color: 'text.secondary' }}
/>
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
open={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -557,6 +682,7 @@ const ChoreToolbar = ({
disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => {
setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
@@ -568,7 +694,10 @@ const ChoreToolbar = ({
<>
<ButtonGroup variant='solid' color='primary'>
<Button
onClick={() => setFilterSheetOpen(false)}
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
{resultCount != null
@@ -590,14 +719,26 @@ const ChoreToolbar = ({
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save Filter
</MenuItem>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
: '',
)
setSavingFilter(true)
}}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save as Filter
Save as New Filter
</MenuItem>
</Menu>
</>
@@ -605,7 +746,10 @@ const ChoreToolbar = ({
<Button
variant='solid'
color='primary'
onClick={() => setFilterSheetOpen(false)}
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
Done
@@ -767,26 +911,6 @@ const ChoreToolbar = ({
onToggle={v => onAssigneeFilterChange?.(v)}
/>
{/* Project section — only when no advanced filter is active */}
{showProjectInDisplay && (
<>
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FolderOpen />}
label='Project'
badge={projectActive ? selectedProject.name : null}
/>
<OptionChips
options={projects.map(p => ({ value: p.id, label: p.name }))}
selected={selectedProject?.id ?? 'default'}
multi={false}
onToggle={id => {
const p = projects.find(x => x.id === id)
if (p) onProjectSelect?.(p)
}}
/>
</>
)}
</Box>
</BottomSheetModal>
</>

View File

@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
}, [chores, activeFilter, tempFilter, context])
const applyCustomFilter = useCallback(filterId => {
setTempFilter(null)
setTempFilterMeta(null)
setActiveFilterId(filterId)
}, [])

View File

@@ -2,14 +2,18 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import {
AccessTime,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
Group,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Style,
ThumbDown,
Timeline,
Toll,
@@ -24,23 +28,20 @@ import {
Divider,
Grid,
Link,
Option,
Select,
Stack,
Tab,
TabList,
Tabs,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading'
const groupByDate = history => {
@@ -416,6 +417,7 @@ const UserActivites = () => {
choresAssigneeBreakdownChartData,
setChoresAssigneeBreakdownChartData,
] = React.useState([])
const { data: userLabels } = useLabels()
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
const {
data: choresHistory,
@@ -432,6 +434,142 @@ const UserActivites = () => {
}
}, [circleMembersData])
// Client-side filters applied on top of the user+time-window slice
const clientFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <Checklist />,
options: [
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
...(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,
},
]
: []),
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'hasPoints',
label: 'Has Points',
type: 'boolean',
icon: <Toll />,
filterFn: item => (item.points ?? 0) > 0,
},
],
[userLabels],
)
const {
filteredData: filteredTimeline,
activeFilters: clientActiveFilters,
setFilter: setClientFilter,
clearAll: clearClientFilters,
} = useFilter(selectedHistory, clientFilterDefs)
// All filter defs merged for FilterBar display
const filterDefs = useMemo(
() => [
{
id: 'timePeriod',
label: 'Time Period',
type: 'single-select',
icon: <CalendarMonth />,
defaultValue: 7,
options: [
{ value: 7, label: '7 Days' },
{ value: 30, label: '30 Days' },
{ value: 90, label: '90 Days' },
{ value: 365, label: 'All Time' },
],
},
{
id: 'completedBy',
label: 'User',
type: 'single-select',
icon: <Person />,
options: circleUsers.map(u => ({
value: u.userId,
label: u.displayName,
avatar: u.image,
})),
},
...clientFilterDefs,
],
[circleUsers, clientFilterDefs],
)
// Merge server-driven and client-driven active filter states for the bar
const activeFilters = useMemo(
() => ({
timePeriod: tabValue,
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
...clientActiveFilters,
}),
[tabValue, selectedUser, clientActiveFilters],
)
const handleSetFilter = (id, value) => {
if (id === 'completedBy') {
const userId = value ?? 'all'
setSelectedUser(userId)
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
} else if (id === 'timePeriod') {
const days = value ?? 7
setTabValue(days)
refetchHistory(days)
} else {
setClientFilter(id, value)
}
}
const handleClearAll = () => {
setSelectedUser('all')
setSelectedHistory(enrichedHistory)
setTabValue(7)
refetchHistory(7)
clearClientFilters()
}
useEffect(() => {
if (
!isChoresHistoryLoading &&
@@ -444,6 +582,7 @@ const UserActivites = () => {
return {
...item,
choreName: chore?.name,
labelsV2: chore?.labelsV2,
}
})
setEnrichedHistory(enrichedHistory)
@@ -837,207 +976,14 @@ const UserActivites = () => {
</Stack>
</Box>
{/* Filter Controls - Always visible */}
<Card
variant='outlined'
sx={{
width: '100%',
p: 2,
mb: 3,
borderRadius: 12,
background:
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
}}
>
<Stack spacing={2}>
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
Filter Activities
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', sm: 'center' }}
>
{/* User Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Show activities for:
</Typography>
<Select
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
enrichedHistory.filter(h => USER_FILTER(h, selected)),
)
}}
renderValue={() => {
if (selectedUser === undefined || selectedUser === 'all') {
return (
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
)
}
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)
}}
>
<Option value='all'>
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
</Option>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
</Box>
{/* Time Period Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Time period:
</Typography>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
refetchHistory(tabValue)
}}
value={tabValue}
sx={{
borderRadius: 8,
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<TabList
disableUnderline
sx={{
borderRadius: 8,
backgroundColor: 'transparent',
p: 0.5,
gap: 0.5,
}}
>
{[
{ label: '7 Days', value: 7 },
{ label: '30 Days', value: 30 },
{ label: '90 Days', value: 90 },
{ label: 'All Time', value: 365 },
].map((tab, index) => (
<Tab
key={index}
sx={{
borderRadius: 6,
minWidth: 'auto',
px: 2,
py: 1,
fontSize: 'sm',
fontWeight: 500,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.plainColor',
backgroundColor: 'primary.softBg',
fontWeight: 600,
},
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
},
}}
disableIndicator
value={tab.value}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
</Stack>
</Stack>
</Card>
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Showing activities for{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{selectedUser === undefined || selectedUser === 'all'
? 'All Users'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
</Typography>{' '}
over the{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
</Typography>
</Typography>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={handleSetFilter}
onClearAll={handleClearAll}
resultCount={filteredTimeline.length}
totalCount={selectedHistory.length}
/>
{/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
@@ -1103,7 +1049,7 @@ const UserActivites = () => {
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline
history={selectedHistory}
history={filteredTimeline}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,