From fe22bb10352be140f64454d691b6a26420e3bc99 Mon Sep 17 00:00:00 2001 From: Mo Tarbin Date: Fri, 12 Jun 2026 21:31:17 -0400 Subject: [PATCH] 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. --- src/components/common/FilterBar.jsx | 502 +++++++ src/hooks/useFilter.js | 59 + src/views/Chores/ArchivedTasks.jsx | 160 ++- src/views/Chores/MyChores.jsx | 498 +++---- .../Modals/Inputs/AdvancedFilterBuilder.jsx | 1178 +++++++---------- 5 files changed, 1344 insertions(+), 1053 deletions(-) create mode 100644 src/components/common/FilterBar.jsx create mode 100644 src/hooks/useFilter.js diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx new file mode 100644 index 0000000..65f49d8 --- /dev/null +++ b/src/components/common/FilterBar.jsx @@ -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 ─────────────────────────────────────── */} + + + + + + {(() => { + 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 }) => ( + { + 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} + + ))} + + {overflow > 0 && ( + setIsOpen(true)} + sx={{ py: 0.64, cursor: 'pointer', transition: 'all 0.15s ease', '&:hover': { opacity: 0.85 } }} + > + +{overflow} more + + )} + + ) + })()} + + {hasActive && ( + + )} + + {hasActive && resultCount !== undefined && totalCount !== undefined && ( + + {resultCount} / {totalCount} + + )} + + + {/* ── Bottom sheet ────────────────────────────────────── */} + setIsOpen(false)} + title={ + + + Filters + {hasActive && ( + + {activeFilterCount} + + )} + + } + footer={ + + + + + } + > + + {filterDefs.map((def, idx) => ( + + {idx > 0 && } + + {/* Section header */} + + {def.icon && ( + + {def.icon} + + )} + + {def.label} + + + {/* active badge in header */} + {def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && ( + + {activeFilters[def.id].length} selected + + )} + {def.type === 'single-select' && activeFilters[def.id] != null && (() => { + const opt = def.options?.find(o => o.value === activeFilters[def.id]) + return opt ? ( + + {opt.label} + + ) : null + })()} + {def.type === 'date-range' && getActiveChipLabel(def) && ( + + {getActiveChipLabel(def)} + + )} + + + {/* multi-select */} + {def.type === 'multi-select' && ( + + {def.options?.map(opt => { + const isSelected = (activeFilters[def.id] || []).includes(opt.value) + return ( + + ) : isSelected ? ( + + ) : (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} + + ) + })} + + )} + + {/* single-select */} + {def.type === 'single-select' && ( + + {def.options?.map(opt => { + const isSelected = activeFilters[def.id] === opt.value + return ( + + ) : isSelected ? ( + + ) : (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} + + ) + })} + + )} + + {/* boolean */} + {def.type === 'boolean' && ( + : null} + onClick={() => handleBoolToggle(def.id)} + sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + > + {def.label} + + )} + + {/* date-range */} + {def.type === 'date-range' && (() => { + const val = activeFilters[def.id] || {} + return ( + + {/* Preset chips */} + + {DATE_RANGE_PRESETS.map(preset => { + const isSelected = val.preset === preset.value + return ( + : null} + onClick={() => handleDateRangePreset(def.id, preset.value)} + sx={{ cursor: 'pointer', transition: 'all 0.15s ease', userSelect: 'none', '&:hover': { opacity: 0.85 } }} + > + {preset.label} + + ) + })} + + + {/* Custom date inputs */} + + handleDateRangeInput(def.id, 'from', e.target.value)} + slotProps={{ input: { max: toInputDate(val.to) || undefined } }} + sx={{ flex: 1, fontSize: '0.8rem' }} + /> + + – + + handleDateRangeInput(def.id, 'to', e.target.value)} + slotProps={{ input: { min: toInputDate(val.from) || undefined } }} + sx={{ flex: 1, fontSize: '0.8rem' }} + /> + + + ) + })()} + + ))} + + + + ) +} + +export default FilterBar diff --git a/src/hooks/useFilter.js b/src/hooks/useFilter.js new file mode 100644 index 0000000..18527de --- /dev/null +++ b/src/hooks/useFilter.js @@ -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, + } +} diff --git a/src/views/Chores/ArchivedTasks.jsx b/src/views/Chores/ArchivedTasks.jsx index afc12cb..e78794c 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -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: , + 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: , + 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: