feat: add custom filter chips and filter section components

- Implemented CustomFilterChips component for displaying and managing custom filters with context menu options for pinning, editing, and deleting filters.
- Created FilterSection component to integrate CustomFilterChips and provide UI for creating advanced filters and displaying active filters.
- Introduced useCustomFilters hook to manage custom filter logic, including loading, saving, updating, and deleting filters.
- Added AdvancedFilterBuilder modal for creating and editing advanced filters with dynamic condition handling.
- Implemented SaveFilterModal for saving filters with preview functionality and validation for filter names.
- Enhanced useChoreFilters and useProjectFilter hooks for improved filter management and state handling.
This commit is contained in:
Mo Tarbin
2026-01-26 01:58:21 -05:00
parent 57bc6ad98e
commit 2b8482cb06
10 changed files with 2329 additions and 41 deletions

View File

@@ -0,0 +1,281 @@
/**
* Custom Filter Storage - Manages saving/loading custom filters
*
* This module handles CRUD operations for custom filters.
* Currently uses localStorage, but designed to easily migrate to backend API.
*/
const STORAGE_KEY = 'customFilters'
const MAX_FILTERS = 20 // Limit to prevent localStorage overflow
/**
* Generate a unique filter ID
*/
const generateFilterId = () => {
return `filter_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
/**
* Get all saved filters
* @returns {Array} - Array of filter objects
*/
export const getSavedFilters = () => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (!stored) return []
const filters = JSON.parse(stored)
// Ensure filters have required fields
return filters.filter(f => f.id && f.name && f.conditions)
} catch (error) {
console.error('Error loading saved filters:', error)
return []
}
}
/**
* Save a new filter
* @param {Object} filter - The filter to save
* @returns {Object} - The saved filter with generated ID and metadata
*/
export const saveFilter = (filter) => {
try {
const filters = getSavedFilters()
// Check limit
if (filters.length >= MAX_FILTERS) {
throw new Error(`Maximum of ${MAX_FILTERS} filters allowed. Please delete some filters first.`)
}
// Create new filter with metadata
const newFilter = {
id: generateFilterId(),
name: filter.name,
icon: filter.icon || null,
conditions: filter.conditions,
operator: filter.operator || 'AND',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
isPinned: filter.isPinned || false,
usageCount: 0,
lastUsedAt: null
}
// Add to filters array
const updatedFilters = [...filters, newFilter]
// Save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
return newFilter
} catch (error) {
console.error('Error saving filter:', error)
throw error
}
}
/**
* Update an existing filter
* @param {string} filterId - The ID of the filter to update
* @param {Object} updates - The fields to update
* @returns {Object} - The updated filter
*/
export const updateFilter = (filterId, updates) => {
try {
const filters = getSavedFilters()
const filterIndex = filters.findIndex(f => f.id === filterId)
if (filterIndex === -1) {
throw new Error('Filter not found')
}
// Update filter
const updatedFilter = {
...filters[filterIndex],
...updates,
updatedAt: new Date().toISOString()
}
filters[filterIndex] = updatedFilter
// Save to localStorage
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
return updatedFilter
} catch (error) {
console.error('Error updating filter:', error)
throw error
}
}
/**
* Delete a filter
* @param {string} filterId - The ID of the filter to delete
* @returns {boolean} - Success status
*/
export const deleteFilter = (filterId) => {
try {
const filters = getSavedFilters()
const updatedFilters = filters.filter(f => f.id !== filterId)
localStorage.setItem(STORAGE_KEY, JSON.stringify(updatedFilters))
return true
} catch (error) {
console.error('Error deleting filter:', error)
throw error
}
}
/**
* Get a single filter by ID
* @param {string} filterId - The ID of the filter
* @returns {Object|null} - The filter object or null if not found
*/
export const getFilterById = (filterId) => {
const filters = getSavedFilters()
return filters.find(f => f.id === filterId) || null
}
/**
* Increment usage count for a filter
* @param {string} filterId - The ID of the filter
*/
export const trackFilterUsage = (filterId) => {
try {
const filters = getSavedFilters()
const filterIndex = filters.findIndex(f => f.id === filterId)
if (filterIndex !== -1) {
filters[filterIndex].usageCount = (filters[filterIndex].usageCount || 0) + 1
filters[filterIndex].lastUsedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
}
} catch (error) {
console.error('Error tracking filter usage:', error)
}
}
/**
* Toggle pin status of a filter
* @param {string} filterId - The ID of the filter
* @returns {boolean} - New pin status
*/
export const toggleFilterPin = (filterId) => {
try {
const filters = getSavedFilters()
const filterIndex = filters.findIndex(f => f.id === filterId)
if (filterIndex === -1) {
throw new Error('Filter not found')
}
filters[filterIndex].isPinned = !filters[filterIndex].isPinned
filters[filterIndex].updatedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEY, JSON.stringify(filters))
return filters[filterIndex].isPinned
} catch (error) {
console.error('Error toggling filter pin:', error)
throw error
}
}
/**
* Get filters sorted by usage (most used first)
* @returns {Array} - Sorted filters
*/
export const getFiltersByUsage = () => {
const filters = getSavedFilters()
return filters.sort((a, b) => (b.usageCount || 0) - (a.usageCount || 0))
}
/**
* Get pinned filters
* @returns {Array} - Pinned filters
*/
export const getPinnedFilters = () => {
const filters = getSavedFilters()
return filters.filter(f => f.isPinned)
}
/**
* Check if filter name already exists
* @param {string} name - The filter name to check
* @param {string} excludeId - Optional ID to exclude from check (for updates)
* @returns {boolean} - Whether the name exists
*/
export const filterNameExists = (name, excludeId = null) => {
const filters = getSavedFilters()
return filters.some(f =>
f.name.toLowerCase() === name.toLowerCase() &&
f.id !== excludeId
)
}
/**
* Export filters as JSON (for backup/sharing)
* @returns {string} - JSON string of all filters
*/
export const exportFilters = () => {
const filters = getSavedFilters()
return JSON.stringify(filters, null, 2)
}
/**
* Import filters from JSON
* @param {string} jsonString - JSON string of filters
* @returns {number} - Number of filters imported
*/
export const importFilters = (jsonString) => {
try {
const importedFilters = JSON.parse(jsonString)
if (!Array.isArray(importedFilters)) {
throw new Error('Invalid filter format')
}
const existingFilters = getSavedFilters()
// Generate new IDs for imported filters to avoid conflicts
const newFilters = importedFilters.map(filter => ({
...filter,
id: generateFilterId(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
usageCount: 0,
lastUsedAt: null
}))
const allFilters = [...existingFilters, ...newFilters]
// Check limit
if (allFilters.length > MAX_FILTERS) {
throw new Error(`Import would exceed maximum of ${MAX_FILTERS} filters`)
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(allFilters))
return newFilters.length
} catch (error) {
console.error('Error importing filters:', error)
throw error
}
}
/**
* Clear all filters (use with caution!)
* @returns {boolean} - Success status
*/
export const clearAllFilters = () => {
try {
localStorage.removeItem(STORAGE_KEY)
return true
} catch (error) {
console.error('Error clearing filters:', error)
throw error
}
}

369
src/utils/FilterEngine.js Normal file
View File

@@ -0,0 +1,369 @@
/**
* Filter Engine - Evaluates filter conditions against chores
*
* This engine takes saved filter conditions and applies them to chores.
* It supports various operators and combines conditions with AND/OR logic.
*/
/**
* Evaluate a single condition against a chore
* @param {Object} chore - The chore to evaluate
* @param {Object} condition - The condition to check
* @param {Object} context - Additional context (userId, members, etc.)
* @returns {boolean} - Whether the chore matches the condition
*/
export const evaluateCondition = (chore, condition, context = {}) => {
const { type, operator, value } = condition
switch (type) {
case 'assignee':
return evaluateAssignee(chore, operator, value, context)
case 'createdBy':
return evaluateCreatedBy(chore, operator, value, context)
case 'priority':
return evaluatePriority(chore, operator, value)
case 'status':
return evaluateStatus(chore, operator, value)
case 'dueDate':
return evaluateDueDate(chore, operator, value)
case 'label':
return evaluateLabel(chore, operator, value)
case 'project':
return evaluateProject(chore, operator, value)
default:
console.warn(`Unknown condition type: ${type}`)
return true
}
}
/**
* Evaluate assignee condition
*/
const evaluateAssignee = (chore, operator, value, context) => {
const { userId } = context
// Handle special values
if (value === 'me' && userId) {
const isAssignedToMe =
String(chore.assignedTo) === String(userId) ||
chore.assignees?.some(a => String(a.userId) === String(userId))
return operator === 'is' ? isAssignedToMe : !isAssignedToMe
}
if (value === 'others' && userId) {
const isAssignedToOthers =
String(chore.assignedTo) !== String(userId) &&
!chore.assignees?.some(a => String(a.userId) === String(userId))
return operator === 'is' ? isAssignedToOthers : !isAssignedToOthers
}
if (value === 'anyone') {
return true
}
// Handle specific user IDs (can be array for multi-select)
const userIds = Array.isArray(value) ? value : [value]
const isAssigned = userIds.some(
id =>
String(chore.assignedTo) === String(id) ||
chore.assignees?.some(a => String(a.userId) === String(id)),
)
return operator === 'is' ? isAssigned : !isAssigned
}
/**
* Evaluate created by condition
*/
const evaluateCreatedBy = (chore, operator, value, context) => {
const { userId } = context
if (value === 'me' && userId) {
return operator === 'is'
? String(chore.createdBy) === String(userId)
: String(chore.createdBy) !== String(userId)
}
const creatorIds = Array.isArray(value) ? value : [value]
const isCreatedBy = creatorIds.some(
id => String(id) === String(chore.createdBy),
)
return operator === 'is' ? isCreatedBy : !isCreatedBy
}
/**
* Evaluate priority condition
*/
const evaluatePriority = (chore, operator, value) => {
const priorities = Array.isArray(value) ? value : [value]
const chorePriority = chore.priority || 0
switch (operator) {
case 'is':
return priorities.some(p => Number(p) === Number(chorePriority))
case 'isNot':
return !priorities.some(p => Number(p) === Number(chorePriority))
case 'greaterThan':
return Number(chorePriority) > Number(value)
case 'lessThan':
return Number(chorePriority) < Number(value)
default:
return false
}
}
/**
* Evaluate status condition
*/
const evaluateStatus = (chore, operator, value) => {
const statuses = Array.isArray(value) ? value : [value]
const choreStatus = chore.status || 0
switch (operator) {
case 'is':
return statuses.some(s => Number(s) === Number(choreStatus))
case 'isNot':
return !statuses.some(s => Number(s) === Number(choreStatus))
default:
return false
}
}
/**
* Evaluate due date condition
*/
const evaluateDueDate = (chore, operator, value) => {
const { nextDueDate } = chore
// Handle "no due date" case
if (operator === 'hasNoDueDate') {
return nextDueDate === null || nextDueDate === undefined
}
if (operator === 'hasDueDate') {
return nextDueDate !== null && nextDueDate !== undefined
}
if (!nextDueDate) return false
const dueDate = new Date(nextDueDate)
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
switch (operator) {
case 'isOverdue':
return dueDate < now
case 'isDueToday':
return dueDate.toDateString() === today.toDateString()
case 'isDueTomorrow':
return dueDate.toDateString() === tomorrow.toDateString()
case 'isDueThisWeek': {
const nextWeek = new Date(today)
nextWeek.setDate(nextWeek.getDate() + 7)
return dueDate >= today && dueDate < nextWeek
}
case 'isDueThisMonth': {
return (
dueDate.getMonth() === today.getMonth() &&
dueDate.getFullYear() === today.getFullYear()
)
}
case 'before': {
const targetDate = value === 'today' ? today : new Date(value)
return dueDate < targetDate
}
case 'after': {
const targetDate = value === 'today' ? today : new Date(value)
return dueDate > targetDate
}
case 'between': {
const [start, end] = value
return dueDate >= new Date(start) && dueDate <= new Date(end)
}
default:
return false
}
}
/**
* Evaluate label condition
*/
const evaluateLabel = (chore, operator, value) => {
const labelIds = Array.isArray(value) ? value : [value]
const choreLabels = chore.labelsV2 || []
const hasLabel = labelIds.some(labelId =>
choreLabels.some(l => String(l.id) === String(labelId)),
)
switch (operator) {
case 'has':
case 'is':
return hasLabel
case 'doesNotHave':
case 'isNot':
return !hasLabel
default:
return false
}
}
/**
* Evaluate project condition
*/
const evaluateProject = (chore, operator, value) => {
const { projectId } = chore
// Convert to array for consistent handling
const projectIds = Array.isArray(value) ? value : [value]
// Check if 'default' is in the selection
const includesDefault = projectIds.includes('default')
// Check if chore is in default project (no projectId or projectId is 'default')
const choreIsDefault =
!projectId || projectId === null || projectId === 'default'
// Check if chore matches any of the non-default project IDs
const otherProjectIds = projectIds.filter(id => id !== 'default')
const matchesOtherProject = otherProjectIds.some(
id => String(projectId) === String(id),
)
// Chore matches if it's default and default is selected, OR if it matches any other selected project
const isInProject = (includesDefault && choreIsDefault) || matchesOtherProject
return operator === 'is' ? isInProject : !isInProject
}
/**
* Apply a complete filter (with multiple conditions) to chores
* @param {Array} chores - The chores to filter
* @param {Object} filter - The filter with conditions
* @param {Object} context - Additional context (userId, members, etc.)
* @returns {Array} - Filtered chores
*/
export const applyFilter = (chores, filter, context = {}) => {
if (!filter || !filter.conditions || filter.conditions.length === 0) {
return chores
}
const { conditions, operator = 'AND' } = filter
return chores.filter(chore => {
if (operator === 'OR') {
// At least one condition must match
return conditions.some(condition =>
evaluateCondition(chore, condition, context),
)
} else {
// All conditions must match (AND)
return conditions.every(condition =>
evaluateCondition(chore, condition, context),
)
}
})
}
/**
* Get count of chores matching a filter
* @param {Array} chores - The chores to count
* @param {Object} filter - The filter to apply
* @param {Object} context - Additional context
* @returns {number} - Count of matching chores
*/
export const getFilterCount = (chores, filter, context = {}) => {
return applyFilter(chores, filter, context).length
}
/**
* Get count of overdue chores matching a filter
* @param {Array} chores - The chores to count
* @param {Object} filter - The filter to apply
* @param {Object} context - Additional context
* @returns {number} - Count of overdue chores
*/
export const getFilterOverdueCount = (chores, filter, context = {}) => {
const filtered = applyFilter(chores, filter, context)
return filtered.filter(chore => {
if (!chore.nextDueDate) return false
return new Date(chore.nextDueDate) < new Date()
}).length
}
/**
* Validate if a filter is still valid
* (e.g., checks if referenced users/labels/projects still exist)
* @param {Object} filter - The filter to validate
* @param {Object} context - Context with available users, labels, projects
* @returns {Object} - { isValid: boolean, issues: Array }
*/
export const validateFilter = (filter, context = {}) => {
const { members = [], labels = [], projects = [] } = context
const issues = []
if (!filter.conditions || filter.conditions.length === 0) {
issues.push('Filter has no conditions')
return { isValid: false, issues }
}
filter.conditions.forEach((condition, index) => {
const { type, value } = condition
// Check assignee references
if (type === 'assignee' && !['me', 'others', 'anyone'].includes(value)) {
const userIds = Array.isArray(value) ? value : [value]
const invalidUsers = userIds.filter(
id => !members.some(m => m.id === id || m.userId === id),
)
if (invalidUsers.length > 0) {
issues.push(`Condition ${index + 1}: User(s) no longer exist`)
}
}
// Check label references
if (type === 'label') {
const labelIds = Array.isArray(value) ? value : [value]
const invalidLabels = labelIds.filter(
id => !labels.some(l => l.id === id),
)
if (invalidLabels.length > 0) {
issues.push(`Condition ${index + 1}: Label(s) no longer exist`)
}
}
// Check project references
if (type === 'project' && value !== 'default') {
const projectIds = Array.isArray(value) ? value : [value]
const invalidProjects = projectIds.filter(
id => !projects.some(p => p.id === id || p.id === Number(id)),
)
if (invalidProjects.length > 0) {
issues.push(`Condition ${index + 1}: Project(s) no longer exist`)
}
}
})
return {
isValid: issues.length === 0,
issues,
}
}

View File

@@ -59,7 +59,20 @@ import TaskInput from '../components/AddTaskModal'
import CalendarDual from '../components/CalendarDual' import CalendarDual from '../components/CalendarDual'
import CalendarMonthly from '../components/CalendarMonthly.jsx' import CalendarMonthly from '../components/CalendarMonthly.jsx'
import ProjectSelector from '../components/ProjectSelector' import ProjectSelector from '../components/ProjectSelector'
import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder'
import SaveFilterModal from '../Modals/Inputs/SaveFilterModal'
import { useProjects } from '../Projects/ProjectQueries.js' import { useProjects } from '../Projects/ProjectQueries.js'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
import { useCustomFilters } from './hooks/useCustomFilters'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useMultiSelect } from './hooks/useMultiSelect'
import { useProjectFilter } from './hooks/useProjectFilter'
import { import {
canScheduleNotification, canScheduleNotification,
scheduleChoreNotification, scheduleChoreNotification,
@@ -67,15 +80,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar' import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel' import Sidepanel from './Sidepanel'
import SortAndGrouping from './SortAndGrouping' import SortAndGrouping from './SortAndGrouping'
import ChoreModals from './components/ChoreModals'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
import { useMultiSelect } from './hooks/useMultiSelect'
import { useProjectFilter } from './hooks/useProjectFilter'
const MyChores = () => { const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } = const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -130,6 +134,7 @@ const MyChores = () => {
selectedChoreFilter, selectedChoreFilter,
projectFilteredChores, projectFilteredChores,
searchFilteredChores, searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm, setSearchTerm,
setSearchFilter, setSearchFilter,
setSelectedChoreFilterWithCache, setSelectedChoreFilterWithCache,
@@ -154,6 +159,31 @@ const MyChores = () => {
const { activeModal, modalChore, modalData, openModal, closeModal } = const { activeModal, modalChore, modalData, openModal, closeModal } =
useChoreModals() useChoreModals()
const {
savedFilters,
activeFilter,
activeFilterId,
filteredChores: customFilteredChores,
applyCustomFilter,
clearActiveFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
} = useCustomFilters(
nonProjectFilteredChores,
membersData?.res,
userLabels,
projectsWithDefault,
)
const [showSaveFilterModal, setShowSaveFilterModal] = useState(false)
const [showAdvancedFilterBuilder, setShowAdvancedFilterBuilder] =
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const processedChores = useMemo(() => { const processedChores = useMemo(() => {
if (!choresData?.res) { if (!choresData?.res) {
return [] return []
@@ -298,6 +328,61 @@ const MyChores = () => {
} }
}, [searchInputFocus]) }, [searchInputFocus])
// Read and apply filters from URL parameters
useEffect(() => {
if (!chores.length || !savedFilters.length) return
// Check for filterId (camelCase) or filter_id (snake_case) for advanced filters
const filterId =
searchParams.get('filterId') || searchParams.get('filter_id')
const oldFilter = searchParams.get('filter')
// Handle advanced filter parameter
if (filterId && !activeFilterId) {
const filter = savedFilters.find(f => f.id === filterId)
if (filter) {
applyCustomFilter(filterId)
return
}
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
today: 'Due today',
week: 'Due in week',
later: 'Due Later',
pending: 'Pending Approval',
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName && FILTERS[filterName]) {
const filtered = FILTERS[filterName](
selectedProject ? projectFilteredChores : chores,
)
setFilteredChores(filtered)
setSearchFilter(filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
}
}, [
searchParams,
chores,
searchFilter,
activeFilterId,
savedFilters,
applyCustomFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
setFilteredChores,
setViewMode,
setSelectedCalendarDate,
])
const { const {
handleChoreAction, handleChoreAction,
handleChangeDueDate, handleChangeDueDate,
@@ -384,7 +469,8 @@ const MyChores = () => {
} }
const handleLabelFiltering = chipClicked => { const handleLabelFiltering = chipClicked => {
// Start with project-filtered chores as base clearActiveFilter()
const baseChores = selectedProject ? projectFilteredChores : chores const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) { if (chipClicked.label) {
@@ -404,10 +490,30 @@ const MyChores = () => {
setFilteredChores(priorityFiltered) setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority) setSearchFilter('Priority: ' + priority)
} }
// Clear selected calendar date when filters change
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
} }
// Helper to update URL with filter parameters
const updateFilterUrl = (filterType, filterValue) => {
const params = new URLSearchParams(searchParams)
// Clear existing filter params
params.delete('filter')
params.delete('filterId')
params.delete('filter_id')
// Set new filter param (use filterId for advanced filters)
if (filterType && filterValue) {
params.set(filterType, filterValue)
}
// Always navigate with params (preserves project param)
const paramString = params.toString()
Navigate(paramString ? `/chores?${paramString}` : '/chores', {
replace: true,
})
}
const searchOptions = useMemo( const searchOptions = useMemo(
() => ({ () => ({
keys: ['name', 'raw_label'], keys: ['name', 'raw_label'],
@@ -433,6 +539,7 @@ const MyChores = () => {
) )
const handleSearchChange = e => { const handleSearchChange = e => {
clearActiveFilter()
if (searchFilter !== 'All') { if (searchFilter !== 'All') {
setSearchFilter('All') setSearchFilter('All')
} }
@@ -440,7 +547,6 @@ const MyChores = () => {
if (search === '') { if (search === '') {
setFilteredChores(selectedProject ? projectFilteredChores : chores) setFilteredChores(selectedProject ? projectFilteredChores : chores)
setSearchTerm('') setSearchTerm('')
// Clear selected calendar date when search changes
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
return return
} }
@@ -514,6 +620,10 @@ const MyChores = () => {
} }
const getFilteredChores = useMemo(() => { const getFilteredChores = useMemo(() => {
if (activeFilterId) {
return customFilteredChores
}
let baseChores = projectFilteredChores let baseChores = projectFilteredChores
if (searchTerm?.length > 0 || searchFilter !== 'All') { if (searchTerm?.length > 0 || searchFilter !== 'All') {
@@ -535,7 +645,14 @@ const MyChores = () => {
} }
return baseChores return baseChores
}, [projectFilteredChores, searchTerm, searchFilter, filteredChores]) }, [
activeFilterId,
customFilteredChores,
projectFilteredChores,
searchTerm,
searchFilter,
filteredChores,
])
const getChoresForDate = useCallback( const getChoresForDate = useCallback(
date => { date => {
@@ -622,14 +739,13 @@ const MyChores = () => {
mouseClickHandler={handleMenuOutsideClick} mouseClickHandler={handleMenuOutsideClick}
/> />
{/* Project Selector - Show only if there are multiple projects */} {/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 && ( {projectsWithDefault.length > 1 && !hasProjectConditions && (
<ProjectSelector <ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'} selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => { onProjectSelect={project => {
setSelectedProjectWithCache(project) setSelectedProjectWithCache(project)
// setFilteredChores(chores) clearActiveFilter()
// setSearchFilter('All')
}} }}
showKeyboardShortcuts={showKeyboardShortcuts} showKeyboardShortcuts={showKeyboardShortcuts}
/> />
@@ -782,6 +898,20 @@ const MyChores = () => {
setFilteredChores(filteredChores) setFilteredChores(filteredChores)
setSearchFilter(filter) setSearchFilter(filter)
handleFilterMenuClose() handleFilterMenuClose()
// Update URL with legacy filter parameter
const filterMap = {
'No Due Date': 'unplanned',
Overdue: 'overdue',
'Due today': 'today',
'Due in week': 'week',
'Due Later': 'later',
'Pending Approval': 'pending',
}
const urlFilter = filterMap[filter]
if (urlFilter) {
updateFilterUrl('filter', urlFilter)
}
}} }}
> >
{filter} {filter}
@@ -810,6 +940,7 @@ const MyChores = () => {
selectedProject ? projectFilteredChores : chores, selectedProject ? projectFilteredChores : chores,
) )
setSearchFilter('All') setSearchFilter('All')
updateFilterUrl(null, null)
}} }}
> >
Cancel All Filters Cancel All Filters
@@ -831,6 +962,7 @@ const MyChores = () => {
setSearchTerm('') setSearchTerm('')
setFilteredChores(chores) setFilteredChores(chores)
setSearchFilter('All') setSearchFilter('All')
updateFilterUrl(null, null)
}} }}
> >
<CancelRounded /> <CancelRounded />
@@ -838,6 +970,42 @@ const MyChores = () => {
</div> </div>
</Box> </Box>
{/* Custom Filters Section */}
<FilterSection
savedFilters={savedFilters}
activeFilterId={activeFilterId}
activeFilter={activeFilter}
hasProjectConditions={hasProjectConditions}
onFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
setSearchFilter('All')
setSearchTerm('')
setFilteredChores([])
// Clear project selection if the filter has project conditions
const filter = savedFilters.find(f => f.id === filterId)
if (filter?.conditions?.some(c => c.type === 'project')) {
setSelectedProjectWithCache(null)
}
applyCustomFilter(filterId)
updateFilterUrl('filterId', filterId)
}
}}
onFilterDelete={deleteFilter}
onFilterPin={pinFilter}
onFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onClearActiveFilter={clearActiveFilter}
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
updateFilterUrl={updateFilterUrl}
/>
<MultiSelectToolbar <MultiSelectToolbar
isVisible={isMultiSelectMode} isVisible={isMultiSelectMode}
selectedCount={selectedChores.size} selectedCount={selectedChores.size}
@@ -868,6 +1036,7 @@ const MyChores = () => {
selectedProject ? projectFilteredChores : chores, selectedProject ? projectFilteredChores : chores,
) )
setSearchFilter('All') setSearchFilter('All')
updateFilterUrl(null, null)
}} }}
endDecorator={<CancelRounded />} endDecorator={<CancelRounded />}
onClick={() => { onClick={() => {
@@ -875,14 +1044,15 @@ const MyChores = () => {
selectedProject ? projectFilteredChores : chores, selectedProject ? projectFilteredChores : chores,
) )
setSearchFilter('All') setSearchFilter('All')
updateFilterUrl(null, null)
}} }}
> >
Additional Filter: {searchFilter} Additional Filter: {searchFilter}
</Chip> </Chip>
)} )}
{/* Show "Nothing scheduled" when appropriate based on current view mode */} {/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All' {(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
? filteredChores.length === 0 ? getFilteredChores.length === 0
: projectFilteredChores.length === 0) && : projectFilteredChores.length === 0) &&
// only if not in calendar view: // only if not in calendar view:
viewMode !== 'calendar' && ( viewMode !== 'calendar' && (
@@ -909,10 +1079,9 @@ const MyChores = () => {
<> <>
<Button <Button
onClick={() => { onClick={() => {
// Reset search and filters to show all chores in current project
setSearchFilter('All') setSearchFilter('All')
setSearchTerm('') setSearchTerm('')
// Clear any manual filteredChores and let the memo handle it clearActiveFilter()
}} }}
variant='outlined' variant='outlined'
color='neutral' color='neutral'
@@ -923,7 +1092,7 @@ const MyChores = () => {
)} )}
</Box> </Box>
)} )}
{(searchTerm?.length > 0 || searchFilter !== 'All') && {(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId) &&
viewMode !== 'calendar' && viewMode !== 'calendar' &&
getFilteredChores.map(chore => getFilteredChores.map(chore =>
renderChoreCard(chore, `filtered-${chore.id}`), renderChoreCard(chore, `filtered-${chore.id}`),
@@ -947,17 +1116,15 @@ const MyChores = () => {
color='danger' color='danger'
size='lg' size='lg'
onClick={() => { onClick={() => {
// Update URL for navigation context // Update state directly for immediate smooth transition
Navigate('/chores?filter=overdue', {
replace: false,
})
// Also update state directly for immediate smooth transition
const overdueChores = FILTERS['Overdue'](getFilteredChores) const overdueChores = FILTERS['Overdue'](getFilteredChores)
setFilteredChores(overdueChores) setFilteredChores(overdueChores)
setSearchFilter('Overdue') setSearchFilter('Overdue')
setViewMode('default') setViewMode('default')
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'overdue')
}} }}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -981,18 +1148,16 @@ const MyChores = () => {
color='neutral' color='neutral'
size='lg' size='lg'
onClick={() => { onClick={() => {
// Update URL for navigation context // Update state directly for immediate smooth transition
Navigate('/chores?filter=unplanned', {
replace: false,
})
// Also update state directly for immediate smooth transition
const unplannedChores = const unplannedChores =
FILTERS['No Due Date'](getFilteredChores) FILTERS['No Due Date'](getFilteredChores)
setFilteredChores(unplannedChores) setFilteredChores(unplannedChores)
setSearchFilter('No Due Date') setSearchFilter('No Due Date')
setViewMode('default') setViewMode('default')
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'unplanned')
}} }}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -1015,18 +1180,16 @@ const MyChores = () => {
variant='soft' variant='soft'
size='lg' size='lg'
onClick={() => { onClick={() => {
// Update URL for navigation context // Update state directly for immediate smooth transition
Navigate('/chores?filter=pending-approval', {
replace: true,
})
// Also update state directly for immediate smooth transition
const pendingApprovalChores = const pendingApprovalChores =
FILTERS['Pending Approval'](getFilteredChores) FILTERS['Pending Approval'](getFilteredChores)
setFilteredChores(pendingApprovalChores) setFilteredChores(pendingApprovalChores)
setSearchFilter('Pending Approval') setSearchFilter('Pending Approval')
setViewMode('default') setViewMode('default')
setSelectedCalendarDate(null) setSelectedCalendarDate(null)
// Update URL
updateFilterUrl('filter', 'pending')
}} }}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
@@ -1121,6 +1284,7 @@ const MyChores = () => {
)} )}
{searchTerm.length === 0 && {searchTerm.length === 0 &&
searchFilter === 'All' && searchFilter === 'All' &&
!activeFilterId &&
viewMode !== 'calendar' && ( viewMode !== 'calendar' && (
<AccordionGroup transition='0.2s ease' disableDivider> <AccordionGroup transition='0.2s ease' disableDivider>
{choreSections.map((section, index) => { {choreSections.map((section, index) => {
@@ -1302,6 +1466,76 @@ const MyChores = () => {
onNudge={handleNudge} onNudge={handleNudge}
onClose={closeModal} onClose={closeModal}
/> />
{/* Save Filter Modal */}
<SaveFilterModal
isOpen={showSaveFilterModal}
onClose={() => setShowSaveFilterModal(false)}
onSave={filter => {
saveFilter(filter)
showSuccess({
title: 'Filter Saved',
message: `"${filter.name}" has been saved successfully`,
})
}}
filterData={createFilterFromCurrentState({
selectedProject,
selectedChoreFilter,
searchFilter,
})}
previewChores={
activeFilterId ? customFilteredChores : searchFilteredChores
}
previewCount={
activeFilterId
? customFilteredChores.length
: searchFilteredChores.length
}
previewOverdueCount={
(activeFilterId ? customFilteredChores : searchFilteredChores).filter(
chore =>
chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
).length
}
/>
{/* Advanced Filter Builder */}
<AdvancedFilterBuilder
isOpen={showAdvancedFilterBuilder}
onClose={() => {
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
}}
onSave={filter => {
if (filter.id) {
// Update existing filter
updateFilter(filter.id, {
name: filter.name,
conditions: filter.conditions,
operator: filter.operator,
})
showSuccess({
title: 'Filter Updated',
message: `"${filter.name}" has been updated successfully`,
})
} else {
// Create new filter
saveFilter(filter)
showSuccess({
title: 'Advanced Filter Created',
message: `"${filter.name}" has been created successfully`,
})
}
setShowAdvancedFilterBuilder(false)
setEditingFilter(null)
}}
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
allChores={searchFilteredChores}
userProfile={userProfile}
editingFilter={editingFilter}
/>
</div> </div>
) )
} }

View File

@@ -0,0 +1,182 @@
import { Delete, Edit, Star, StarBorder, Warning } from '@mui/icons-material'
import { Box, Chip, Menu, MenuItem, Tooltip, Typography } from '@mui/joy'
import { useState } from 'react'
const CustomFilterChips = ({
filters = [],
activeFilterId,
onFilterClick,
onFilterDelete,
onFilterPin,
onFilterEdit,
}) => {
const [menuAnchor, setMenuAnchor] = useState(null)
const [selectedFilter, setSelectedFilter] = useState(null)
if (filters.length === 0) return null
const handleContextMenu = (event, filter) => {
event.preventDefault()
event.stopPropagation()
setMenuAnchor(event.currentTarget)
setSelectedFilter(filter)
}
const handleMenuClose = () => {
setMenuAnchor(null)
setSelectedFilter(null)
}
const handleDelete = () => {
if (selectedFilter) {
onFilterDelete(selectedFilter.id)
}
handleMenuClose()
}
const handlePin = () => {
if (selectedFilter) {
onFilterPin(selectedFilter.id)
}
handleMenuClose()
}
const handleEdit = () => {
if (selectedFilter && onFilterEdit) {
onFilterEdit(selectedFilter)
}
handleMenuClose()
}
const sortedFilters = [...filters].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
return (b.usageCount || 0) - (a.usageCount || 0)
})
return (
<Box
sx={{
display: 'flex',
gap: 1,
overflowX: 'auto',
py: 1,
'&::-webkit-scrollbar': {
height: 6,
},
'&::-webkit-scrollbar-thumb': {
backgroundColor: 'neutral.400',
borderRadius: 3,
},
}}
>
{sortedFilters.map(filter => {
const isActive = activeFilterId === filter.id
const hasWarning = !filter.isValid
return (
<Tooltip
key={filter.id}
title={
hasWarning
? `Filter has issues: ${filter.validationIssues?.join(', ')}`
: `${filter.count} tasks${filter.overdueCount > 0 ? ` (${filter.overdueCount} overdue)` : ''}`
}
placement='bottom'
>
<Chip
variant={isActive ? 'solid' : 'soft'}
color={hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'}
size='lg'
onClick={() => !hasWarning && onFilterClick(filter.id)}
onContextMenu={e => handleContextMenu(e, filter)}
sx={{
cursor: hasWarning ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
px: 1.5,
py: 0.5,
opacity: hasWarning ? 0.7 : 1,
}}
startDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{filter.isPinned && (
<Star sx={{ fontSize: '0.9rem', color: 'warning.500' }} />
)}
<Chip
size='sm'
variant='solid'
color={
hasWarning ? 'warning' : isActive ? 'primary' : 'neutral'
}
>
{filter.count}
</Chip>
</Box>
}
endDecorator={
<Box sx={{ display: 'flex', gap: 0.5, alignItems: 'center' }}>
{hasWarning && <Warning sx={{ fontSize: '1rem' }} />}
{!hasWarning && filter.overdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{filter.overdueCount}
</Chip>
)}
</Box>
}
>
<Typography
level='body-sm'
fontWeight={isActive ? 'md' : 'normal'}
sx={{
whiteSpace: 'nowrap',
maxWidth: 200,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{filter.name}
</Typography>
</Chip>
</Tooltip>
)
})}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
placement='bottom-start'
>
{selectedFilter && (
<>
<MenuItem onClick={handlePin}>
{selectedFilter.isPinned ? (
<>
<StarBorder sx={{ mr: 1 }} />
Unpin filter
</>
) : (
<>
<Star sx={{ mr: 1 }} />
Pin filter
</>
)}
</MenuItem>
{onFilterEdit && (
<MenuItem onClick={handleEdit}>
<Edit sx={{ mr: 1 }} />
Edit filter
</MenuItem>
)}
<MenuItem onClick={handleDelete} color='danger'>
<Delete sx={{ mr: 1 }} />
Delete filter
</MenuItem>
</>
)}
</Menu>
</Box>
)
}
export default CustomFilterChips

View File

@@ -0,0 +1,85 @@
import { Add, CancelRounded } from '@mui/icons-material'
import { Box, Button, Chip, IconButton } from '@mui/joy'
import CustomFilterChips from './CustomFilterChips'
const FilterSection = ({
savedFilters,
activeFilterId,
activeFilter,
hasProjectConditions,
onFilterClick,
onFilterDelete,
onFilterPin,
onFilterEdit,
onClearActiveFilter,
onCreateAdvancedFilter,
updateFilterUrl,
}) => {
return (
<>
{/* Custom Filter Chips */}
{savedFilters.length > 0 && (
<Box sx={{ mt: 1 }}>
<CustomFilterChips
filters={savedFilters}
activeFilterId={activeFilterId}
onFilterClick={onFilterClick}
onFilterDelete={onFilterDelete}
onFilterPin={onFilterPin}
onFilterEdit={onFilterEdit}
/>
</Box>
)}
{/* Create Advanced Filter Button */}
{!activeFilterId && (
<Box
sx={{
mt: 1,
display: 'flex',
gap: 1,
justifyContent: 'flex-start',
}}
>
<Button
size='sm'
variant='outlined'
color='primary'
startDecorator={<Add />}
onClick={onCreateAdvancedFilter}
>
Create Advanced Filter
</Button>
</Box>
)}
{/* Active Custom Filter Display */}
{activeFilter && (
<Box sx={{ mt: 1 }}>
<Chip
color='primary'
variant='soft'
size='lg'
endDecorator={
<IconButton size='sm' onClick={onClearActiveFilter}>
<CancelRounded />
</IconButton>
}
>
Filter: {activeFilter.name} ({activeFilter.count} tasks
{activeFilter.overdueCount > 0 &&
`, ${activeFilter.overdueCount} overdue`}
)
{hasProjectConditions && (
<Chip size='sm' sx={{ ml: 1 }} variant='solid' color='primary'>
Cross-Project
</Chip>
)}
</Chip>
</Box>
)}
</>
)
}
export default FilterSection

View File

@@ -62,6 +62,45 @@ export const useChoreFilters = ({
selectedChoreFilter, selectedChoreFilter,
]) ])
// Non-project-filtered chores for custom filters that may have their own project conditions
const nonProjectFilteredChores = useMemo(() => {
let baseChores = chores
if (searchTerm?.length > 0) {
const searchableChores = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(searchableChores, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm.toLowerCase()).map(result => result.item)
}
if (impersonatedUser) {
baseChores = baseChores.filter(
chore => chore.assignedTo === impersonatedUser.userId,
)
}
return baseChores.filter(
ChoreFilters(impersonatedUser?.userId || userProfile?.id)[
selectedChoreFilter
],
)
}, [
searchTerm,
chores,
impersonatedUser,
userProfile?.id,
selectedChoreFilter,
])
const setSelectedChoreFilterWithCache = useCallback(value => { const setSelectedChoreFilterWithCache = useCallback(value => {
setSelectedChoreFilter(value) setSelectedChoreFilter(value)
localStorage.setItem('selectedChoreFilter', value) localStorage.setItem('selectedChoreFilter', value)
@@ -78,6 +117,7 @@ export const useChoreFilters = ({
selectedChoreFilter, selectedChoreFilter,
projectFilteredChores, projectFilteredChores,
searchFilteredChores, searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm, setSearchTerm,
setSearchFilter, setSearchFilter,
setSelectedChoreFilter, setSelectedChoreFilter,

View File

@@ -0,0 +1,240 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useUserProfile } from '../../../queries/UserQueries'
import {
applyFilter,
getFilterCount,
getFilterOverdueCount,
validateFilter,
} from '../../../utils/FilterEngine'
import {
deleteFilter as deleteFilterStorage,
getFilterById,
getSavedFilters,
saveFilter as saveFilterStorage,
toggleFilterPin,
trackFilterUsage,
updateFilter as updateFilterStorage,
} from '../../../utils/CustomFilterStorage'
export const useCustomFilters = (chores, membersData, labels, projects) => {
const { data: userProfile } = useUserProfile()
const [savedFilters, setSavedFilters] = useState([])
const [activeFilterId, setActiveFilterId] = useState(null)
const loadFilters = useCallback(() => {
const filters = getSavedFilters()
setSavedFilters(filters)
}, [])
useEffect(() => {
loadFilters()
}, [loadFilters])
const context = useMemo(
() => ({
userId: userProfile?.id,
members: membersData || [],
labels: labels || [],
projects: projects || [],
}),
[userProfile?.id, membersData, labels, projects],
)
const filtersWithCounts = useMemo(() => {
if (!chores || !Array.isArray(chores)) return []
return savedFilters.map(filter => {
const validation = validateFilter(filter, context)
const count = validation.isValid
? getFilterCount(chores, filter, context)
: 0
const overdueCount = validation.isValid
? getFilterOverdueCount(chores, filter, context)
: 0
return {
...filter,
count,
overdueCount,
isValid: validation.isValid,
validationIssues: validation.issues,
}
})
}, [savedFilters, chores, context])
const activeFilter = useMemo(() => {
if (!activeFilterId) return null
return filtersWithCounts.find(f => f.id === activeFilterId)
}, [activeFilterId, filtersWithCounts])
// Check if active filter has project conditions
const hasProjectConditions = useMemo(() => {
if (!activeFilter || !activeFilter.conditions) return false
return activeFilter.conditions.some(c => c.type === 'project')
}, [activeFilter])
const filteredChores = useMemo(() => {
if (!activeFilter || !activeFilter.isValid) {
return chores
}
return applyFilter(chores, activeFilter, context)
}, [chores, activeFilter, context])
const applyCustomFilter = useCallback(
filterId => {
setActiveFilterId(filterId)
trackFilterUsage(filterId)
loadFilters()
},
[loadFilters],
)
const clearActiveFilter = useCallback(() => {
setActiveFilterId(null)
}, [])
const saveFilter = useCallback(
filter => {
const savedFilter = saveFilterStorage(filter)
loadFilters()
return savedFilter
},
[loadFilters],
)
const updateFilter = useCallback(
(filterId, updates) => {
const updated = updateFilterStorage(filterId, updates)
loadFilters()
return updated
},
[loadFilters],
)
const deleteFilter = useCallback(
filterId => {
if (activeFilterId === filterId) {
setActiveFilterId(null)
}
deleteFilterStorage(filterId)
loadFilters()
},
[activeFilterId, loadFilters],
)
const pinFilter = useCallback(
filterId => {
toggleFilterPin(filterId)
loadFilters()
},
[loadFilters],
)
const createFilterFromCurrentState = useCallback(
currentState => {
const conditions = []
if (currentState.selectedProject) {
conditions.push({
type: 'project',
operator: 'is',
value: currentState.selectedProject.id,
})
}
if (
currentState.selectedChoreFilter &&
currentState.selectedChoreFilter !== 'anyone'
) {
const filterMap = {
assigned_to_me: { type: 'assignee', operator: 'is', value: 'me' },
assigned_to_others: {
type: 'assignee',
operator: 'is',
value: 'others',
},
created_by_me: { type: 'createdBy', operator: 'is', value: 'me' },
}
const condition = filterMap[currentState.selectedChoreFilter]
if (condition) {
conditions.push(condition)
}
}
if (currentState.searchFilter && currentState.searchFilter !== 'All') {
if (currentState.searchFilter.startsWith('Priority: ')) {
const priority = parseInt(
currentState.searchFilter.replace('Priority: ', ''),
)
conditions.push({
type: 'priority',
operator: 'is',
value: priority,
})
} else if (currentState.searchFilter.startsWith('Label: ')) {
const labelName = currentState.searchFilter.replace('Label: ', '')
const label = labels?.find(l => l.name === labelName)
if (label) {
conditions.push({
type: 'label',
operator: 'has',
value: label.id,
})
}
} else if (currentState.searchFilter === 'Overdue') {
conditions.push({
type: 'dueDate',
operator: 'isOverdue',
value: null,
})
} else if (currentState.searchFilter === 'Due today') {
conditions.push({
type: 'dueDate',
operator: 'isDueToday',
value: null,
})
} else if (currentState.searchFilter === 'Due in week') {
conditions.push({
type: 'dueDate',
operator: 'isDueThisWeek',
value: null,
})
} else if (currentState.searchFilter === 'No Due Date') {
conditions.push({
type: 'dueDate',
operator: 'hasNoDueDate',
value: null,
})
} else if (currentState.searchFilter === 'Pending Approval') {
conditions.push({
type: 'status',
operator: 'is',
value: 3,
})
}
}
return {
conditions,
operator: 'AND',
}
},
[labels],
)
return {
savedFilters: filtersWithCounts,
activeFilter,
activeFilterId,
filteredChores,
applyCustomFilter,
clearActiveFilter,
saveFilter,
updateFilter,
deleteFilter,
pinFilter,
createFilterFromCurrentState,
hasProjectConditions,
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react' import { useCallback, useMemo, useState } from 'react'
export const useProjectFilter = projects => { export const useProjectFilter = projects => {
const [selectedProject, setSelectedProject] = useState(() => { const [selectedProject, setSelectedProject] = useState(() => {

View File

@@ -0,0 +1,594 @@
import { Add, Delete, Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Input,
List,
ListItem,
Option,
Select,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
const AdvancedFilterBuilder = ({
isOpen,
onClose,
onSave,
members = [],
labels = [],
projects = [],
allChores = [],
userProfile = null,
editingFilter = null,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const [filterName, setFilterName] = useState('')
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [error, setError] = useState('')
// Initialize state when editing a filter
useEffect(() => {
if (editingFilter) {
setFilterName(editingFilter.name)
setConditions(editingFilter.conditions || [])
setError('')
} else {
setFilterName('')
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
}
}, [editingFilter, isOpen])
const previewChores = useMemo(() => {
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (validConditions.length === 0) return []
const result = applyFilter(
allChores,
{ conditions: validConditions, operator: 'AND' },
{
userId: userProfile?.id,
members,
labels,
projects,
},
)
return result
}, [conditions, allChores, userProfile, members, labels, projects])
const previewCount = previewChores.length
const previewOverdueCount = previewChores.filter(
chore => chore.nextDueDate && new Date(chore.nextDueDate) < new Date(),
).length
const addCondition = () => {
setConditions([
...conditions,
{ type: 'assignee', operator: 'is', value: [] },
])
}
const removeCondition = index => {
setConditions(conditions.filter((_, i) => i !== index))
}
const updateCondition = (index, field, value) => {
const updated = [...conditions]
updated[index] = { ...updated[index], [field]: value }
if (field === 'type') {
updated[index].value = []
if (value === 'dueDate') {
updated[index].operator = 'isOverdue'
updated[index].value = null
} else if (value === 'status') {
updated[index].value = [3]
}
}
setConditions(updated)
}
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
// Check for duplicate name, excluding current filter if editing
if (filterNameExists(filterName.trim(), editingFilter?.id)) {
setError('A filter with this name already exists')
return
}
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (conditions.length === 0 || validConditions.length === 0) {
setError('Please add at least one filter condition')
return
}
const filterData = {
name: filterName.trim(),
conditions: validConditions,
operator: 'AND',
}
// Include ID if editing
if (editingFilter) {
filterData.id = editingFilter.id
}
onSave(filterData)
onClose()
}
const renderValueSelector = (condition, index) => {
switch (condition.type) {
case 'assignee':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select assignees'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{members.map((member, idx) => (
<Option
key={`member-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username} ({member.userId})
</Option>
))}
</Select>
)
case 'createdBy':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select creators'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
if (value === 'me')
return (
<Chip key={`${value}-${idx}`} size='sm'>
Me
</Chip>
)
const member = members.find(
m => String(m.userId) === String(value),
)
return (
<Chip key={`${value}-${idx}`} size='sm'>
{member?.displayName || member?.username || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='me'>Me</Option>
{members.map((member, idx) => (
<Option
key={`creator-${member.userId}-${idx}`}
value={member.userId}
>
{member.displayName || member.username}
</Option>
))}
</Select>
)
case 'priority':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select priorities'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((value, idx) => (
<Chip key={`priority-${value}-${idx}`} size='sm'>
Priority {value}
</Chip>
))}
</Box>
)}
>
{Priorities.map((priority, idx) => (
<Option
key={`priority-opt-${priority.value}-${idx}`}
value={priority.value}
>
{priority.name}
</Option>
))}
</Select>
)
case 'label':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select labels'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const label = labels.find(l => String(l.id) === String(value))
return (
<Chip key={`label-chip-${value}-${idx}`} size='sm'>
{label?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
{labels.map((label, idx) => (
<Option key={`label-opt-${label.id}-${idx}`} value={label.id}>
{label.name}
</Option>
))}
</Select>
)
case 'project':
return (
<Select
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select projects'
sx={{ width: '100%' }}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((event, idx) => {
const value = event.value
if (value === 'default')
return (
<Chip key={`default-${idx}`} size='sm'>
Default
</Chip>
)
const project = projects.find(
p => String(p.id) === String(value),
)
return (
<Chip key={`project-chip-${value}-${idx}`} size='sm'>
{project?.name || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<Option value='default'>Default Project</Option>
{projects
.filter(p => p.id !== 'default')
.map((project, idx) => (
<Option
key={`project-opt-${project.id}-${idx}`}
value={project.id}
>
{project.name}
</Option>
))}
</Select>
)
case 'status':
return (
<Select
value={condition.value?.[0] || 3}
onChange={(_, newValue) =>
updateCondition(index, 'value', [newValue])
}
sx={{ width: '100%' }}
>
<Option value={0}>Active</Option>
<Option value={1}>Started</Option>
<Option value={2}>In Progress</Option>
<Option value={3}>Pending Approval</Option>
</Select>
)
case 'dueDate':
return (
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ width: '100%' }}
>
<Option value='isOverdue'>Is Overdue</Option>
<Option value='isDueToday'>Is Due Today</Option>
<Option value='isDueTomorrow'>Is Due Tomorrow</Option>
<Option value='isDueThisWeek'>Is Due This Week</Option>
<Option value='isDueThisMonth'>Is Due This Month</Option>
<Option value='hasNoDueDate'>Has No Due Date</Option>
<Option value='hasDueDate'>Has Due Date</Option>
</Select>
)
default:
return null
}
}
return (
<ResponsiveModal open={isOpen} onClose={onClose} size='md'>
<Typography level='h4' sx={{ mb: 2 }}>
{editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
</Typography>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
}}
>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Name
</Typography>
<Input
placeholder='e.g., High Priority Tasks for Team'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error}
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
}}
>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions (All must match)
</Typography>
<List
sx={{
gap: 1,
overflowY: 'auto',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
}}
>
{conditions.map((condition, index) => (
<ListItem
key={index}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.5,
bgcolor: 'background.level1',
borderRadius: 'sm',
position: 'relative',
}}
>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
width: '100%',
}}
>
<Typography level='body-xs' color='neutral'>
Condition {index + 1}
</Typography>
<IconButton
size='sm'
color='danger'
variant='plain'
onClick={() => removeCondition(index)}
disabled={conditions.length === 1}
>
<Delete />
</IconButton>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
Field
</Typography>
<Select
value={condition.type}
onChange={(_, newValue) =>
updateCondition(index, 'type', newValue)
}
sx={{ width: '100%' }}
>
<Option value='assignee'>Assignee</Option>
<Option value='createdBy'>Created By</Option>
<Option value='priority'>Priority</Option>
<Option value='label'>Label</Option>
<Option value='project'>Project</Option>
<Option value='status'>Status</Option>
<Option value='dueDate'>Due Date</Option>
</Select>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
{condition.type === 'dueDate' ? 'Condition' : 'Value'}
</Typography>
{renderValueSelector(condition, index)}
</Box>
</ListItem>
))}
</List>
<Button
size='sm'
variant='outlined'
startDecorator={<Add />}
onClick={addCondition}
sx={{ mt: 1 }}
>
Add Condition
</Button>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 150,
overflowY: 'auto',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
>
{editingFilter ? 'Update Filter' : 'Save Filter'}
</Button>
</Box>
</Box>
</ResponsiveModal>
)
}
export default AdvancedFilterBuilder

