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,
}
}