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 { import {
Avatar, Avatar,
Badge, Badge,
@@ -11,6 +11,7 @@ import {
} from '@mui/joy' } from '@mui/joy'
import { useState } from 'react' import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal' import BottomSheetModal from './BottomSheetModal'
import ActiveFilterChips from './filter/ActiveFilterChips'
/** /**
* Reusable filter bar component. * Reusable filter bar component.
@@ -134,6 +135,47 @@ const FilterBar = ({
const hasActive = activeFilterCount > 0 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 ───────────────────────────────────────────── // ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => { const getActiveChipLabel = def => {
@@ -223,85 +265,47 @@ const FilterBar = ({
color='primary' color='primary'
size='sm' size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }} anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
> >
<Button <Button
size='sm' size='md'
variant={hasActive ? 'solid' : 'outlined'} variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'} color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />} startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)} 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 Filters
</Button> </Button>
</Badge> </Badge>
{(() => { <ActiveFilterChips
const activeChips = filterDefs chips={filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) })) .map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label) .filter(({ label }) => !!label)
const MAX_VISIBLE = 2 .map(({ def, label }) => ({
const visible = activeChips.slice(0, MAX_VISIBLE) key: def.id,
const overflow = activeChips.length - MAX_VISIBLE label,
onClear: () => onSetFilter(def.id, null),
return ( }))}
<> onOpen={() => setIsOpen(true)}
{visible.map(({ def, label }) => ( onClearAll={hasActive ? onClearAll : undefined}
<Chip resultCount={hasActive ? resultCount : undefined}
key={def.id} totalCount={hasActive ? totalCount : undefined}
size='md' maxVisible={2}
variant='soft' chipSize='md'
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> </Box>
{/* ── Bottom sheet ────────────────────────────────────── */} {/* ── Bottom sheet ────────────────────────────────────── */}
@@ -313,7 +317,7 @@ const FilterBar = ({
<Tune sx={{ fontSize: 20 }} /> <Tune sx={{ fontSize: 20 }} />
Filters Filters
{hasActive && ( {hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}> <Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
{activeFilterCount} {activeFilterCount}
</Chip> </Chip>
)} )}
@@ -356,20 +360,20 @@ const FilterBar = ({
{/* active badge in header */} {/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && ( {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 {activeFilters[def.id].length} selected
</Chip> </Chip>
)} )}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => { {def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id]) const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? ( 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} {opt.label}
</Chip> </Chip>
) : null ) : null
})()} })()}
{def.type === 'date-range' && getActiveChipLabel(def) && ( {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)} {getActiveChipLabel(def)}
</Chip> </Chip>
)} )}
@@ -393,7 +397,7 @@ const FilterBar = ({
) : (opt.icon ?? null) ) : (opt.icon ?? null)
} }
onClick={() => handleMultiToggle(def.id, opt.value)} onClick={() => handleMultiToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} sx={selectableChipSx}
> >
{opt.label} {opt.label}
</Chip> </Chip>
@@ -420,7 +424,7 @@ const FilterBar = ({
) : (opt.icon ?? null) ) : (opt.icon ?? null)
} }
onClick={() => handleSingleToggle(def.id, opt.value)} onClick={() => handleSingleToggle(def.id, opt.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} sx={selectableChipSx}
> >
{opt.label} {opt.label}
</Chip> </Chip>
@@ -436,7 +440,7 @@ const FilterBar = ({
color={activeFilters[def.id] ? 'primary' : 'neutral'} color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null} startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleBoolToggle(def.id)} onClick={() => handleBoolToggle(def.id)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} sx={selectableChipSx}
> >
{def.label} {def.label}
</Chip> </Chip>
@@ -458,7 +462,7 @@ const FilterBar = ({
color={isSelected ? 'primary' : 'neutral'} color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null} startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)} onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} sx={selectableChipSx}
> >
{preset.label} {preset.label}
</Chip> </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 || []} labels={userLabels || []}
projects={projectsWithDefault} projects={projectsWithDefault}
tempFilter={tempFilter} tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter} applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter} clearTempFilter={clearTempFilter}
saveFilter={saveFilter} saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name => onFilterSaved={name =>
showSuccess({ showSuccess({
title: 'Filter Saved', title: 'Filter Saved',

View File

@@ -23,9 +23,7 @@ import {
Check, Check,
CheckBox, CheckBox,
CheckBoxOutlineBlank, CheckBoxOutlineBlank,
Close,
FilterList, FilterList,
FolderOpen,
Save, Save,
Sort, Sort,
Tune, Tune,
@@ -46,17 +44,23 @@ import {
MenuItem, MenuItem,
Typography, Typography,
} from '@mui/joy' } from '@mui/joy'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal' import BottomSheetModal from '../../../components/common/BottomSheetModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex' import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint' import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors' import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import FilterBuilderContent, { import FilterBuilderContent, {
CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections, conditionsToSelections,
defaultSelections, defaultSelections,
selectionsToConditions, selectionsToConditions,
} from './FilterBuilderContent' } from './FilterBuilderContent'
import SearchBar from './SearchBar' import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
// ─── sub-components for the Display sheet ──────────────────────────────────── // ─── sub-components for the Display sheet ────────────────────────────────────
@@ -133,9 +137,11 @@ const OptionChips = ({ options, selected, multi, onToggle }) => (
* labels user labels for Labels section * labels user labels for Labels section
* projects projects list (projectsWithDefault) for Projects section + Display sheet * projects projects list (projectsWithDefault) for Projects section + Display sheet
* tempFilter current temp filter object { conditions, operator } or null * 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 * applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void * clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter * 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) * onFilterSaved (name) => void — called after successful save (for notifications)
* *
* -- Result counts -- * -- Result counts --
@@ -176,9 +182,11 @@ const ChoreToolbar = ({
labels = [], labels = [],
projects = [], projects = [],
tempFilter, tempFilter,
tempFilterMeta,
applyTempFilter, applyTempFilter,
clearTempFilter, clearTempFilter,
saveFilter, saveFilter,
updateFilter,
onFilterSaved, onFilterSaved,
// result counts // result counts
resultCount, resultCount,
@@ -219,7 +227,9 @@ const ChoreToolbar = ({
const [savingFilter, setSavingFilter] = useState(false) const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('') const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null) const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null) const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ───────────────────────────────────────────────────────────── // ── badge counts ─────────────────────────────────────────────────────────────
@@ -232,46 +242,166 @@ const ChoreToolbar = ({
const inlineChips = [] const inlineChips = []
if (savedFilterActive) { const getConditionChipLabel = condition => {
const sf = savedFilters.find(f => f.id === activeFilterId) if (!condition?.type) return 'Filter'
if (sf) {
inlineChips.push({ const typeLabels = {
key: '__saved', assignee: 'Assignee',
label: sf.name, createdBy: 'Created By',
onClear: () => onSavedFilterClick?.(activeFilterId), 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({ inlineChips.push({
key: '__temp', key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: `${tempConditionCount} condition${tempConditionCount !== 1 ? 's' : ''}`, label: getConditionChipLabel(condition),
onClear: () => { onClear: () => {
setLocalSelections(defaultSelections()) if (savedFilterActive) {
clearTempFilter?.() onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
}, },
}) })
} })
// ── open filter sheet ──────────────────────────────────────────────────────── // ── open filter sheet ────────────────────────────────────────────────────────
const openFilterSheet = () => { const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) { if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions)) 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) { } else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId) const sf = savedFilters.find(f => f.id === activeFilterId)
setEditingSavedFilter(sf || null)
setLocalSelections( setLocalSelections(
sf?.conditions sf?.conditions
? conditionsToSelections(sf.conditions) ? conditionsToSelections(sf.conditions)
: defaultSelections(), : defaultSelections(),
) )
} else { } else {
setEditingSavedFilter(null)
setLocalSelections(defaultSelections()) setLocalSelections(defaultSelections())
} }
setSavingFilter(false) setSavingFilter(false)
setSaveFilterName('') setSaveFilterName('')
setSaveMenuAnchorEl(null)
setFilterSheetOpen(true) setFilterSheetOpen(true)
} }
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
// ── selection changes → apply temp filter immediately ──────────────────────── // ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => { const handleSelectionsChange = updater => {
@@ -279,7 +409,20 @@ const ChoreToolbar = ({
const next = typeof updater === 'function' ? updater(prev) : updater const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next) const conditions = selectionsToConditions(next)
if (conditions.length > 0) { 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 { } else {
clearTempFilter?.() clearTempFilter?.()
} }
@@ -310,6 +453,31 @@ const ChoreToolbar = ({
setFilterSheetOpen(false) 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 ──────────────────────────────────────────────────── // ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0 const filterActive = activeFilterId != null || tempConditionCount > 0
@@ -341,13 +509,6 @@ const ChoreToolbar = ({
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> }, { 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 ( return (
<> <>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */} {/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
@@ -387,6 +548,15 @@ const ChoreToolbar = ({
</IconButton> </IconButton>
</Badge> </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 */} {/* Display button — View + Group combined */}
<IconButton <IconButton
variant={displayActive ? 'solid' : 'outlined'} variant={displayActive ? 'solid' : 'outlined'}
@@ -431,74 +601,29 @@ const ChoreToolbar = ({
{/* ── Row 2: active filter chips ──────────────────────────────────────── */} {/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && ( {hasAnyActive && (
<Box <ActiveFilterChips
sx={{ chips={inlineChips}
display: 'flex', onOpen={openFilterSheet}
alignItems: 'center', onClearAll={() => {
gap: 1, setLocalSelections(defaultSelections())
flexWrap: 'nowrap', onClearAllFilters?.()
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
}} }}
> resultCount={resultCount}
{inlineChips.map(({ key, label, onClear }) => ( totalCount={totalCount}
<Chip maxVisible={2}
key={key} chipSize='md'
size='sm' clearButtonSize='sm'
variant='soft' clearButtonSx={{ color: 'text.secondary' }}
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>
)} )}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */} {/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal <BottomSheetModal
open={filterSheetOpen} open={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)} onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
maxHeight='92vh' maxHeight='92vh'
title={ title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
@@ -557,6 +682,7 @@ const ChoreToolbar = ({
disabled={!hasAnyActive && activeConditions.length === 0} disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => { onClick={() => {
setLocalSelections(defaultSelections()) setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
onClearAllFilters?.() onClearAllFilters?.()
setFilterSheetOpen(false) setFilterSheetOpen(false)
}} }}
@@ -568,7 +694,10 @@ const ChoreToolbar = ({
<> <>
<ButtonGroup variant='solid' color='primary'> <ButtonGroup variant='solid' color='primary'>
<Button <Button
onClick={() => setFilterSheetOpen(false)} onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
{resultCount != null {resultCount != null
@@ -590,14 +719,26 @@ const ChoreToolbar = ({
placement='top-end' placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }} sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
> >
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save Filter
</MenuItem>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
setSaveMenuAnchorEl(null) setSaveMenuAnchorEl(null)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
: '',
)
setSavingFilter(true) setSavingFilter(true)
}} }}
> >
<Save sx={{ fontSize: 16, mr: 1 }} /> <Save sx={{ fontSize: 16, mr: 1 }} />
Save as Filter Save as New Filter
</MenuItem> </MenuItem>
</Menu> </Menu>
</> </>
@@ -605,7 +746,10 @@ const ChoreToolbar = ({
<Button <Button
variant='solid' variant='solid'
color='primary' color='primary'
onClick={() => setFilterSheetOpen(false)} onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }} sx={{ minWidth: 140 }}
> >
Done Done
@@ -767,26 +911,6 @@ const ChoreToolbar = ({
onToggle={v => onAssigneeFilterChange?.(v)} 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> </Box>
</BottomSheetModal> </BottomSheetModal>
</> </>

View File

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

View File

@@ -2,14 +2,18 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import { import {
AccessTime, AccessTime,
CalendarMonth,
Check, Check,
Checklist,
EventBusy, EventBusy,
EventNote, EventNote,
Group, Group,
HourglassEmpty, HourglassEmpty,
Person,
Redo, Redo,
RunningWithErrors, RunningWithErrors,
Schedule, Schedule,
Style,
ThumbDown, ThumbDown,
Timeline, Timeline,
Toll, Toll,
@@ -24,23 +28,20 @@ import {
Divider, Divider,
Grid, Grid,
Link, Link,
Option,
Select,
Stack, Stack,
Tab,
TabList,
Tabs,
Typography, Typography,
} from '@mui/joy' } 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 { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries' import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal' import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores' import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx' import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading' import LoadingComponent from '../components/Loading'
const groupByDate = history => { const groupByDate = history => {
@@ -416,6 +417,7 @@ const UserActivites = () => {
choresAssigneeBreakdownChartData, choresAssigneeBreakdownChartData,
setChoresAssigneeBreakdownChartData, setChoresAssigneeBreakdownChartData,
] = React.useState([]) ] = React.useState([])
const { data: userLabels } = useLabels()
const { data: choresData, isLoading: isChoresLoading } = useChores(true) const { data: choresData, isLoading: isChoresLoading } = useChores(true)
const { const {
data: choresHistory, data: choresHistory,
@@ -432,6 +434,142 @@ const UserActivites = () => {
} }
}, [circleMembersData]) }, [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(() => { useEffect(() => {
if ( if (
!isChoresHistoryLoading && !isChoresHistoryLoading &&
@@ -444,6 +582,7 @@ const UserActivites = () => {
return { return {
...item, ...item,
choreName: chore?.name, choreName: chore?.name,
labelsV2: chore?.labelsV2,
} }
}) })
setEnrichedHistory(enrichedHistory) setEnrichedHistory(enrichedHistory)
@@ -837,207 +976,14 @@ const UserActivites = () => {
</Stack> </Stack>
</Box> </Box>
{/* Filter Controls - Always visible */} <FilterBar
<Card filterDefs={filterDefs}
variant='outlined' activeFilters={activeFilters}
sx={{ onSetFilter={handleSetFilter}
width: '100%', onClearAll={handleClearAll}
p: 2, resultCount={filteredTimeline.length}
mb: 3, totalCount={selectedHistory.length}
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>
{/* Conditional Content Based on Data Availability */} {/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? ( {!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
@@ -1103,7 +1049,7 @@ const UserActivites = () => {
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */} {/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}> <Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline <ChoreHistoryTimeline
history={selectedHistory} history={filteredTimeline}
onViewNote={notes => { onViewNote={notes => {
setNoteViewerConfig({ setNoteViewerConfig({
isOpen: true, isOpen: true,