View File

@@ -0,0 +1,263 @@
import { Save, Star, StarBorder } from '@mui/icons-material'
import {
Box,
Button,
Chip,
Input,
Modal,
ModalClose,
ModalDialog,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import { filterNameExists } from '../../../utils/CustomFilterStorage'
import CompactChoreCard from '../../Chores/CompactChoreCard'
const SaveFilterModal = ({
isOpen,
onClose,
onSave,
filterData,
previewChores = [],
previewCount = 0,
previewOverdueCount = 0,
}) => {
const [filterName, setFilterName] = useState('')
const [isPinned, setIsPinned] = useState(false)
const [error, setError] = useState('')
const handleSave = () => {
if (!filterName.trim()) {
setError('Please enter a filter name')
return
}
if (filterNameExists(filterName.trim())) {
setError('A filter with this name already exists')
return
}
const newFilter = {
...filterData,
name: filterName.trim(),
isPinned,
}
onSave(newFilter)
onClose()
}
const getConditionLabel = condition => {
switch (condition.type) {
case 'assignee':
if (condition.value === 'me') return 'Assigned to me'
if (condition.value === 'others') return 'Assigned to others'
return 'Specific assignee'
case 'createdBy':
if (condition.value === 'me') return 'Created by me'
return 'Created by specific user'
case 'priority':
return `Priority ${condition.value}`
case 'status':
return condition.value === 3 ? 'Pending approval' : `Status ${condition.value}`
case 'dueDate':
if (condition.operator === 'isOverdue') return 'Overdue'
if (condition.operator === 'isDueToday') return 'Due today'
if (condition.operator === 'isDueThisWeek') return 'Due this week'
if (condition.operator === 'hasNoDueDate') return 'No due date'
return 'Due date condition'
case 'label':
return 'Has label'
case 'project':
if (condition.value === 'default') return 'Default project'
return 'Specific project'
default:
return condition.type
}
}
return (
<Modal open={isOpen} onClose={onClose}>
<ModalDialog
sx={{
maxWidth: 500,
width: '90%',
maxHeight: '90vh',
overflow: 'auto',
}}
>
<ModalClose />
<Typography level='h4' sx={{ mb: 2 }}>
Save Filter
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Name
</Typography>
<Input
placeholder='e.g., My High Priority Tasks'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
setError('')
}}
error={!!error}
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Filter Conditions
</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
{filterData.conditions.length === 0 ? (
<Typography level='body-sm' color='neutral'>
No filters applied
</Typography>
) : (
filterData.conditions.map((condition, index) => (
<Chip
key={index}
variant='soft'
color='neutral'
size='sm'
>
{getConditionLabel(condition)}
</Chip>
))
)}
</Box>
</Box>
<Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 1,
}}
>
<Typography level='body-sm'>Preview</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} tasks
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</Box>
</Box>
<Box
sx={{
maxHeight: 200,
overflowY: 'auto',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
}}
>
{previewCount === 0 ? (
<Typography
level='body-sm'
color='neutral'
sx={{ textAlign: 'center', py: 2 }}
>
No tasks match these filters
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{previewChores.slice(0, 3).map(chore => (
<Box
key={chore.id}
sx={{
bgcolor: 'background.surface',
p: 1,
borderRadius: 'sm',
}}
>
<Typography level='body-sm'>{chore.name}</Typography>
</Box>
))}
{previewCount > 3 && (
<Typography
level='body-xs'
color='neutral'
sx={{ textAlign: 'center', mt: 0.5 }}
>
...and {previewCount - 3} more
</Typography>
)}
</Box>
)}
</Box>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
p: 1,
borderRadius: 'sm',
'&:hover': {
bgcolor: 'background.level1',
},
}}
onClick={() => setIsPinned(!isPinned)}
>
{isPinned ? (
<Star color='warning' />
) : (
<StarBorder color='neutral' />
)}
<Box>
<Typography level='body-sm' fontWeight='md'>
Pin this filter
</Typography>
<Typography level='body-xs' color='neutral'>
Pinned filters appear first in the list
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button variant='outlined' color='neutral' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
onClick={handleSave}
startDecorator={<Save />}
disabled={!filterName.trim() || filterData.conditions.length === 0}
>
Save Filter
</Button>
</Box>
</Box>
</ModalDialog>
</Modal>
)
}
export default SaveFilterModal