diff --git a/src/utils/CustomFilterStorage.js b/src/utils/CustomFilterStorage.js new file mode 100644 index 0000000..a2a6695 --- /dev/null +++ b/src/utils/CustomFilterStorage.js @@ -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 + } +} diff --git a/src/utils/FilterEngine.js b/src/utils/FilterEngine.js new file mode 100644 index 0000000..1a263e1 --- /dev/null +++ b/src/utils/FilterEngine.js @@ -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, + } +} diff --git a/src/views/Chores/MyChores.jsx b/src/views/Chores/MyChores.jsx index 8896241..67eb512 100644 --- a/src/views/Chores/MyChores.jsx +++ b/src/views/Chores/MyChores.jsx @@ -59,7 +59,20 @@ import TaskInput from '../components/AddTaskModal' import CalendarDual from '../components/CalendarDual' import CalendarMonthly from '../components/CalendarMonthly.jsx' import ProjectSelector from '../components/ProjectSelector' +import AdvancedFilterBuilder from '../Modals/Inputs/AdvancedFilterBuilder' +import SaveFilterModal from '../Modals/Inputs/SaveFilterModal' 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 { canScheduleNotification, scheduleChoreNotification, @@ -67,15 +80,6 @@ import { import NotificationAccessSnackbar from './NotificationAccessSnackbar' import Sidepanel from './Sidepanel' 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 { data: userProfile, isLoading: isUserProfileLoading } = @@ -130,6 +134,7 @@ const MyChores = () => { selectedChoreFilter, projectFilteredChores, searchFilteredChores, + nonProjectFilteredChores, setSearchTerm, setSearchFilter, setSelectedChoreFilterWithCache, @@ -154,6 +159,31 @@ const MyChores = () => { const { activeModal, modalChore, modalData, openModal, closeModal } = 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(() => { if (!choresData?.res) { return [] @@ -298,6 +328,61 @@ const MyChores = () => { } }, [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 { handleChoreAction, handleChangeDueDate, @@ -384,7 +469,8 @@ const MyChores = () => { } const handleLabelFiltering = chipClicked => { - // Start with project-filtered chores as base + clearActiveFilter() + const baseChores = selectedProject ? projectFilteredChores : chores if (chipClicked.label) { @@ -404,10 +490,30 @@ const MyChores = () => { setFilteredChores(priorityFiltered) setSearchFilter('Priority: ' + priority) } - // Clear selected calendar date when filters change 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( () => ({ keys: ['name', 'raw_label'], @@ -433,6 +539,7 @@ const MyChores = () => { ) const handleSearchChange = e => { + clearActiveFilter() if (searchFilter !== 'All') { setSearchFilter('All') } @@ -440,7 +547,6 @@ const MyChores = () => { if (search === '') { setFilteredChores(selectedProject ? projectFilteredChores : chores) setSearchTerm('') - // Clear selected calendar date when search changes setSelectedCalendarDate(null) return } @@ -514,6 +620,10 @@ const MyChores = () => { } const getFilteredChores = useMemo(() => { + if (activeFilterId) { + return customFilteredChores + } + let baseChores = projectFilteredChores if (searchTerm?.length > 0 || searchFilter !== 'All') { @@ -535,7 +645,14 @@ const MyChores = () => { } return baseChores - }, [projectFilteredChores, searchTerm, searchFilter, filteredChores]) + }, [ + activeFilterId, + customFilteredChores, + projectFilteredChores, + searchTerm, + searchFilter, + filteredChores, + ]) const getChoresForDate = useCallback( date => { @@ -622,14 +739,13 @@ const MyChores = () => { mouseClickHandler={handleMenuOutsideClick} /> - {/* Project Selector - Show only if there are multiple projects */} - {projectsWithDefault.length > 1 && ( + {/* Project Selector - Hidden when active filter has project conditions */} + {projectsWithDefault.length > 1 && !hasProjectConditions && ( { setSelectedProjectWithCache(project) - // setFilteredChores(chores) - // setSearchFilter('All') + clearActiveFilter() }} showKeyboardShortcuts={showKeyboardShortcuts} /> @@ -782,6 +898,20 @@ const MyChores = () => { setFilteredChores(filteredChores) setSearchFilter(filter) 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} @@ -810,6 +940,7 @@ const MyChores = () => { selectedProject ? projectFilteredChores : chores, ) setSearchFilter('All') + updateFilterUrl(null, null) }} > Cancel All Filters @@ -831,6 +962,7 @@ const MyChores = () => { setSearchTerm('') setFilteredChores(chores) setSearchFilter('All') + updateFilterUrl(null, null) }} > @@ -838,6 +970,42 @@ const MyChores = () => { + {/* Custom Filters Section */} + { + 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} + /> + { selectedProject ? projectFilteredChores : chores, ) setSearchFilter('All') + updateFilterUrl(null, null) }} endDecorator={} onClick={() => { @@ -875,14 +1044,15 @@ const MyChores = () => { selectedProject ? projectFilteredChores : chores, ) setSearchFilter('All') + updateFilterUrl(null, null) }} > Additional Filter: {searchFilter} )} {/* Show "Nothing scheduled" when appropriate based on current view mode */} - {(searchTerm?.length > 0 || searchFilter !== 'All' - ? filteredChores.length === 0 + {(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId + ? getFilteredChores.length === 0 : projectFilteredChores.length === 0) && // only if not in calendar view: viewMode !== 'calendar' && ( @@ -909,10 +1079,9 @@ const MyChores = () => { <> + + )} + + {/* Active Custom Filter Display */} + {activeFilter && ( + + + + + } + > + Filter: {activeFilter.name} ({activeFilter.count} tasks + {activeFilter.overdueCount > 0 && + `, ${activeFilter.overdueCount} overdue`} + ) + {hasProjectConditions && ( + + Cross-Project + + )} + + + )} + + ) +} + +export default FilterSection diff --git a/src/views/Chores/hooks/useChoreFilters.js b/src/views/Chores/hooks/useChoreFilters.js index 2c6dc8a..fccb5e5 100644 --- a/src/views/Chores/hooks/useChoreFilters.js +++ b/src/views/Chores/hooks/useChoreFilters.js @@ -62,6 +62,45 @@ export const useChoreFilters = ({ 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 => { setSelectedChoreFilter(value) localStorage.setItem('selectedChoreFilter', value) @@ -78,6 +117,7 @@ export const useChoreFilters = ({ selectedChoreFilter, projectFilteredChores, searchFilteredChores, + nonProjectFilteredChores, setSearchTerm, setSearchFilter, setSelectedChoreFilter, diff --git a/src/views/Chores/hooks/useCustomFilters.js b/src/views/Chores/hooks/useCustomFilters.js new file mode 100644 index 0000000..1f3449b --- /dev/null +++ b/src/views/Chores/hooks/useCustomFilters.js @@ -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, + } +} diff --git a/src/views/Chores/hooks/useProjectFilter.js b/src/views/Chores/hooks/useProjectFilter.js index 428531d..f2439b4 100644 --- a/src/views/Chores/hooks/useProjectFilter.js +++ b/src/views/Chores/hooks/useProjectFilter.js @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from 'react' +import { useCallback, useMemo, useState } from 'react' export const useProjectFilter = projects => { const [selectedProject, setSelectedProject] = useState(() => { diff --git a/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx new file mode 100644 index 0000000..ae4cfc5 --- /dev/null +++ b/src/views/Modals/Inputs/AdvancedFilterBuilder.jsx @@ -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 ( + + ) + + case 'createdBy': + return ( + + ) + + case 'priority': + return ( + + ) + + case 'label': + return ( + + ) + + case 'project': + return ( + + ) + + case 'status': + return ( + + ) + + case 'dueDate': + return ( + + ) + + default: + return null + } + } + + return ( + + + {editingFilter ? 'Edit Filter' : 'Create Advanced Filter'} + + + + + + Filter Name + + { + setFilterName(e.target.value) + setError('') + }} + error={!!error} + autoFocus + /> + {error && ( + + {error} + + )} + + + + + Filter Conditions (All must match) + + + + {conditions.map((condition, index) => ( + + + + Condition {index + 1} + + removeCondition(index)} + disabled={conditions.length === 1} + > + + + + + + + Field + + + + + + + {condition.type === 'dueDate' ? 'Condition' : 'Value'} + + {renderValueSelector(condition, index)} + + + ))} + + + + + + + + Preview + + + {previewCount} tasks + + {previewOverdueCount > 0 && ( + + {previewOverdueCount} overdue + + )} + + + + + {previewCount === 0 ? ( + + No tasks match these filters + + ) : ( + + {previewChores.slice(0, 3).map(chore => ( + + {chore.name} + + ))} + {previewCount > 3 && ( + + ...and {previewCount - 3} more + + )} + + )} + + + + + + + + + + ) +} + +export default AdvancedFilterBuilder diff --git a/src/views/Modals/Inputs/SaveFilterModal.jsx b/src/views/Modals/Inputs/SaveFilterModal.jsx new file mode 100644 index 0000000..3d547ed --- /dev/null +++ b/src/views/Modals/Inputs/SaveFilterModal.jsx @@ -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 ( + + + + + Save Filter + + + + + + Filter Name + + { + setFilterName(e.target.value) + setError('') + }} + error={!!error} + autoFocus + /> + {error && ( + + {error} + + )} + + + + + Filter Conditions + + + {filterData.conditions.length === 0 ? ( + + No filters applied + + ) : ( + filterData.conditions.map((condition, index) => ( + + {getConditionLabel(condition)} + + )) + )} + + + + + + Preview + + + {previewCount} tasks + + {previewOverdueCount > 0 && ( + + {previewOverdueCount} overdue + + )} + + + + + {previewCount === 0 ? ( + + No tasks match these filters + + ) : ( + + {previewChores.slice(0, 3).map(chore => ( + + {chore.name} + + ))} + {previewCount > 3 && ( + + ...and {previewCount - 3} more + + )} + + )} + + + + setIsPinned(!isPinned)} + > + {isPinned ? ( + + ) : ( + + )} + + + Pin this filter + + + Pinned filters appear first in the list + + + + + + + + + + + + ) +} + +export default SaveFilterModal