diff --git a/src/components/common/FilterBar.jsx b/src/components/common/FilterBar.jsx new file mode 100644 index 0000000..6f221e1 --- /dev/null +++ b/src/components/common/FilterBar.jsx @@ -0,0 +1,506 @@ +import { Check, 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' +import ActiveFilterChips from './filter/ActiveFilterChips' + +/** + * 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 + + 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 => { + 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 ─────────────────────────────────────── */} + + + + + + ({ def, label: getActiveChipLabel(def) })) + .filter(({ label }) => !!label) + .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' + /> + + + {/* ── 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={selectableChipSx} + > + {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={selectableChipSx} + > + {opt.label} + + ) + })} + + )} + + {/* boolean */} + {def.type === 'boolean' && ( + : null} + onClick={() => handleBoolToggle(def.id)} + sx={selectableChipSx} + > + {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={selectableChipSx} + > + {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/components/common/filter/ActiveFilterChips.jsx b/src/components/common/filter/ActiveFilterChips.jsx new file mode 100644 index 0000000..4e06220 --- /dev/null +++ b/src/components/common/filter/ActiveFilterChips.jsx @@ -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 ( + + {visible.map(({ key, label, onClear, color = 'primary' }) => ( + { + 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} + + ))} + + {overflow > 0 && ( + + +{overflow} more + + )} + + {resultCount != null && totalCount != null && ( + + {resultCount} / {totalCount} + + )} + + {onClearAll && ( + + )} + + ) +} + +export default ActiveFilterChips 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 c292ad3..cf118f3 100644 --- a/src/views/Chores/ArchivedTasks.jsx +++ b/src/views/Chores/ArchivedTasks.jsx @@ -1,13 +1,16 @@ import { - Archive, - CheckBox, - CheckBoxOutlineBlank, - Close, - Delete, - SelectAll, - Unarchive, - ViewAgenda, - ViewModule, + Archive, + CheckBox, + CheckBoxOutlineBlank, + Close, + Delete, + Label, + Person, + PriorityHigh, + SelectAll, + Unarchive, + ViewAgenda, + ViewModule, } from '@mui/icons-material' import { Box, @@ -22,15 +25,18 @@ import { } from '@mui/joy' import { useQueryClient } from '@tanstack/react-query' 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 { commandQueue, CommandType } from '../../utils/CommandQueue' import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher' +import Priorities from '../../utils/Priorities' import { offlineDB } from '../../utils/OfflineDB' import LoadingComponent from '../components/Loading' import ConfirmationModal from '../Modals/Inputs/ConfirmationModal' @@ -107,6 +113,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: