Merge pull request #126 from donetick/advance-filtering

Advance filtering
This commit is contained in:
Mohamad Tarbin
2026-07-04 02:25:31 -04:00
committed by GitHub
13 changed files with 3376 additions and 1796 deletions

View File

@@ -0,0 +1,506 @@
import { Check, FilterList, Tune } from '@mui/icons-material'
import {
Avatar,
Badge,
Box,
Button,
Chip,
Divider,
Input,
Typography,
} from '@mui/joy'
import { useState } from 'react'
import BottomSheetModal from './BottomSheetModal'
import ActiveFilterChips from './filter/ActiveFilterChips'
/**
* Reusable filter bar component.
*
* Props:
* filterDefs - array of filter definitions:
* { id, label, type ('multi-select'|'single-select'|'boolean'|'date-range'),
* icon, options?, defaultValue?, filterFn }
* options item: { value, label, color?, icon?, avatar? }
* defaultValue: if the active value equals this, no chip is shown
* date-range value shape: { preset?, from?: ISO string, to?: ISO string }
* activeFilters - current filter state object { [id]: value }
* onSetFilter - (filterId, value | null) => void
* onClearAll - () => void
* resultCount - optional number shown in "Show N results" button
* totalCount - optional total for "N of M" label
*/
// ── Date range presets (no moment dependency — pure Date) ────────────────────
const d = (date, h = 0, m = 0, s = 0, ms = 0) =>
new Date(date.getFullYear(), date.getMonth(), date.getDate(), h, m, s, ms)
const DATE_RANGE_PRESETS = [
{
value: 'today',
label: 'Today',
getRange: () => {
const t = d(new Date())
return { from: t.toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'yesterday',
label: 'Yesterday',
getRange: () => {
const t = d(new Date())
const y = new Date(t); y.setDate(t.getDate() - 1)
return { from: d(y).toISOString(), to: d(y, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-week',
label: 'This Week',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - t.getDay())
const end = new Date(start); end.setDate(start.getDate() + 6)
return { from: d(start).toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-7-days',
label: 'Last 7 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 6)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'this-month',
label: 'This Month',
getRange: () => {
const n = new Date()
const start = new Date(n.getFullYear(), n.getMonth(), 1)
const end = new Date(n.getFullYear(), n.getMonth() + 1, 0)
return { from: start.toISOString(), to: d(end, 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-30-days',
label: 'Last 30 Days',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setDate(t.getDate() - 29)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
{
value: 'last-3-months',
label: 'Last 3 Months',
getRange: () => {
const t = d(new Date())
const start = new Date(t); start.setMonth(t.getMonth() - 3)
return { from: d(start).toISOString(), to: d(new Date(), 23, 59, 59, 999).toISOString() }
},
},
]
const toInputDate = iso => (iso ? iso.split('T')[0] : '')
const fmtDisplayDate = iso => {
if (!iso) return null
const dt = new Date(iso)
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// ── Component ────────────────────────────────────────────────────────────────
const FilterBar = ({
filterDefs,
activeFilters,
onSetFilter,
onClearAll,
resultCount,
totalCount,
}) => {
const [isOpen, setIsOpen] = useState(false)
// ── Active count ───────────────────────────────────────────────────────────
const activeFilterCount = filterDefs.filter(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return false
if (def.defaultValue !== undefined && value === def.defaultValue) return false
if (Array.isArray(value) && value.length === 0) return false
if (def.type === 'date-range') return !!(value?.from || value?.to)
return true
}).length
const hasActive = activeFilterCount > 0
const selectableChipSx = {
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
alignItems: 'center',
'& .MuiChip-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
}
const sectionBadgeChipSx = {
ml: 'auto',
fontSize: '0.7rem',
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
const modalCountChipSx = {
ml: 0.5,
minHeight: 22,
py: 0.25,
px: 0.75,
alignItems: 'center',
'& .MuiChip-label': {
lineHeight: 1.2,
px: 0,
},
}
// ── Chip labels for inline bar ─────────────────────────────────────────────
const getActiveChipLabel = def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return null
if (def.type === 'single-select') {
if (def.defaultValue !== undefined && value === def.defaultValue) return null
return def.options?.find(o => o.value === value)?.label ?? def.label
}
if (def.type === 'boolean') return def.label
if (def.type === 'multi-select' && Array.isArray(value) && value.length > 0) {
if (value.length === 1) {
return def.options?.find(o => o.value === value[0])?.label ?? def.label
}
return `${def.label} (${value.length})`
}
if (def.type === 'date-range') {
if (!value?.from && !value?.to) return null
if (value.preset) {
return DATE_RANGE_PRESETS.find(p => p.value === value.preset)?.label ?? 'Date Range'
}
const from = fmtDisplayDate(value.from)
const to = fmtDisplayDate(value.to)
if (from && to) return `${from} ${to}`
if (from) return `From ${from}`
if (to) return `Until ${to}`
return null
}
return null
}
// ── Handlers ───────────────────────────────────────────────────────────────
const handleMultiToggle = (defId, optValue) => {
const current = activeFilters[defId] || []
const next = current.includes(optValue)
? current.filter(v => v !== optValue)
: [...current, optValue]
onSetFilter(defId, next.length > 0 ? next : null)
}
const handleSingleToggle = (defId, optValue) => {
onSetFilter(defId, activeFilters[defId] === optValue ? null : optValue)
}
const handleBoolToggle = defId => {
onSetFilter(defId, activeFilters[defId] ? null : true)
}
const handleDateRangePreset = (defId, presetValue) => {
const current = activeFilters[defId] || {}
if (current.preset === presetValue) {
onSetFilter(defId, null)
return
}
const preset = DATE_RANGE_PRESETS.find(p => p.value === presetValue)
onSetFilter(defId, { preset: presetValue, ...preset.getRange() })
}
const handleDateRangeInput = (defId, field, dateStr) => {
const current = activeFilters[defId] || {}
if (!dateStr) {
const next = { ...current, preset: null, [field]: null }
onSetFilter(defId, next.from || next.to ? next : null)
} else {
const iso =
field === 'to'
? new Date(dateStr + 'T23:59:59').toISOString()
: new Date(dateStr + 'T00:00:00').toISOString()
onSetFilter(defId, { ...current, preset: null, [field]: iso })
}
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* ── Inline bar ─────────────────────────────────────── */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Badge
badgeContent={activeFilterCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{ display: 'flex', alignItems: 'center' }}
>
<Button
size='md'
variant={hasActive ? 'solid' : 'outlined'}
color={hasActive ? 'primary' : 'neutral'}
startDecorator={<FilterList sx={{ fontSize: 16 }} />}
onClick={() => setIsOpen(true)}
sx={{
borderRadius: 'xl',
py: 0.5,
px: 1,
gap: 0.5,
alignItems: 'center',
'& .MuiButton-startDecorator': {
display: 'flex',
alignItems: 'center',
mr: 0.5,
},
}}
>
Filters
</Button>
</Badge>
<ActiveFilterChips
chips={filterDefs
.map(def => ({ def, label: getActiveChipLabel(def) }))
.filter(({ label }) => !!label)
.map(({ def, label }) => ({
key: def.id,
label,
onClear: () => onSetFilter(def.id, null),
}))}
onOpen={() => setIsOpen(true)}
onClearAll={hasActive ? onClearAll : undefined}
resultCount={hasActive ? resultCount : undefined}
totalCount={hasActive ? totalCount : undefined}
maxVisible={2}
chipSize='md'
/>
</Box>
{/* ── Bottom sheet ────────────────────────────────────── */}
<BottomSheetModal
open={isOpen}
onClose={() => setIsOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasActive && (
<Chip size='sm' variant='solid' color='primary' sx={modalCountChipSx}>
{activeFilterCount}
</Chip>
)}
</Box>
}
footer={
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 1 }}>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasActive}
onClick={onClearAll}
>
Clear all
</Button>
<Button onClick={() => setIsOpen(false)} sx={{ minWidth: 140 }}>
{resultCount !== undefined
? `Show ${resultCount} result${resultCount !== 1 ? 's' : ''}`
: 'Done'}
</Button>
</Box>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{filterDefs.map((def, idx) => (
<Box key={def.id}>
{idx > 0 && <Divider sx={{ my: 2.5 }} />}
{/* Section header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{def.icon && (
<Box sx={{ color: 'text.secondary', display: 'flex', alignItems: 'center', '& svg': { fontSize: 18 } }}>
{def.icon}
</Box>
)}
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{def.label}
</Typography>
{/* active badge in header */}
{def.type === 'multi-select' && (activeFilters[def.id]?.length ?? 0) > 0 && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{activeFilters[def.id].length} selected
</Chip>
)}
{def.type === 'single-select' && activeFilters[def.id] != null && (() => {
const opt = def.options?.find(o => o.value === activeFilters[def.id])
return opt ? (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{opt.label}
</Chip>
) : null
})()}
{def.type === 'date-range' && getActiveChipLabel(def) && (
<Chip size='sm' variant='solid' color='primary' sx={sectionBadgeChipSx}>
{getActiveChipLabel(def)}
</Chip>
)}
</Box>
{/* multi-select */}
{def.type === 'multi-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = (activeFilters[def.id] || []).includes(opt.value)
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleMultiToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* single-select */}
{def.type === 'single-select' && (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{def.options?.map(opt => {
const isSelected = activeFilters[def.id] === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={
opt.avatar ? (
<Avatar src={opt.avatar} alt={opt.label} sx={{ '--Avatar-size': '20px' }} />
) : isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (opt.icon ?? null)
}
onClick={() => handleSingleToggle(def.id, opt.value)}
sx={selectableChipSx}
>
{opt.label}
</Chip>
)
})}
</Box>
)}
{/* boolean */}
{def.type === 'boolean' && (
<Chip
variant={activeFilters[def.id] ? 'solid' : 'soft'}
color={activeFilters[def.id] ? 'primary' : 'neutral'}
startDecorator={activeFilters[def.id] ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleBoolToggle(def.id)}
sx={selectableChipSx}
>
{def.label}
</Chip>
)}
{/* date-range */}
{def.type === 'date-range' && (() => {
const val = activeFilters[def.id] || {}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{/* Preset chips */}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DATE_RANGE_PRESETS.map(preset => {
const isSelected = val.preset === preset.value
return (
<Chip
key={preset.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => handleDateRangePreset(def.id, preset.value)}
sx={selectableChipSx}
>
{preset.label}
</Chip>
)
})}
</Box>
{/* Custom date inputs */}
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<Input
type='date'
size='sm'
value={toInputDate(val.from)}
onChange={e => handleDateRangeInput(def.id, 'from', e.target.value)}
slotProps={{ input: { max: toInputDate(val.to) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
<Typography level='body-xs' sx={{ color: 'text.tertiary', flexShrink: 0 }}>
</Typography>
<Input
type='date'
size='sm'
value={toInputDate(val.to)}
onChange={e => handleDateRangeInput(def.id, 'to', e.target.value)}
slotProps={{ input: { min: toInputDate(val.from) || undefined } }}
sx={{ flex: 1, fontSize: '0.8rem' }}
/>
</Box>
</Box>
)
})()}
</Box>
))}
</Box>
</BottomSheetModal>
</>
)
}
export default FilterBar

View File

@@ -0,0 +1,131 @@
import { Close } from '@mui/icons-material'
import { Box, Button, Chip, Typography } from '@mui/joy'
const ActiveFilterChips = ({
chips = [],
onOpen,
onClearAll,
resultCount,
totalCount,
maxVisible = 2,
chipSize = 'md',
clearButtonSize = 'sm',
clearButtonSx,
containerSx,
chipSx,
overflowChipSx,
resultSx,
}) => {
if (!chips.length) {
return null
}
const visible = chips.slice(0, maxVisible)
const overflow = chips.length - maxVisible
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'nowrap',
overflowX: 'auto',
py: 0.5,
'&::-webkit-scrollbar': { display: 'none' },
scrollbarWidth: 'none',
...containerSx,
}}
>
{visible.map(({ key, label, onClear, color = 'primary' }) => (
<Chip
key={key}
size={chipSize}
variant='soft'
color={color}
endDecorator={
<Close
sx={{ cursor: 'pointer', fontSize: chipSize === 'sm' ? 12 : 16 }}
onClick={e => {
e.stopPropagation()
onClear?.()
}}
/>
}
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
alignItems: 'center',
'& .MuiChip-endDecorator': {
display: 'flex',
alignItems: 'center',
ml: 0.5,
},
'& .MuiChip-label': {
lineHeight: 1.2,
},
'&:hover': { opacity: 0.85 },
...chipSx,
}}
>
{label}
</Chip>
))}
{overflow > 0 && (
<Chip
size={chipSize}
variant='soft'
color='neutral'
onClick={onOpen}
sx={{
cursor: 'pointer',
flexShrink: 0,
transition: 'all 0.15s ease',
'&:hover': { opacity: 0.85 },
...overflowChipSx,
}}
>
+{overflow} more
</Chip>
)}
{resultCount != null && totalCount != null && (
<Typography
level='body-xs'
sx={{
color: 'text.tertiary',
ml: 'auto',
flexShrink: 0,
...resultSx,
}}
>
{resultCount} / {totalCount}
</Typography>
)}
{onClearAll && (
<Button
size={clearButtonSize}
variant='plain'
color='neutral'
onClick={onClearAll}
sx={{
px: 0.5,
fontSize: chipSize === 'sm' ? '0.72rem' : '0.75rem',
color: 'text.secondary',
minHeight: 0,
flexShrink: 0,
...clearButtonSx,
}}
>
Clear all
</Button>
)}
</Box>
)
}
export default ActiveFilterChips

59
src/hooks/useFilter.js Normal file
View File

@@ -0,0 +1,59 @@
import { useMemo, useState } from 'react'
/**
* Generic client-side filter hook.
*
* @param {Array} data - the full list to filter
* @param {Array} filterDefs - array of filter definitions (see FilterBar)
* @returns {{ filteredData, activeFilters, setFilter, clearAll, activeFilterCount, hasActiveFilters }}
*
* Each filterDef must include:
* id - unique string key
* type - 'multi-select' | 'boolean'
* filterFn - (item, filterValue) => boolean
*/
export const useFilter = (data, filterDefs) => {
const [activeFilters, setActiveFilters] = useState({})
const setFilter = (filterId, value) => {
setActiveFilters(prev => {
const isEmpty =
value === null ||
value === undefined ||
(Array.isArray(value) && value.length === 0)
if (isEmpty) {
const { [filterId]: _removed, ...rest } = prev
return rest
}
return { ...prev, [filterId]: value }
})
}
const clearAll = () => setActiveFilters({})
const filteredData = useMemo(() => {
if (!data) return []
if (!Object.keys(activeFilters).length) return data
return data.filter(item =>
filterDefs.every(def => {
const value = activeFilters[def.id]
if (value === undefined || value === null) return true
if (Array.isArray(value) && value.length === 0) return true
return def.filterFn(item, value)
}),
)
}, [data, activeFilters, filterDefs])
const activeFilterCount = Object.keys(activeFilters).length
return {
filteredData,
activeFilters,
setFilter,
clearAll,
activeFilterCount,
hasActiveFilters: activeFilterCount > 0,
}
}

View File

@@ -1,13 +1,16 @@
import {
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
Archive,
CheckBox,
CheckBoxOutlineBlank,
Close,
Delete,
Label,
Person,
PriorityHigh,
SelectAll,
Unarchive,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import {
Box,
@@ -22,15 +25,18 @@ import {
} from '@mui/joy'
import { useQueryClient } from '@tanstack/react-query'
import Fuse from 'fuse.js'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useFilter } from '../../hooks/useFilter'
import { useUnArchiveChore } from '../../queries/ChoreQueries'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import { useNotification } from '../../service/NotificationProvider'
import { commandQueue, CommandType } from '../../utils/CommandQueue'
import { DeleteChore, GetArchivedChores } from '../../utils/Fetcher'
import Priorities from '../../utils/Priorities'
import { offlineDB } from '../../utils/OfflineDB'
import LoadingComponent from '../components/Loading'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
@@ -107,6 +113,95 @@ const ArchivedTasks = () => {
const { data: membersData, isLoading: membersLoading } = useCircleMembers()
// Unique labels present across all archived chores
const availableLabels = useMemo(() => {
const seen = {}
archivedChores.forEach(c => {
c.labelsV2?.forEach(l => { seen[l.id] = l })
})
return Object.values(seen)
}, [archivedChores])
const filterDefs = useMemo(
() => [
{
id: 'assignee',
label: 'Assignee',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.assignedTo),
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(availableLabels.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Label />,
options: availableLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'archivedAt',
label: 'Archived Date',
type: 'date-range',
icon: <Archive />,
filterFn: (item, value) => {
const date = new Date(item.updatedAt)
if (value.from && date < new Date(value.from)) return false
if (value.to && date > new Date(value.to)) return false
return true
},
},
],
[performers, availableLabels],
)
const {
filteredData: finalChores,
activeFilters,
setFilter,
clearAll,
hasActiveFilters,
} = useFilter(filteredChores, filterDefs)
useEffect(() => {
const loadArchivedChores = async () => {
if (!membersLoading && userProfile) {
@@ -335,11 +430,8 @@ const ArchivedTasks = () => {
}
const selectAllVisibleChores = () => {
const visibleChores =
searchTerm?.length > 0 ? filteredChores : archivedChores
if (visibleChores.length > 0) {
const allIds = new Set(visibleChores.map(chore => chore.id))
setSelectedChores(allIds)
if (finalChores.length > 0) {
setSelectedChores(new Set(finalChores.map(c => c.id)))
}
}
@@ -673,6 +765,15 @@ const ArchivedTasks = () => {
</Box>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={finalChores.length}
totalCount={filteredChores.length}
/>
{/* Multi-select Toolbar */}
{isMultiSelectMode && (
<Box
@@ -745,7 +846,7 @@ const ArchivedTasks = () => {
variant='outlined'
onClick={selectAllVisibleChores}
startDecorator={<SelectAll />}
disabled={selectedChores.size === filteredChores.length}
disabled={selectedChores.size === finalChores.length}
sx={{
minWidth: 'auto',
'--Button-paddingInline': '0.75rem',
@@ -876,7 +977,7 @@ const ArchivedTasks = () => {
)}
{/* Content */}
{filteredChores.length === 0 ? (
{finalChores.length === 0 ? (
<Box
sx={{
display: 'flex',
@@ -886,42 +987,43 @@ const ArchivedTasks = () => {
height: '50vh',
}}
>
<Archive
sx={{
fontSize: '4rem',
mb: 1,
color: 'text.tertiary',
}}
/>
<Archive sx={{ fontSize: '4rem', mb: 1, color: 'text.tertiary' }} />
<Typography level='title-md' gutterBottom>
{searchTerm ? 'No archived tasks found' : 'No archived tasks'}
{searchTerm || hasActiveFilters
? 'No archived tasks found'
: 'No archived tasks'}
</Typography>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{searchTerm
? 'Try adjusting your search terms'
{searchTerm || hasActiveFilters
? 'Try adjusting your search or filters'
: 'Archived tasks will appear here when you archive them from the main task list'}
</Typography>
{searchTerm && (
<Button
onClick={handleSearchClose}
variant='outlined'
color='neutral'
>
Clear search
</Button>
{(searchTerm || hasActiveFilters) && (
<Box sx={{ display: 'flex', gap: 1 }}>
{searchTerm && (
<Button onClick={handleSearchClose} variant='outlined' color='neutral'>
Clear search
</Button>
)}
{hasActiveFilters && (
<Button onClick={clearAll} variant='outlined' color='neutral'>
Clear filters
</Button>
)}
</Box>
)}
</Box>
) : (
<Box>
<Typography level='body-sm' color='text.secondary' sx={{ mb: 2 }}>
{filteredChores.length} archived task
{filteredChores.length !== 1 ? 's' : ''}
{finalChores.length} archived task
{finalChores.length !== 1 ? 's' : ''}
{searchTerm && ` matching "${searchTerm}"`}
</Typography>
<List sx={{ gap: viewMode === 'compact' ? 0 : 1 }}>
<ChoreListView
chores={filteredChores}
chores={finalChores}
// viewOnly={true}
showActions={false}
viewMode={viewMode}

View File

@@ -2,17 +2,10 @@ import {
Add,
Bolt,
CalendarMonth,
CancelRounded,
CheckBox,
CheckBoxOutlineBlank,
EditCalendar,
ExpandCircleDown,
Grain,
PriorityHigh,
Sort,
Style,
ViewAgenda,
ViewModule,
} from '@mui/icons-material'
import Logo from '../../Logo'
import {
@@ -25,9 +18,6 @@ import {
Container,
Divider,
IconButton,
List,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import Fuse from 'fuse.js'
@@ -44,6 +34,7 @@ import IconButtonWithMenu from './IconButtonWithMenu'
import { useMediaQuery } from '@mui/material'
import { useQueryClient } from '@tanstack/react-query'
import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint'
import { useFilter } from '../../hooks/useFilter'
import { useImpersonateUser } from '../../contexts/ImpersonateUserContext.jsx'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries'
import {
@@ -56,15 +47,13 @@ import { getSafeBottom } from '../../utils/SafeAreaUtils.js'
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 { useProjects } from '../Projects/ProjectQueries.js'
import ChoreListView from './ChoreListView.jsx'
import ChoreToolbar from './components/ChoreToolbarPrototype'
import ChoreModals from './components/ChoreModals'
import FilterSection from './components/FilterSection'
import MultiSelectToolbar from './components/MultiSelectToolbar'
import MyChoreHeader from './components/MyChoreHeader'
import SearchBar from './components/SearchBar'
import { useChoreActions } from './hooks/useChoreActions'
import { useChoreFilters } from './hooks/useChoreFilters'
import { useChoreModals } from './hooks/useChoreModals'
@@ -79,7 +68,6 @@ import {
import NotificationAccessSnackbar from './NotificationAccessSnackbar'
import Sidepanel from './Sidepanel'
import { INSIGHT_FILTER_DEFS } from './SmartInsightsCard'
import SortAndGrouping from './SortAndGrouping'
const MyChores = () => {
const { data: userProfile, isLoading: isUserProfileLoading } =
@@ -108,7 +96,6 @@ const MyChores = () => {
const [chores, setChores] = useState([])
const [filteredChores, setFilteredChores] = useState([])
const [choreSections, setChoreSections] = useState([])
const [showSearchFilter, setShowSearchFilter] = useState(false)
const [addTaskModalOpen, setAddTaskModalOpen] = useState(false)
const [taskInputFocus, setTaskInputFocus] = useState(0)
const searchInputRef = useRef(null)
@@ -136,15 +123,12 @@ const MyChores = () => {
const {
searchTerm,
searchFilter,
selectedChoreFilter,
projectFilteredChores,
searchFilteredChores,
nonProjectFilteredChores,
setSearchTerm,
setSearchFilter,
setSelectedChoreFilterWithCache,
clearFilters,
} = useChoreFilters({
chores,
selectedProject,
@@ -194,6 +178,102 @@ const MyChores = () => {
useState(false)
const [editingFilter, setEditingFilter] = useState(null)
const quickFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Due Date',
type: 'single-select',
icon: <CalendarMonth />,
options: [
{ value: 'Overdue', label: 'Overdue', color: 'danger' },
{ value: 'Due today', label: 'Due Today', color: 'warning' },
{ value: 'Due in week', label: 'Due This Week' },
{ value: 'Due Later', label: 'Due Later' },
{ value: 'No Due Date', label: 'No Due Date' },
{ value: 'Pending Approval', label: 'Pending Approval' },
],
filterFn: (item, value) => {
const now = new Date()
const d = item.nextDueDate ? new Date(item.nextDueDate) : null
switch (value) {
case 'Overdue':
return d !== null && d < now
case 'Due today':
return d !== null && d.toDateString() === now.toDateString()
case 'Due in week':
return (
d !== null &&
d < new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000) &&
d > now
)
case 'Due Later':
return (
d !== null &&
d > new Date(now.getTime() + 24 * 60 * 60 * 1000)
)
case 'No Due Date':
return item.nextDueDate === null
case 'Pending Approval':
return item.status === 3
default:
return true
}
},
},
{
id: 'priority',
label: 'Priority',
type: 'multi-select',
icon: <PriorityHigh />,
options: Priorities.map(p => ({
value: p.value,
label: p.name,
color: p.color || 'neutral',
icon: p.icon,
})),
filterFn: (item, values) => values.includes(item.priority ?? 0),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
],
[userLabels],
)
const {
filteredData: quickFilteredChores,
setFilter: setQuickFilter,
clearAll: clearQuickFilters,
hasActiveFilters: hasQuickFilters,
} = useFilter(projectFilteredChores, quickFilterDefs)
const processedChores = useMemo(() => {
if (!choresData?.res) {
return []
@@ -223,9 +303,9 @@ const MyChores = () => {
if (tempFilter || activeFilterId) {
// Advanced/custom filter active
choresToGroup = customFilteredChores
} else if (searchFilter !== 'All') {
// Quick filter active (Overdue, Due today, Label, Priority, etc.)
choresToGroup = filteredChores
} else if (hasQuickFilters) {
// Quick filter active (Due date, Priority, Labels)
choresToGroup = quickFilteredChores
} else if (!selectedProject || selectedProject.id === 'default') {
// No project selected or default project: only show tasks without a projectId
choresToGroup = chores.filter(chore => !chore.projectId)
@@ -245,11 +325,11 @@ const MyChores = () => {
return sections
}, [
chores,
filteredChores,
quickFilteredChores,
customFilteredChores,
tempFilter,
activeFilterId,
searchFilter,
hasQuickFilters,
selectedChoreSection,
selectedChoreFilter,
selectedProject,
@@ -399,7 +479,7 @@ const MyChores = () => {
}
// Handle legacy filter parameter (e.g., filter=unplanned)
if (oldFilter && searchFilter === 'All' && !activeFilterId) {
if (oldFilter && !hasQuickFilters && !activeFilterId) {
const filterMap = {
unplanned: 'No Due Date',
overdue: 'Overdue',
@@ -410,12 +490,8 @@ const MyChores = () => {
}
const filterName = filterMap[oldFilter.toLowerCase()]
if (filterName && FILTERS[filterName]) {
const filtered = FILTERS[filterName](
selectedProject ? projectFilteredChores : chores,
)
setFilteredChores(filtered)
setSearchFilter(filterName)
if (filterName) {
setQuickFilter('status', filterName)
setViewMode('default')
setSelectedCalendarDate(null)
}
@@ -423,7 +499,7 @@ const MyChores = () => {
}, [
searchParams,
chores,
searchFilter,
hasQuickFilters,
activeFilterId,
savedFilters,
applyCustomFilter,
@@ -431,8 +507,7 @@ const MyChores = () => {
clearActiveFilter,
selectedProject,
projectFilteredChores,
setSearchFilter,
setFilteredChores,
setQuickFilter,
setViewMode,
setSelectedCalendarDate,
])
@@ -497,13 +572,47 @@ const MyChores = () => {
clearSelection,
})
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
const baseChores = hasQuickFilters
? quickFilteredChores
: projectFilteredChores
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).map(result => result.item)
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
hasQuickFilters,
quickFilteredChores,
projectFilteredChores,
searchTerm,
])
const { showKeyboardShortcuts } = useKeyboardShortcuts({
isMultiSelectMode,
selectedChores,
addTaskModalOpen,
searchTerm,
searchFilter,
filteredChores,
searchFilter: hasQuickFilters || searchTerm?.length > 0 ? 'filtered' : 'All',
filteredChores: getFilteredChores,
choreSections,
openChoreSections,
handlers: {
@@ -554,25 +663,10 @@ const MyChores = () => {
const handleLabelFiltering = chipClicked => {
clearActiveFilter()
const baseChores = selectedProject ? projectFilteredChores : chores
if (chipClicked.label) {
const label = chipClicked.label
const labelFiltered = baseChores.filter(chore =>
chore.labelsV2.some(
l => l.id === label.id && l.created_by === label.created_by,
),
)
setFilteredChores(labelFiltered)
setSearchFilter('Label: ' + label.name)
setQuickFilter('label', [chipClicked.label.id])
} else if (chipClicked.priority) {
const priority = chipClicked.priority
const priorityFiltered = baseChores.filter(
chore => chore.priority === priority,
)
setFilteredChores(priorityFiltered)
setSearchFilter('Priority: ' + priority)
setQuickFilter('priority', [chipClicked.priority])
}
setSelectedCalendarDate(null)
}
@@ -624,8 +718,8 @@ const MyChores = () => {
const handleSearchChange = e => {
clearActiveFilter()
if (searchFilter !== 'All') {
setSearchFilter('All')
if (hasQuickFilters) {
clearQuickFilters()
}
const search = e.target.value
if (search === '') {
@@ -673,14 +767,13 @@ const MyChores = () => {
localStorage.setItem('openChoreSections', JSON.stringify(value))
}
const toggleViewMode = () => {
const modes = ['default', 'compact', 'calendar']
const currentIndex = modes.indexOf(viewMode)
const nextIndex = (currentIndex + 1) % modes.length
const newMode = modes[nextIndex]
const toggleViewMode = value => {
const newMode = value ?? (() => {
const modes = ['default', 'compact', 'calendar']
return modes[(modes.indexOf(viewMode) + 1) % modes.length]
})()
setViewMode(newMode)
localStorage.setItem('choreCardViewMode', newMode)
if (newMode !== 'calendar') {
setSelectedCalendarDate(null)
}
@@ -741,42 +834,6 @@ const MyChores = () => {
// )
// }
const getFilteredChores = useMemo(() => {
if (activeFilterId || tempFilter) {
return customFilteredChores
}
let baseChores = projectFilteredChores
if (searchTerm?.length > 0 || searchFilter !== 'All') {
if (searchTerm?.length > 0) {
const projectFilteredForSearch = baseChores.map(c => ({
...c,
raw_label: c.labelsV2?.map(l => l.name).join(' '),
}))
const fuse = new Fuse(projectFilteredForSearch, {
keys: ['name', 'raw_label'],
includeScore: true,
isCaseSensitive: false,
findAllMatches: true,
})
return fuse.search(searchTerm).map(result => result.item)
} else if (searchFilter !== 'All') {
return filteredChores
}
}
return baseChores
}, [
activeFilterId,
tempFilter,
customFilteredChores,
projectFilteredChores,
searchTerm,
searchFilter,
filteredChores,
])
const getChoresForDate = useCallback(
date => {
const filteredChoresData = getFilteredChores
@@ -801,7 +858,7 @@ const MyChores = () => {
setChores(newChores)
setFilteredChores(newChores)
setSearchFilter('All')
clearQuickFilters()
}
// Show error state when API is unreachable
@@ -876,322 +933,96 @@ const MyChores = () => {
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
/>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignContent: 'center',
alignItems: 'center',
gap: 0.5,
<ChoreToolbar
members={membersData?.res || []}
labels={userLabels || []}
projects={projectsWithDefault}
tempFilter={tempFilter}
tempFilterMeta={tempFilterMeta}
applyTempFilter={applyTempFilter}
clearTempFilter={clearTempFilter}
saveFilter={saveFilter}
updateFilter={updateFilter}
onFilterSaved={name =>
showSuccess({
title: 'Filter Saved',
message: `"${name}" has been saved`,
})
}
onClearAllFilters={() => {
clearQuickFilters()
clearActiveFilter()
setSelectedChoreFilterWithCache('anyone')
setSelectedProjectWithCache(
projectsWithDefault.find(p => p.id === 'default') || null,
)
updateFilterUrl(null, null)
}}
>
<SearchBar
value={searchTerm}
onChange={handleSearchChange}
onClose={handleSearchClose}
onFocus={() => setShowSearchFilter(true)}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
<SortAndGrouping
title='Group by'
k={'icon-menu-group-by'}
icon={<Sort />}
selectedItem={selectedChoreSection}
selectedFilter={selectedChoreFilter}
setFilter={filter => {
setSelectedChoreFilterWithCache(filter)
// Clear active custom filter when quick filter is applied
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
}}
onItemSelect={selected => {
setSelectedChoreSectionWithCache(selected.value)
setFilteredChores(chores)
setSearchFilter('All')
}}
onCreateNewFilter={() => {
setShowAdvancedFilterBuilder(true)
setEditingFilter(null)
}}
mouseClickHandler={handleMenuOutsideClick}
/>
{/* Project Selector - Hidden when active filter has project conditions */}
{projectsWithDefault.length > 1 &&
!hasProjectConditions &&
!hasFilterApplied && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* View Mode Toggle Button */}
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleViewMode}
title={
viewMode === 'default'
? 'Switch to Compact View'
: viewMode === 'compact'
? 'Switch to Calendar View'
: 'Switch to Card View'
resultCount={
hasQuickFilters || hasFilterApplied
? getFilteredChores.length
: undefined
}
totalCount={
hasQuickFilters || hasFilterApplied
? projectFilteredChores.length
: undefined
}
selectedProject={selectedProject}
onProjectSelect={project => {
setSelectedProjectWithCache(project)
clearActiveFilter()
}}
selectedAssigneeFilter={selectedChoreFilter}
onAssigneeFilterChange={filter => {
setSelectedChoreFilterWithCache(filter)
if (activeFilterId) {
clearActiveFilter()
updateFilterUrl(null, null)
}
>
{viewMode === 'default' ? (
<ViewAgenda />
) : viewMode === 'compact' ? (
<CalendarMonth />
) : (
<ViewModule />
)}
</IconButton>
{/* Multi-select Toggle Button */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{
height: 32,
width: 32,
borderRadius: '50%',
}}
onClick={toggleMultiSelectMode}
title={
isMultiSelectMode
? 'Exit Multi-select Mode (Ctrl+S)'
: 'Enable Multi-select Mode (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1000,
}}
/>
</Box>
</Box>
{/* Search Filter with animation */}
<Box
sx={{
overflow: 'hidden',
transition: 'all 0.3s ease-in-out',
maxHeight: showSearchFilter ? '150px' : '0',
opacity: showSearchFilter ? 1 : 0,
transform: showSearchFilter ? 'translateY(0)' : 'translateY(-10px)',
marginBottom: showSearchFilter ? 1 : 0,
}}
>
<div className='flex gap-4'>
<div className='grid flex-1 grid-cols-3 gap-4'>
<IconButtonWithMenu
label={' Priority'}
k={'icon-menu-priority-filter'}
icon={<PriorityHigh />}
options={Priorities}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ priority: selected.value })
}}
mouseClickHandler={handleMenuOutsideClick}
isActive={searchFilter.startsWith('Priority: ')}
/>
<IconButtonWithMenu
k={'icon-menu-labels-filter'}
label={' Labels'}
icon={<Style />}
options={userLabels}
selectedItem={searchFilter}
onItemSelect={selected => {
handleLabelFiltering({ label: selected })
}}
isActive={searchFilter.startsWith('Label: ')}
mouseClickHandler={handleMenuOutsideClick}
useChips
/>
<Button
onClick={handleFilterMenuOpen}
variant='outlined'
startDecorator={<Grain />}
color={
searchFilter && FILTERS[searchFilter] && searchFilter != 'All'
? 'primary'
: 'neutral'
}
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
>
{' Other'}
</Button>
<List
orientation='horizontal'
wrap
sx={{
mt: 0.2,
}}
>
<Menu
ref={menuRef}
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleFilterMenuClose}
>
{Object.keys(FILTERS).map((filter, index) => (
<MenuItem
key={`filter-list-${filter}-${index}`}
onClick={() => {
const filterFunction = FILTERS[filter]
const baseChores = selectedProject
? projectFilteredChores
: chores
const filteredChores =
filterFunction.length === 2
? filterFunction(baseChores, userProfile?.id)
: filterFunction(baseChores)
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}
<Chip
color={searchFilter === filter ? 'primary' : 'neutral'}
>
{(() => {
const baseChores = selectedProject
? projectFilteredChores
: chores
return FILTERS[filter].length === 2
? FILTERS[filter](baseChores, userProfile?.id)
.length
: FILTERS[filter](baseChores).length
})()}
</Chip>
</MenuItem>
))}
{searchFilter.startsWith('Label: ') ||
(searchFilter.startsWith('Priority: ') && (
<MenuItem
key={`filter-list-cancel-all-filters`}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Cancel All Filters
</MenuItem>
))}
</Menu>
</List>
</div>
<IconButton
variant='outlined'
color='neutral'
size='sm'
sx={{
height: 24,
borderRadius: 24,
}}
onClick={() => {
setShowSearchFilter(false)
setSearchTerm('')
setFilteredChores(chores)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
<CancelRounded />
</IconButton>
</div>
</Box>
{/* Custom Filters Section */}
<FilterSection
savedFilters={savedFilters}
activeFilterId={activeFilterId}
activeFilter={activeFilter}
hasProjectConditions={hasProjectConditions}
onFilterClick={filterId => {
onSavedFilterClick={filterId => {
if (activeFilterId === filterId) {
clearActiveFilter()
updateFilterUrl(null, null)
} else {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
setFilteredChores([])
// Reset quick filter to 'anyone' when custom filter is applied
if (selectedChoreFilter !== 'anyone') {
setSelectedChoreFilterWithCache('anyone')
}
// 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 => {
onSavedFilterEdit={filter => {
setEditingFilter(filter)
setShowAdvancedFilterBuilder(true)
}}
onClearActiveFilter={clearActiveFilter}
onCreateAdvancedFilter={() => setShowAdvancedFilterBuilder(true)}
updateFilterUrl={updateFilterUrl}
onSavedFilterDelete={deleteFilter}
onSavedFilterPin={pinFilter}
selectedGroupBy={selectedChoreSection}
onGroupBySelect={value => {
setSelectedChoreSectionWithCache(value)
setFilteredChores(chores)
clearQuickFilters()
}}
viewMode={viewMode}
onToggleViewMode={toggleViewMode}
isMultiSelectMode={isMultiSelectMode}
onToggleMultiSelect={toggleMultiSelectMode}
searchTerm={searchTerm}
onSearchChange={handleSearchChange}
onSearchClose={handleSearchClose}
searchInputRef={searchInputRef}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
<MultiSelectToolbar
@@ -1205,41 +1036,15 @@ const MyChores = () => {
onDelete={handleBulkDelete}
showKeyboardShortcuts={showKeyboardShortcuts}
selectAllDisabled={
searchTerm?.length > 0 || searchFilter !== 'All'
? selectedChores.size === filteredChores.length
searchTerm?.length > 0 || hasQuickFilters
? selectedChores.size === getFilteredChores.length
: selectedChores.size ===
choreSections.flatMap(s => s.content || []).length
}
/>
{/* Additional Filters Display */}
{searchFilter !== 'All' && (
<Chip
level='title-md'
gutterBottom
color='warning'
label={searchFilter}
onDelete={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
endDecorator={<CancelRounded />}
onClick={() => {
setFilteredChores(
selectedProject ? projectFilteredChores : chores,
)
setSearchFilter('All')
updateFilterUrl(null, null)
}}
>
Additional Filter: {searchFilter}
</Chip>
)}
{/* Show "Nothing scheduled" when appropriate based on current view mode */}
{(searchTerm?.length > 0 || searchFilter !== 'All' || activeFilterId
{(searchTerm?.length > 0 || hasQuickFilters || activeFilterId
? getFilteredChores.length === 0
: projectFilteredChores.length === 0) &&
// only if not in calendar view:
@@ -1267,10 +1072,9 @@ const MyChores = () => {
<>
<Button
onClick={() => {
setSearchFilter('All')
clearQuickFilters()
setSearchTerm('')
clearActiveFilter()
// reset project and filters :
setSelectedProjectWithCache(null)
updateFilterUrl(null, null)
}}
@@ -1721,59 +1525,4 @@ const MyChores = () => {
)
}
const FILTERS = {
All: function (chores) {
return chores
},
Overdue: function (chores) {
return chores.filter(chore => {
if (chore.nextDueDate === null) return false
return new Date(chore.nextDueDate) < new Date()
})
},
'Due today': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate).toDateString() === new Date().toDateString()
)
})
},
'Due in week': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) <
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) &&
new Date(chore.nextDueDate) > new Date()
)
})
},
'Due Later': function (chores) {
return chores.filter(chore => {
return (
new Date(chore.nextDueDate) > new Date(Date.now() + 24 * 60 * 60 * 1000)
)
})
},
'Created By Me': function (chores, userID) {
return chores.filter(chore => {
return chore.createdBy === userID
})
},
'Assigned To Me': function (chores, userID) {
return chores.filter(chore => {
return chore.assignedTo === userID
})
},
'No Due Date': function (chores) {
return chores.filter(chore => {
return chore.nextDueDate === null
})
},
'Pending Approval': function (chores) {
return chores.filter(chore => {
return chore.status === 3
})
},
}
export default MyChores

View File

@@ -0,0 +1,920 @@
/**
* PROTOTYPE Unified Chore Toolbar
*
* Proposed design to replace the current 3-surface layout:
* OLD: [Search] [Sort+Group+AssigneeFilter+CreateFilter] [ProjectSelector] [View] [Multiselect]
* + FilterBar (Due Date / Priority / Labels chips row)
* + FilterSection (saved/pinned filter chips row)
*
* NEW: [Search] [Filter(n)] [Group ▾] [View] [Multiselect]
* + active filter chips appear inline next to Filter button
* + Filter button opens ONE unified bottom sheet containing:
* Assignee · Created By · Status · Priority · Due Date · Labels · Projects · Points
* + Saved Filters section
*
* How to try it: in MyChores.jsx, replace the <Box sx={{display:'flex'...}}> toolbar block
* and the two rows below it (FilterBar + FilterSection) with:
* <ChoreToolbar ... />
*/
import {
ArrowDropDown,
CalendarMonth,
Check,
CheckBox,
CheckBoxOutlineBlank,
FilterList,
Save,
Sort,
Tune,
ViewAgenda,
ViewComfy,
ViewModule,
} from '@mui/icons-material'
import {
Badge,
Box,
Button,
ButtonGroup,
Chip,
Divider,
IconButton,
Input,
Menu,
MenuItem,
Typography,
} from '@mui/joy'
import { useEffect, useRef, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import ActiveFilterChips from '../../../components/common/filter/ActiveFilterChips'
import { Z_INDEX } from '../../../constants/zIndex'
import KeyboardShortcutHint from '../../../components/common/KeyboardShortcutHint'
import { FILTER_COLORS } from '../../../utils/Colors'
import Priorities from '../../../utils/Priorities'
import FilterBuilderContent, {
CHORE_STATUSES,
DUE_DATE_OPTIONS,
POINTS_OPERATORS,
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from './FilterBuilderContent'
import SearchBar from './SearchBar'
import ProjectSelector from '../../components/ProjectSelector'
// ─── sub-components for the Display sheet ────────────────────────────────────
const SectionHeader = ({ icon, label, badge }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
{icon && (
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
)}
<Typography level='title-sm' fontWeight={600}>
{label}
</Typography>
{badge != null && (
<Chip
size='sm'
variant='solid'
color='primary'
sx={{ ml: 'auto', fontSize: '0.7rem', height: 20 }}
>
{badge}
</Chip>
)}
</Box>
)
const OptionChips = ({ options, selected, multi, onToggle }) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = multi
? (selected || []).includes(opt.value)
: selected === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? opt.color ?? 'primary' : 'neutral'}
startDecorator={
opt.icon != null
? isSelected
? <Check sx={{ fontSize: 14 }} />
: opt.icon
: undefined
}
onClick={() => onToggle(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
// ─── main component ───────────────────────────────────────────────────────────
/**
* Props:
* -- Advanced filter (filter sheet) --
* members circle members for Assignee / Created By sections
* labels user labels for Labels section
* projects projects list (projectsWithDefault) for Projects section + Display sheet
* tempFilter current temp filter object { conditions, operator } or null
* tempFilterMeta metadata for temp filter, including saved-filter edit source when applicable
* applyTempFilter (filter) => void — called immediately as selections change
* clearTempFilter () => void
* saveFilter (filterData) => Promise — saves as a named filter
* updateFilter (filterId, filterData) => Promise — updates an existing saved filter
* onFilterSaved (name) => void — called after successful save (for notifications)
*
* -- Result counts --
* resultCount / totalCount
*
* -- Clear all --
* onClearAllFilters () => void
*
* -- Saved filters --
* savedFilters [{ id, name, color, count, isPinned }]
* activeFilterId number | null
* onSavedFilterClick (id) => void
* onSavedFilterEdit (filter) => void
* onSavedFilterDelete (id) => void
* onSavedFilterPin (id) => void
*
* -- Display sheet --
* selectedProject current project object (for Display sheet section)
* onProjectSelect (project) => void
* selectedAssigneeFilter 'anyone' | 'assigned_to_me' | 'available_for_me' | 'assigned_to_others'
* onAssigneeFilterChange (key) => void
* selectedGroupBy 'default' | 'due_date' | 'priority' | 'labels'
* onGroupBySelect (value) => void
* viewMode 'default' | 'compact' | 'calendar'
* onToggleViewMode (value?) => void
*
* -- Multi-select --
* isMultiSelectMode bool
* onToggleMultiSelect () => void
*
* -- Search --
* searchTerm / onSearchChange / onSearchClose / searchInputRef
* showKeyboardShortcuts
*/
const ChoreToolbar = ({
// advanced filter
members = [],
labels = [],
projects = [],
tempFilter,
tempFilterMeta,
applyTempFilter,
clearTempFilter,
saveFilter,
updateFilter,
onFilterSaved,
// result counts
resultCount,
totalCount,
// clear all
onClearAllFilters,
// project (for Display sheet)
selectedProject,
onProjectSelect,
// assignee (for Display sheet)
selectedAssigneeFilter = 'anyone',
onAssigneeFilterChange,
// saved / custom
savedFilters = [],
activeFilterId,
onSavedFilterClick,
onSavedFilterEdit,
onSavedFilterDelete,
onSavedFilterPin,
// grouping
selectedGroupBy = 'default',
onGroupBySelect,
// view + multiselect
viewMode = 'default',
onToggleViewMode,
isMultiSelectMode,
onToggleMultiSelect,
// search
searchTerm,
onSearchChange,
onSearchClose,
searchInputRef,
showKeyboardShortcuts,
}) => {
const [filterSheetOpen, setFilterSheetOpen] = useState(false)
const [displaySheetOpen, setDisplaySheetOpen] = useState(false)
const [localSelections, setLocalSelections] = useState(defaultSelections())
const [savingFilter, setSavingFilter] = useState(false)
const [saveFilterName, setSaveFilterName] = useState('')
const [saveMenuAnchorEl, setSaveMenuAnchorEl] = useState(null)
const [editingSavedFilter, setEditingSavedFilter] = useState(null)
const saveMenuRef = useRef(null)
const activeConditions = selectionsToConditions(localSelections)
// ── badge counts ─────────────────────────────────────────────────────────────
const tempConditionCount = tempFilter?.conditions?.length || 0
const savedFilterActive = activeFilterId != null ? 1 : 0
const totalActiveCount = tempConditionCount + savedFilterActive
const hasAnyActive = totalActiveCount > 0
// ── inline chip strip ────────────────────────────────────────────────────────
const inlineChips = []
const getConditionChipLabel = condition => {
if (!condition?.type) return 'Filter'
const typeLabels = {
assignee: 'Assignee',
createdBy: 'Created By',
status: 'Status',
priority: 'Priority',
label: 'Labels',
project: 'Project',
dueDate: 'Due Date',
points: 'Points',
}
const typeLabel = typeLabels[condition.type] || 'Filter'
const prefix = condition.operator === 'isNot' ? 'Not ' : ''
if (condition.type === 'dueDate') {
const dueDateLabel =
DUE_DATE_OPTIONS.find(o => o.value === condition.operator)?.label ||
'Custom'
return `${typeLabel}: ${dueDateLabel}`
}
if (condition.type === 'points') {
const pointsOp =
POINTS_OPERATORS.find(o => o.value === condition.operator)?.label ||
condition.operator ||
''
return `${typeLabel} ${pointsOp} ${condition.value ?? 0}`
}
const rawValues = Array.isArray(condition.value)
? condition.value
: condition.value != null
? [condition.value]
: []
const resolveLabel = value => {
if (condition.type === 'assignee' || condition.type === 'createdBy') {
const member = members.find(m => m.userId === value)
return member?.displayName || member?.username || String(value)
}
if (condition.type === 'status') {
return CHORE_STATUSES.find(s => s.value === value)?.label || String(value)
}
if (condition.type === 'priority') {
return Priorities.find(p => p.value === value)?.name || String(value)
}
if (condition.type === 'label') {
return labels.find(l => l.id === value)?.name || String(value)
}
if (condition.type === 'project') {
if (value === 'default') return 'Default Project'
return projects.find(p => p.id === value)?.name || String(value)
}
return String(value)
}
if (rawValues.length === 0) {
return `${prefix}${typeLabel}`
}
if (rawValues.length === 1) {
return `${prefix}${typeLabel}: ${resolveLabel(rawValues[0])}`
}
return `${prefix}${typeLabel} (${rawValues.length})`
}
const clearConditionAtIndex = index => {
const nextConditions = (tempFilter?.conditions || []).filter(
(_condition, conditionIndex) => conditionIndex !== index,
)
if (nextConditions.length === 0) {
setLocalSelections(defaultSelections())
clearTempFilter?.()
return
}
const nextFilter = {
...tempFilter,
operator: tempFilter?.operator || 'AND',
conditions: nextConditions,
}
setLocalSelections(conditionsToSelections(nextConditions))
applyTempFilter?.(nextFilter)
}
const activeSavedFilter = savedFilterActive
? savedFilters.find(f => f.id === activeFilterId)
: null
const activeChipConditions = savedFilterActive
? activeSavedFilter?.conditions || []
: tempFilter?.conditions || []
activeChipConditions.forEach((condition, index) => {
inlineChips.push({
key: `${savedFilterActive ? '__saved' : '__temp'}_${index}`,
label: getConditionChipLabel(condition),
onClear: () => {
if (savedFilterActive) {
onSavedFilterClick?.(activeFilterId)
return
}
clearConditionAtIndex(index)
},
})
})
// ── open filter sheet ────────────────────────────────────────────────────────
const openFilterSheet = () => {
if (tempFilter?.conditions?.length > 0) {
setLocalSelections(conditionsToSelections(tempFilter.conditions))
if (tempFilterMeta?.sourceFilterId) {
const sourceFilter =
savedFilters.find(f => f.id === tempFilterMeta.sourceFilterId) ||
null
setEditingSavedFilter(
sourceFilter ||
(tempFilterMeta.sourceFilterId
? {
id: tempFilterMeta.sourceFilterId,
name: tempFilterMeta.sourceFilterName,
description: tempFilterMeta.sourceFilterDescription,
color: tempFilterMeta.sourceFilterColor,
}
: null),
)
} else {
setEditingSavedFilter(null)
}
} else if (activeFilterId) {
const sf = savedFilters.find(f => f.id === activeFilterId)
setEditingSavedFilter(sf || null)
setLocalSelections(
sf?.conditions
? conditionsToSelections(sf.conditions)
: defaultSelections(),
)
} else {
setEditingSavedFilter(null)
setLocalSelections(defaultSelections())
}
setSavingFilter(false)
setSaveFilterName('')
setSaveMenuAnchorEl(null)
setFilterSheetOpen(true)
}
useEffect(() => {
if (!filterSheetOpen || savingFilter || activeConditions.length === 0) {
setSaveMenuAnchorEl(null)
}
}, [filterSheetOpen, savingFilter, activeConditions.length])
// ── selection changes → apply temp filter immediately ────────────────────────
const handleSelectionsChange = updater => {
setLocalSelections(prev => {
const next = typeof updater === 'function' ? updater(prev) : updater
const conditions = selectionsToConditions(next)
if (conditions.length > 0) {
applyTempFilter?.(
{ conditions, operator: 'AND' },
editingSavedFilter
? {
name: editingSavedFilter.name,
description: editingSavedFilter.description,
sourceFilterId: editingSavedFilter.id,
sourceFilterName: editingSavedFilter.name,
sourceFilterDescription: editingSavedFilter.description,
sourceFilterColor: editingSavedFilter.color,
isEditingSavedFilter: true,
}
: null,
)
} else {
clearTempFilter?.()
}
return next
})
}
// ── save filter ───────────────────────────────────────────────────────────────
const handleSaveFilter = () => {
const name = saveFilterName.trim()
if (!name) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
const usedColors = savedFilters.map(f => f.color)
const color =
FILTER_COLORS.find(c => !usedColors.includes(c.value))?.value ??
FILTER_COLORS[0].value
saveFilter?.({ name, description: '', color, conditions, operator: 'AND' })?.then?.(() => {
applyTempFilter?.({ conditions, operator: 'AND' }, { name })
onFilterSaved?.(name)
})
setSavingFilter(false)
setSaveFilterName('')
setFilterSheetOpen(false)
}
const handleUpdateFilter = () => {
if (!editingSavedFilter?.id || !updateFilter) return
const conditions = selectionsToConditions(localSelections)
if (conditions.length === 0) return
updateFilter(
editingSavedFilter.id,
{
name: editingSavedFilter.name,
description: editingSavedFilter.description || '',
color: editingSavedFilter.color,
conditions,
operator: 'AND',
},
)?.then?.(() => {
clearTempFilter?.()
onSavedFilterClick?.(editingSavedFilter.id)
onFilterSaved?.(editingSavedFilter.name)
})
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}
// ── display sheet helpers ────────────────────────────────────────────────────
const filterActive = activeFilterId != null || tempConditionCount > 0
const projectActive = selectedProject && selectedProject.id !== 'default' ? 1 : 0
const assigneeActive = selectedAssigneeFilter !== 'anyone' ? 1 : 0
const displayActive =
selectedGroupBy !== 'default' ||
viewMode !== 'default' ||
projectActive > 0 ||
assigneeActive > 0
const groupByOptions = [
{ value: 'default', label: 'Smart' },
{ value: 'due_date', label: 'Due Date' },
{ value: 'priority', label: 'Priority' },
{ value: 'labels', label: 'Labels' },
]
const assigneeOptions = [
{ value: 'anyone', label: 'Everyone' },
{ value: 'assigned_to_me', label: 'Mine' },
{ value: 'available_for_me', label: 'Available to me' },
{ value: 'assigned_to_others', label: 'Others' },
]
const viewOptions = [
{ value: 'default', label: 'Cards', icon: <ViewAgenda sx={{ fontSize: 16 }} /> },
{ value: 'compact', label: 'Compact', icon: <ViewComfy sx={{ fontSize: 16 }} /> },
{ value: 'calendar', label: 'Calendar', icon: <CalendarMonth sx={{ fontSize: 16 }} /> },
]
return (
<>
{/* ── Row 1: main toolbar ─────────────────────────────────────────────── */}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
justifyContent: 'space-between',
}}
>
{/* Search takes available space */}
<SearchBar
value={searchTerm}
onChange={onSearchChange}
onClose={onSearchClose}
showKeyboardShortcuts={showKeyboardShortcuts}
inputRef={searchInputRef}
/>
{/* Filter button */}
<Badge
badgeContent={totalActiveCount || null}
color='primary'
size='sm'
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<IconButton
variant={hasAnyActive ? 'solid' : 'outlined'}
color={hasAnyActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={openFilterSheet}
title='Filters'
>
<FilterList />
</IconButton>
</Badge>
{/* Project selector */}
{!filterActive && projects.filter(p => p.id !== 'default').length > 0 && (
<ProjectSelector
selectedProject={selectedProject?.name || 'Default Project'}
onProjectSelect={onProjectSelect}
showKeyboardShortcuts={showKeyboardShortcuts}
/>
)}
{/* Display button — View + Group combined */}
<IconButton
variant={displayActive ? 'solid' : 'outlined'}
color={displayActive ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={() => setDisplaySheetOpen(true)}
title='View & Group'
>
{viewMode === 'calendar' ? (
<CalendarMonth />
) : viewMode === 'compact' ? (
<ViewModule />
) : (
<ViewAgenda />
)}
</IconButton>
{/* Multiselect */}
<Box sx={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
variant={isMultiSelectMode ? 'solid' : 'outlined'}
color={isMultiSelectMode ? 'primary' : 'neutral'}
size='sm'
sx={{ height: 32, width: 32, borderRadius: '50%' }}
onClick={onToggleMultiSelect}
title={
isMultiSelectMode
? 'Exit multi-select (Ctrl+S)'
: 'Multi-select (Ctrl+S)'
}
>
{isMultiSelectMode ? <CheckBox /> : <CheckBoxOutlineBlank />}
</IconButton>
<KeyboardShortcutHint
shortcut='S'
show={showKeyboardShortcuts}
sx={{ position: 'absolute', top: -8, right: -8, zIndex: 1000 }}
/>
</Box>
</Box>
{/* ── Row 2: active filter chips ──────────────────────────────────────── */}
{hasAnyActive && (
<ActiveFilterChips
chips={inlineChips}
onOpen={openFilterSheet}
onClearAll={() => {
setLocalSelections(defaultSelections())
onClearAllFilters?.()
}}
resultCount={resultCount}
totalCount={totalCount}
maxVisible={2}
chipSize='md'
clearButtonSize='sm'
clearButtonSx={{ color: 'text.secondary' }}
/>
)}
{/* ── Unified Filter bottom sheet ─────────────────────────────────────── */}
<BottomSheetModal
open={filterSheetOpen}
onClose={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tune sx={{ fontSize: 20 }} />
Filters
{hasAnyActive && (
<Chip size='sm' variant='solid' color='primary' sx={{ ml: 0.5 }}>
{totalActiveCount}
</Chip>
)}
</Box>
}
footer={
savingFilter ? (
<Box
sx={{ display: 'flex', gap: 1, width: '100%', alignItems: 'center' }}
>
<Input
size='sm'
placeholder='Filter name…'
value={saveFilterName}
onChange={e => setSaveFilterName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveFilter()}
autoFocus
sx={{ flex: 1 }}
/>
<Button
size='sm'
onClick={handleSaveFilter}
disabled={!saveFilterName.trim()}
>
Save
</Button>
<Button
size='sm'
variant='plain'
color='neutral'
onClick={() => setSavingFilter(false)}
>
Cancel
</Button>
</Box>
) : (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: 1,
}}
>
<Button
variant='plain'
color='danger'
size='sm'
disabled={!hasAnyActive && activeConditions.length === 0}
onClick={() => {
setLocalSelections(defaultSelections())
setSaveMenuAnchorEl(null)
onClearAllFilters?.()
setFilterSheetOpen(false)
}}
>
Clear all
</Button>
{activeConditions.length > 0 ? (
<>
<ButtonGroup variant='solid' color='primary'>
<Button
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
{resultCount != null
? `Show ${resultCount}`
: 'Done'}
</Button>
<IconButton
ref={saveMenuRef}
onClick={e => setSaveMenuAnchorEl(e.currentTarget)}
>
<ArrowDropDown />
</IconButton>
</ButtonGroup>
<Menu
anchorEl={saveMenuAnchorEl}
open={Boolean(saveMenuAnchorEl)}
onClose={() => setSaveMenuAnchorEl(null)}
placement='top-end'
sx={{ zIndex: Z_INDEX.MODAL_CONTENT + 10 }}
>
<MenuItem
onClick={handleUpdateFilter}
disabled={!editingSavedFilter}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save Filter
</MenuItem>
<MenuItem
onClick={() => {
setSaveMenuAnchorEl(null)
setSaveFilterName(
editingSavedFilter
? `${editingSavedFilter.name} Copy`
: '',
)
setSavingFilter(true)
}}
>
<Save sx={{ fontSize: 16, mr: 1 }} />
Save as New Filter
</MenuItem>
</Menu>
</>
) : (
<Button
variant='solid'
color='primary'
onClick={() => {
setSaveMenuAnchorEl(null)
setFilterSheetOpen(false)
}}
sx={{ minWidth: 140 }}
>
Done
</Button>
)}
</Box>
)
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Full advanced filter content */}
<FilterBuilderContent
selections={localSelections}
onSelectionsChange={handleSelectionsChange}
members={members}
labels={labels}
projects={projects}
/>
{/* Saved filters section */}
{savedFilters.length > 0 && (
<>
<Divider sx={{ my: 2.5 }} />
<Typography level='title-sm' fontWeight={600} sx={{ mb: 1.5 }}>
Saved Filters
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{savedFilters.map(filter => {
const isActive = activeFilterId === filter.id
return (
<Chip
key={filter.id}
variant={isActive ? 'solid' : 'soft'}
color='neutral'
startDecorator={
isActive ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Chip size='sm' variant='plain' color='neutral'>
{filter.count ?? 0}
</Chip>
)
}
onClick={() => {
onSavedFilterClick?.(filter.id)
if (!isActive) setFilterSheetOpen(false)
}}
sx={{
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
...(filter.color && !isActive
? { borderColor: filter.color }
: {}),
}}
>
{filter.name}
</Chip>
)
})}
</Box>
</>
)}
</Box>
</BottomSheetModal>
{/* ── Display bottom sheet (View + Group + Assignee + Project) ──────────── */}
<BottomSheetModal
open={displaySheetOpen}
onClose={() => setDisplaySheetOpen(false)}
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ViewAgenda sx={{ fontSize: 20 }} />
Display
</Box>
}
footer={
<Button onClick={() => setDisplaySheetOpen(false)} sx={{ minWidth: 140 }}>
Done
</Button>
}
>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* View section */}
<SectionHeader label='View' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{viewOptions.map(opt => (
<Chip
key={opt.value}
variant={viewMode === opt.value ? 'solid' : 'soft'}
color={viewMode === opt.value ? 'primary' : 'neutral'}
startDecorator={
viewMode === opt.value
? <Check sx={{ fontSize: 14 }} />
: opt.icon
}
onClick={() => onToggleViewMode?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Group by section */}
<SectionHeader
icon={<Sort />}
label='Group by'
badge={
selectedGroupBy !== 'default'
? groupByOptions.find(o => o.value === selectedGroupBy)?.label
: null
}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{groupByOptions.map(opt => (
<Chip
key={opt.value}
variant={selectedGroupBy === opt.value ? 'solid' : 'soft'}
color={selectedGroupBy === opt.value ? 'primary' : 'neutral'}
onClick={() => onGroupBySelect?.(opt.value)}
sx={{
py: 0.64,
cursor: 'pointer',
transition: 'all 0.15s ease',
userSelect: 'none',
'&:hover': { opacity: 0.85 },
}}
>
{opt.label}
</Chip>
))}
</Box>
{/* Show tasks for section */}
<Divider sx={{ my: 2.5 }} />
<SectionHeader
icon={<FilterList />}
label='Show tasks for'
badge={
selectedAssigneeFilter !== 'anyone'
? assigneeOptions.find(o => o.value === selectedAssigneeFilter)?.label
: null
}
/>
<OptionChips
options={assigneeOptions}
selected={selectedAssigneeFilter}
multi={false}
onToggle={v => onAssigneeFilterChange?.(v)}
/>
</Box>
</BottomSheetModal>
</>
)
}
export default ChoreToolbar

View File

@@ -0,0 +1,477 @@
import {
CalendarMonth,
Check,
FolderOpen,
Label,
Person,
PriorityHigh,
Stars,
TaskAlt,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Divider, Input, Typography } from '@mui/joy'
import Priorities from '../../../utils/Priorities'
export const DUE_DATE_OPTIONS = [
{ value: 'isOverdue', label: 'Overdue', color: 'danger' },
{ value: 'isDueToday', label: 'Today', color: 'warning' },
{ value: 'isDueTomorrow', label: 'Tomorrow', color: 'primary' },
{ value: 'isDueThisWeek', label: 'This Week', color: 'primary' },
{ value: 'isDueThisMonth', label: 'This Month', color: 'neutral' },
{ value: 'hasNoDueDate', label: 'No Due Date', color: 'neutral' },
{ value: 'hasDueDate', label: 'Has Due Date', color: 'neutral' },
]
export const POINTS_OPERATORS = [
{ value: 'greaterThan', label: '>' },
{ value: 'greaterThanOrEqual', label: '>=' },
{ value: 'equals', label: '=' },
{ value: 'lessThanOrEqual', label: '<=' },
{ value: 'lessThan', label: '<' },
]
export const CHORE_STATUSES = [
{ value: 0, label: 'Active' },
{ value: 1, label: 'Started' },
{ value: 2, label: 'In Progress' },
{ value: 3, label: 'Pending Approval' },
]
export const defaultSelections = () => ({
assignee: { operator: 'is', values: [] },
createdBy: { operator: 'is', values: [] },
status: { operator: 'is', values: [] },
priority: { operator: 'is', values: [] },
label: { operator: 'is', values: [] },
project: { operator: 'is', values: [] },
dueDate: { operator: null },
points: { operator: 'greaterThan', value: 0, active: false },
})
export const conditionsToSelections = conditions => {
const sel = defaultSelections()
if (!conditions) return sel
conditions.forEach(c => {
if (c.type === 'dueDate') {
sel.dueDate = { operator: c.operator }
} else if (c.type === 'points') {
sel.points = { operator: c.operator, value: c.value ?? 0, active: true }
} else if (c.type in sel) {
sel[c.type] = {
operator: c.operator ?? 'is',
values: Array.isArray(c.value)
? c.value
: c.value != null
? [c.value]
: [],
}
}
})
return sel
}
export const selectionsToConditions = selections => {
const conditions = []
;['assignee', 'createdBy', 'status', 'priority', 'label', 'project'].forEach(
type => {
if (selections[type].values?.length > 0) {
conditions.push({
type,
operator: selections[type].operator,
value: selections[type].values,
})
}
},
)
if (selections.dueDate.operator) {
conditions.push({
type: 'dueDate',
operator: selections.dueDate.operator,
value: null,
})
}
if (selections.points.active) {
conditions.push({
type: 'points',
operator: selections.points.operator,
value: selections.points.value,
})
}
return conditions
}
const SectionHeader = ({ icon, label, children }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
<Box
sx={{
color: 'text.secondary',
display: 'flex',
alignItems: 'center',
'& svg': { fontSize: 18 },
}}
>
{icon}
</Box>
<Typography level='title-sm' sx={{ fontWeight: 600 }}>
{label}
</Typography>
{children}
</Box>
)
const IncludeExcludeToggle = ({
value,
onChange,
labels = ['Include', 'Exclude'],
}) => (
<Box sx={{ display: 'flex', gap: 0.5, ml: 'auto' }}>
{[
{ op: 'is', label: labels[0] },
{ op: 'isNot', label: labels[1] },
].map(o => (
<Chip
key={o.op}
size='sm'
variant={value === o.op ? 'solid' : 'soft'}
color={
value === o.op ? (o.op === 'isNot' ? 'danger' : 'primary') : 'neutral'
}
onClick={() => onChange(o.op)}
sx={{ cursor: 'pointer', userSelect: 'none', transition: 'all 0.15s ease' }}
>
{o.label}
</Chip>
))}
</Box>
)
/**
* Reusable filter conditions UI used by both the filter sheet in ChoreToolbar
* and the AdvancedFilterBuilder save modal.
*
* `onSelectionsChange` must accept either a new selections object or a
* functional updater `prev => next` (same contract as React's setState setter).
*/
const FilterBuilderContent = ({
selections,
onSelectionsChange,
members = [],
labels = [],
projects = [],
}) => {
const toggleValue = (type, value) =>
onSelectionsChange(prev => {
const cur = prev[type].values || []
const next = cur.includes(value)
? cur.filter(v => v !== value)
: [...cur, value]
return { ...prev, [type]: { ...prev[type], values: next } }
})
const setOperator = (type, op) =>
onSelectionsChange(prev => ({
...prev,
[type]: { ...prev[type], operator: op },
}))
const toggleDueDate = op =>
onSelectionsChange(prev => ({
...prev,
dueDate: { operator: prev.dueDate.operator === op ? null : op },
}))
const setPointsOperator = op =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, operator: op, active: true },
}))
const setPointsValue = val =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, value: val, active: val > 0 },
}))
const chipRow = (type, options, getChipProps) => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{options.map(opt => {
const isSelected = selected.includes(opt.value)
const extra = getChipProps ? getChipProps(opt, isSelected) : {}
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (extra.color ?? 'primary') : 'neutral'}
startDecorator={
isSelected
? <Check sx={{ fontSize: 14 }} />
: (extra.startDecorator ?? null)
}
onClick={() => toggleValue(type, opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
)
}
const personChipRow = type => {
const selected = selections[type].values || []
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{members.map(m => {
const isSelected = selected.includes(m.userId)
return (
<Chip
key={m.userId}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? 'primary' : 'neutral'}
startDecorator={
isSelected ? (
<Check sx={{ fontSize: 14 }} />
) : (
<Avatar
src={m.image}
alt={m.displayName}
sx={{ '--Avatar-size': '20px' }}
/>
)
}
onClick={() => toggleValue(type, m.userId)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{m.displayName || m.username}
</Chip>
)
})}
</Box>
)
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Assignee */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Assignee'>
<IncludeExcludeToggle
value={selections.assignee.operator}
onChange={op => setOperator('assignee', op)}
/>
</SectionHeader>
{personChipRow('assignee')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Created By */}
{members.length > 0 && (
<>
<SectionHeader icon={<Person />} label='Created By'>
<IncludeExcludeToggle
value={selections.createdBy.operator}
onChange={op => setOperator('createdBy', op)}
/>
</SectionHeader>
{personChipRow('createdBy')}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Status */}
<SectionHeader icon={<TaskAlt />} label='Status'>
<IncludeExcludeToggle
value={selections.status.operator}
onChange={op => setOperator('status', op)}
/>
</SectionHeader>
{chipRow('status', CHORE_STATUSES)}
<Divider sx={{ my: 2.5 }} />
{/* Priority */}
<SectionHeader icon={<PriorityHigh />} label='Priority'>
<IncludeExcludeToggle
value={selections.priority.operator}
onChange={op => setOperator('priority', op)}
/>
</SectionHeader>
{chipRow(
'priority',
Priorities.map(p => ({ value: p.value, label: p.name })),
(opt, isSelected) => ({
color: isSelected
? (Priorities.find(p => p.value === opt.value)?.color || 'primary')
: 'neutral',
startDecorator: !isSelected
? Priorities.find(p => p.value === opt.value)?.icon
: null,
}),
)}
<Divider sx={{ my: 2.5 }} />
{/* Due Date */}
<SectionHeader icon={<CalendarMonth />} label='Due Date' />
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{DUE_DATE_OPTIONS.map(opt => {
const isSelected = selections.dueDate.operator === opt.value
return (
<Chip
key={opt.value}
variant={isSelected ? 'solid' : 'soft'}
color={isSelected ? (opt.color ?? 'primary') : 'neutral'}
startDecorator={isSelected ? <Check sx={{ fontSize: 14 }} /> : null}
onClick={() => toggleDueDate(opt.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
}}
>
{opt.label}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
{/* Labels */}
{labels.length > 0 && (
<>
<SectionHeader icon={<Label />} label='Labels'>
<IncludeExcludeToggle
value={selections.label.operator}
onChange={op => setOperator('label', op)}
labels={['Has', "Doesn't Have"]}
/>
</SectionHeader>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{labels.map(lbl => {
const isSelected = selections.label.values.includes(lbl.id)
return (
<Chip
key={lbl.id}
variant={isSelected ? 'solid' : 'soft'}
color='neutral'
startDecorator={
<Box
sx={{
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: lbl.color || '#90a4ae',
flexShrink: 0,
}}
/>
}
endDecorator={isSelected ? <Check sx={{ fontSize: 12 }} /> : null}
onClick={() => toggleValue('label', lbl.id)}
sx={{
cursor: 'pointer',
userSelect: 'none',
transition: 'all 0.15s ease',
...(isSelected && {
outline: '2px solid',
outlineColor: 'primary.400',
}),
}}
>
{lbl.name}
</Chip>
)
})}
</Box>
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Projects */}
{projects.length > 0 && (
<>
<SectionHeader icon={<FolderOpen />} label='Projects'>
<IncludeExcludeToggle
value={selections.project.operator}
onChange={op => setOperator('project', op)}
/>
</SectionHeader>
{chipRow('project', [
{ value: 'default', label: 'Default Project' },
...projects
.filter(p => p.id !== 'default')
.map(p => ({ value: p.id, label: p.name })),
])}
<Divider sx={{ my: 2.5 }} />
</>
)}
{/* Points */}
<SectionHeader icon={<Stars />} label='Points' />
<Box
sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}
>
{POINTS_OPERATORS.map(op => (
<Chip
key={op.value}
size='sm'
variant={
selections.points.operator === op.value && selections.points.active
? 'solid'
: 'soft'
}
color={
selections.points.operator === op.value && selections.points.active
? 'primary'
: 'neutral'
}
onClick={() => setPointsOperator(op.value)}
sx={{
cursor: 'pointer',
userSelect: 'none',
fontFamily: 'monospace',
fontWeight: 600,
}}
>
{op.label}
</Chip>
))}
<Input
type='number'
size='sm'
value={selections.points.value}
onChange={e => setPointsValue(parseInt(e.target.value) || 0)}
sx={{ width: 80 }}
slotProps={{ input: { min: 0 } }}
/>
{selections.points.active && (
<Chip
size='sm'
variant='soft'
color='danger'
onClick={() =>
onSelectionsChange(prev => ({
...prev,
points: { ...prev.points, active: false, value: 0 },
}))
}
sx={{ cursor: 'pointer' }}
>
Clear
</Chip>
)}
</Box>
</Box>
)
}
export default FilterBuilderContent

View File

@@ -90,6 +90,8 @@ export const useCustomFilters = (chores, membersData, labels, projects) => {
}, [chores, activeFilter, tempFilter, context])
const applyCustomFilter = useCallback(filterId => {
setTempFilter(null)
setTempFilterMeta(null)
setActiveFilterId(filterId)
}, [])

View File

@@ -8,11 +8,21 @@ import {
import '@meauxt/react-swipeable-list/dist/styles.css'
import {
Analytics,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
FilterList,
Group,
History,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Star,
ThumbDown,
Timelapse,
TrendingUp,
} from '@mui/icons-material'
@@ -22,8 +32,10 @@ import { Box, Button, Card, Container, Grid, Sheet, Typography } from '@mui/joy'
import moment from 'moment'
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import FilterBar from '../../components/common/FilterBar'
import { useLocalization } from '../../contexts/LocalizationContext'
import useConfirmationModal from '../../hooks/useConfirmationModal'
import { useFilter } from '../../hooks/useFilter'
import { usePendingCommands } from '../../hooks/usePendingCommands'
import {
useChoreHistory,
@@ -35,6 +47,7 @@ import { useNotification } from '../../service/NotificationProvider'
import { ChoreHistoryStatus } from '../../utils/Chores'
import LoadingComponent from '../components/Loading'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import ConfirmationModal from '../Modals/Inputs/ConfirmationModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import HistoryCard from './HistoryCard'
@@ -49,7 +62,8 @@ const ChoreHistory = () => {
const { fmt } = useLocalization()
const [showMoreInfoId, setShowMoreInfoId] = useState(null)
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const { showSuccess } = useNotification()
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const { showSuccess, showError } = useNotification()
// React Query hooks
const { data: choreHistoryData, isLoading } = useChoreHistory(choreId)
const { data: circleMembersData } = useCircleMembers()
@@ -77,6 +91,61 @@ const ChoreHistory = () => {
}, {})
}, [pendingCmds])
const filterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <FilterList />,
options: [
{ value: ChoreHistoryStatus.COMPLETED, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.SKIPPED, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.PENDING_APPROVAL, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: ChoreHistoryStatus.REJECTED, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'completedBy',
label: 'Completed By',
type: 'multi-select',
icon: <Person />,
options: performers.map(p => ({
value: p.userId,
label: p.displayName,
avatar: p.image,
})),
filterFn: (item, values) => values.includes(item.completedBy),
},
{
id: 'dateRange',
label: 'Completed At',
type: 'date-range',
icon: <CalendarMonth />,
filterFn: (item, value) => {
const performed = new Date(item.performedAt || item.updatedAt)
if (value.from && performed < new Date(value.from)) return false
if (value.to && performed > new Date(value.to)) return false
return true
},
},
],
[performers],
)
const { filteredData: filteredHistory, activeFilters, setFilter, clearAll, activeFilterCount } =
useFilter(choreHistory, filterDefs)
const handleDelete = historyEntry => {
showConfirmation(
`Are you sure you want to delete this history record?`,
@@ -224,9 +293,10 @@ const ChoreHistory = () => {
}
return (
<Container maxWidth='md'>
<Container maxWidth='md' sx={{ px: 0 }}>
{/* Enhanced Header Section */}
<Box sx={{ mb: 4 }}>
<Box sx={{ gap: 2, p: 2 }}>
{/* <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2, p: 2 }}> */}
{/* Statistics Cards Grid - Compact Design */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<History sx={{ fontSize: '1.5rem' }} />
@@ -304,7 +374,9 @@ const ChoreHistory = () => {
</Box>
{/* History Section Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, p: 2 }}>
<Analytics sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
@@ -313,14 +385,50 @@ const ChoreHistory = () => {
Task Activity
</Typography>
</Box>
<Box sx={{ px: 2 }}>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={setFilter}
onClearAll={clearAll}
resultCount={filteredHistory.length}
totalCount={choreHistory.length}
/>
</Box>
{filteredHistory.length === 0 && activeFilterCount > 0 && (
<Box
sx={{
textAlign: 'center',
py: 6,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1.5,
}}
>
<FilterList sx={{ fontSize: '3rem', color: 'text.tertiary' }} />
<Typography level='title-md' sx={{ color: 'text.secondary' }}>
No results match your filters
</Typography>
<Typography level='body-sm' sx={{ color: 'text.tertiary' }}>
Try adjusting or clearing the active filters.
</Typography>
<Button variant='soft' size='sm' onClick={clearAll} sx={{ mt: 0.5 }}>
Clear filters
</Button>
</Box>
)}
{filteredHistory.length > 0 && (
<Sheet
variant='plain'
sx={{ borderRadius: 'sm', boxShadow: 'md', overflow: 'hidden' }}
sx={{ borderRadius: 'sm', overflow: 'hidden' }}
>
{/* Chore History List (Updated Style) */}
<SwipeableList type={ListType.IOS} fullSwipe={false}>
{choreHistory.map((historyEntry, index) => (
{filteredHistory.map((historyEntry, index) => (
<SwipeableListItem
key={historyEntry.id || index}
swipeActionOpen={
@@ -385,6 +493,19 @@ const ChoreHistory = () => {
performers={performers}
allHistory={choreHistory}
index={index}
onViewDetails={() => {
setDetailModalConfig({
isOpen: true,
entry: historyEntry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig({ isOpen: false })
setEditHistory(record)
setIsEditModalOpen(true)
},
})
}}
pendingCommands={pendingByHistoryId[historyEntry.id] || []}
onViewNote={notes => {
setNoteViewerConfig({
@@ -407,6 +528,7 @@ const ChoreHistory = () => {
))}
</SwipeableList>
</Sheet>
)}
<EditHistoryModal
config={{
isOpen: isEditModalOpen,
@@ -481,7 +603,8 @@ const ChoreHistory = () => {
/>
<ConfirmationModal config={confirmModalConfig} />
<NoteViewerModal config={noteViewerConfig} />
</Container>
<HistoryDetailModal config={detailModalConfig} />
</Container>
)
}

View File

@@ -1,99 +1,47 @@
import {
AccessTime,
CalendarMonth,
Check,
EventNote,
HourglassEmpty,
MoreVert,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Timelapse,
Toll,
} from '@mui/icons-material'
import { Avatar, Box, Chip, Grid, IconButton, Typography } from '@mui/joy'
import { Avatar, Box, Card, Chip, IconButton, Typography } from '@mui/joy'
import moment from 'moment'
import { useLocalization } from '../../contexts/LocalizationContext'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import PendingBadge from '../components/PendingBadge'
const getCompletedChip = historyEntry => {
if (
historyEntry.status === 0 ||
historyEntry.status === 5 ||
historyEntry.status === 6
) {
return null
}
if (!historyEntry.dueDate) {
return null
// <Chip
// size='sm'
// variant='soft'
// color='neutral'
// startDecorator={<CalendarViewDay />}
// >
// No Due Date
// </Chip>
}
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
// TODO: make this a config at some point
const gracePeriod = 6 * 60 * 60 * 1000 // 6 hours in milliseconds
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }}
startDecorator={<Check />}
>
On Time
</Chip>
)
} else if (performedAt.isBefore(dueDate)) {
return (
<Chip
size='sm'
variant='soft'
sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }}
startDecorator={<Check />}
>
Early
</Chip>
)
} else {
return (
<Chip
size='sm'
variant='solid'
sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}
startDecorator={<Timelapse />}
>
Late
</Chip>
)
}
}
const formatTime = seconds => {
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) {
return null
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
if (typeof seconds !== 'number' || isNaN(seconds) || seconds < 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
const stripHtmlTags = html => {
if (!html) return ''
if (typeof document === 'undefined') {
return String(html).replace(/<[^>]*>/g, '')
}
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
const statusConfig = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
/**
* Compact HistoryCard component - content only
*/
const HistoryCard = ({
allHistory,
performers,
@@ -102,235 +50,137 @@ const HistoryCard = ({
pendingCommands,
onToggleActions,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const performer = performers.find(p => p.userId === historyEntry.completedBy)
const assignedTo = performers.find(p => p.userId === historyEntry.assignedTo)
const config = statusConfig[historyEntry.status] ?? statusConfig[1]
const displayLabel =
historyEntry.status === 6 && !historyEntry.dueDate ? 'Scheduled' : config.label
const actionDate = historyEntry.performedAt || historyEntry.updatedAt
const formatTimeDifference = (startDate, endDate) => {
const diffInMinutes = moment(startDate).diff(endDate, 'minutes')
let timeValue = diffInMinutes
let unit = 'minute'
const getTimingLine = () => {
const { status, performedAt, dueDate } = historyEntry
if (!dueDate) return null
if (diffInMinutes >= 60) {
const diffInHours = moment(startDate).diff(endDate, 'hours')
timeValue = diffInHours
unit = 'hour'
if (diffInHours >= 24) {
const diffInDays = moment(startDate).diff(endDate, 'days')
timeValue = diffInDays
unit = 'day'
}
if (status === 6) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
return `${timeValue} ${unit}${timeValue !== 1 ? 's' : ''}`
if (status === 5) {
return `Was due ${moment(dueDate).format('MMM D')}`
}
if ((status === 1 || status === 2 || status === 0) && performedAt) {
const diffHours = moment(performedAt).diff(dueDate, 'hours')
const abs = Math.abs(diffHours)
if (abs <= 6) return null // chip already says "On Time"
if (diffHours < 0) return abs >= 48 ? `${Math.floor(abs / 24)}d before due date` : `${abs}h before due date`
return abs >= 48 ? `${Math.floor(abs / 24)}d after due date` : `${abs}h after due date`
}
return null
}
const getStatusAvatar = () => {
const statusMap = {
0: { icon: <AccessTime />, color: 'primary' }, // Started
1: { icon: <Check />, color: 'success' }, // Completed
2: { icon: <Redo />, color: 'warning' }, // Skipped
3: { icon: <HourglassEmpty />, color: 'neutral' }, // Pending Approval
4: { icon: <ThumbDown />, color: 'danger' }, // Rejected
5: { icon: <RunningWithErrors />, color: 'danger' }, // Missed
6: { icon: <Schedule />, color: 'warning' }, // Rescheduled
}
const timingLine = getTimingLine()
const noteLabel = historyEntry.status === 2 || historyEntry.status === 4 ? 'Reason' : 'Note'
const plainTextNotes = historyEntry.notes ? stripHtmlTags(historyEntry.notes) : ''
const config = statusMap[historyEntry.status] || statusMap[1]
return (
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{
width: 24,
height: 24,
'& svg': { fontSize: '14px' },
}}
>
{config.icon}
</Avatar>
)
}
const metaTextParts = [
fmt.dateTime(actionDate),
historyEntry.completedBy !== historyEntry.assignedTo && assignedTo
? `Assigned to ${assignedTo.displayName}`
: null,
historyEntry?.duration > 0 ? `${formatTime(historyEntry.duration)}` : null,
historyEntry?.points > 0 ? `${historyEntry.points} pt${historyEntry.points > 1 ? 's' : ''}` : null,
].filter(Boolean)
return (
<Box
onClick={() => onViewDetails?.()}
sx={{
display: 'flex',
alignItems: 'center',
minHeight: 64,
minWidth: '100%',
px: 2,
py: 1.5,
bgcolor: 'background.body',
borderBottom: '1px solid',
borderColor: 'divider',
borderLeft: '3px solid',
borderLeftColor: `${config.color}.400`,
cursor: onViewDetails ? 'pointer' : 'default',
'&:hover': onViewDetails ? { bgcolor: 'background.level1' } : {},
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Grid container spacing={1} alignItems='center'>
{/* First Row/Column: Status and Time Info */}
<Grid xs={12} sm={8}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
}}
<Box sx={{ flex: 1, minWidth: 0, px: 2, py: 1.5 }}>
{/* Status + timing chip */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar
size='sm'
color={config.color}
variant='soft'
sx={{ width: 20, height: 20, '& svg': { fontSize: '11px' } }}
>
{getStatusAvatar()}
{config.icon}
</Avatar>
<Typography level='title-sm' fontWeight='lg' sx={{ color: `${config.color}.plainColor` }}>
{displayLabel}
</Typography>
</Box>
</Box>
<Typography
level='body-sm'
sx={{
color: 'text.secondary',
fontWeight: 'md',
}}
>
{historyEntry.status === 0
? 'In Progress'
: historyEntry.status === 1
? 'Completed'
: historyEntry.status === 2
? 'Skipped'
: historyEntry.status === 3
? 'Pending Approval'
: historyEntry.status === 4
? 'Rejected'
: historyEntry.status === 5
? 'Missed'
: historyEntry.status === 6
? 'Rescheduled'
: 'Completed'}
</Typography>
{/* Timing relationship line */}
{timingLine && (
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.25 }}>
{timingLine}
</Typography>
)}
<Chip size='sm' startDecorator={<EventNote />}>
{fmt.dateTime(
historyEntry.performedAt || historyEntry.updatedAt,
)}
</Chip>
{/* Notes inline */}
<Box sx={{ display: 'flex', gap: 0.5 }}>
{getCompletedChip(historyEntry)}
</Box>
</Box>
</Grid>
{plainTextNotes && (
<Card
variant='soft'
color='neutral'
size='sm'
sx={{ mt: 0.5, whiteSpace: 'pre-wrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
<Typography
level='body-xs'
sx={{ color: 'text.secondary', fontStyle: 'italic', mb: 0.25, cursor: 'pointer' }}
onClick={e => { e.stopPropagation(); onViewNote?.(historyEntry.notes) }}
>
{plainTextNotes.length > 80 ? `${plainTextNotes.slice(0, 80)}` : plainTextNotes}
</Typography>
</Card>
)}
{/* Second Row/Column: Completion Status (right side on desktop) */}
<Grid xs={12} sm={4}>
<Box
sx={{
display: 'flex',
justifyContent: { xs: 'flex-start', sm: 'flex-end' },
alignItems: 'center',
gap: 1,
}}
{/* Metadata strip: performer chip + date + extras */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mt: 0.5, flexWrap: 'wrap' }}>
{performer && (
<Chip
size='sm'
variant='soft'
color='neutral'
startDecorator={
<Avatar src={performer.image} alt={performer.displayName} sx={{ width: 14, height: 14 }} />
}
>
{historyEntry.dueDate && (
<Chip size='sm' startDecorator={<CalendarMonth />}>
{fmt.dateTime(historyEntry.dueDate)}
</Chip>
)}
</Box>
</Grid>
{/* Third Row: Performer and Assignment Info */}
<Grid xs={12}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flexWrap: 'wrap',
mt: 0.5,
}}
>
{performer && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={
<Avatar
src={performer?.image}
alt={performer?.displayName}
/>
}
>
{performer?.displayName || 'Unknown'}
</Chip>
)}
{historyEntry.completedBy !== historyEntry.assignedTo &&
assignedTo && (
<Chip
size='sm'
variant='outlined'
color='neutral'
startDecorator={<Person />}
>
Assigned to {assignedTo.displayName}
</Chip>
)}
{historyEntry.notes && (
<Chip
size='sm'
variant='plain'
color='neutral'
startDecorator={<EventNote />}
sx={{
maxWidth: '120px',
overflow: 'hidden',
cursor: 'pointer',
}}
onClick={e => {
e.stopPropagation()
onViewNote?.(historyEntry.notes)
}}
>
Note
</Chip>
)}
{/* add a duration chip if we have duration */}
{historyEntry?.duration > 0 && (
<Chip
size='sm'
variant='soft'
color='primary'
startDecorator={<AccessTime />}
>
{formatTime(historyEntry.duration)}
</Chip>
)}
{historyEntry?.points > 0 && (
<Chip
size='sm'
variant='solid'
color='success'
startDecorator={<Toll />}
>
{historyEntry.points} pt
{historyEntry.points > 1 ? 's' : ''}
</Chip>
)}
</Box>
</Grid>
</Grid>
{performer.displayName}
</Chip>
)}
{metaTextParts.length > 0 && (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
{metaTextParts.join(' · ')}
</Typography>
)}
</Box>
</Box>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', pr: 0.5 }} onClick={e => e.stopPropagation()}>
{onToggleActions && (
<IconButton
color='neutral'
variant='plain'
size='sm'
onClick={e => {
e.stopPropagation()
onToggleActions()
}}
onClick={e => { e.stopPropagation(); onToggleActions() }}
>
<MoreVert sx={{ fontSize: 18 }} />
</IconButton>

View File

@@ -0,0 +1,230 @@
import {
AccessTime,
CalendarMonth,
Check,
Edit,
HourglassEmpty,
OpenInNew,
Person,
Redo,
RunningWithErrors,
Schedule,
ThumbDown,
Update,
} from '@mui/icons-material'
import { Avatar, Box, Button, Chip, Divider, Stack, Typography } from '@mui/joy'
import moment from 'moment'
import { useNavigate } from 'react-router-dom'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useResponsiveModal } from '../../hooks/useResponsiveModal'
import { TASK_COLOR } from '../../utils/Colors.jsx'
import RichTextEditor from '../components/RichTextEditor.jsx'
const STATUS_CONFIG = {
0: { label: 'In Progress', color: 'primary', icon: <AccessTime /> },
1: { label: 'Completed', color: 'success', icon: <Check /> },
2: { label: 'Skipped', color: 'warning', icon: <Redo /> },
3: { label: 'Pending Approval', color: 'neutral', icon: <HourglassEmpty /> },
4: { label: 'Rejected', color: 'danger', icon: <ThumbDown /> },
5: { label: 'Missed', color: 'danger', icon: <RunningWithErrors /> },
6: { label: 'Rescheduled', color: 'warning', icon: <Schedule /> },
}
const DetailRow = ({ icon, label, value, children }) => (
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1.5, py: 0.75 }}>
<Box sx={{ color: 'text.tertiary', mt: 0.25, flexShrink: 0, display: 'flex' }}>{icon}</Box>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.15 }}>{label}</Typography>
{children ?? (
<Typography level='body-sm' sx={{ color: 'text.primary', fontWeight: 'md' }}>{value}</Typography>
)}
</Box>
</Box>
)
const TimingBadge = ({ historyEntry }) => {
if (!historyEntry.dueDate || !historyEntry.performedAt) return null
if ([0, 5, 6].includes(historyEntry.status)) return null
const performedAt = moment(historyEntry.performedAt)
const dueDate = moment(historyEntry.dueDate)
const diffHours = performedAt.diff(dueDate, 'hours')
const gracePeriod = 6 * 60 * 60 * 1000
if (Math.abs(performedAt - dueDate) <= gracePeriod) {
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.COMPLETED, color: 'white' }} startDecorator={<Check />}>On Time</Chip>
} else if (performedAt.isBefore(dueDate)) {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d early` : `${abs}h early`
return <Chip size='sm' variant='soft' sx={{ backgroundColor: TASK_COLOR.SCHEDULED, color: 'white' }} startDecorator={<Check />}>{label}</Chip>
} else {
const abs = Math.abs(diffHours)
const label = abs >= 48 ? `${Math.floor(abs / 24)}d late` : `${abs}h late`
return <Chip size='sm' variant='solid' sx={{ backgroundColor: TASK_COLOR.LATE, color: 'white' }}>{label}</Chip>
}
}
function HistoryDetailModal({ config }) {
const { ResponsiveModal } = useResponsiveModal()
const { fmt } = useLocalization()
const navigate = useNavigate()
const entry = config?.entry
const performers = config?.performers ?? []
if (!entry) return null
const statusCfg = STATUS_CONFIG[entry.status] ?? STATUS_CONFIG[1]
const isFirstSchedule = entry.status === 6 && !entry.dueDate
const statusLabel = isFirstSchedule ? 'Scheduled' : statusCfg.label
const performer = performers.find(p => p.userId === entry.completedBy)
const assignedTo = performers.find(p => p.userId === entry.assignedTo)
const isDifferentAssignee = entry.assignedTo && entry.completedBy !== entry.assignedTo
// updatedAt is only meaningful if it differs from performedAt by more than a minute
const showUpdatedAt =
entry.updatedAt &&
entry.performedAt &&
Math.abs(moment(entry.updatedAt).diff(entry.performedAt, 'minutes')) > 1
const formatDuration = seconds => {
if (!seconds || seconds <= 0) return null
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = seconds % 60
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
}
return (
<ResponsiveModal
open={config?.isOpen}
onClose={config?.onClose}
title='Activity Detail'
>
{/* Status header */}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Avatar size='sm' color={statusCfg.color} variant='soft'>
{statusCfg.icon}
</Avatar>
<Typography level='title-md' fontWeight='lg' sx={{ color: `${statusCfg.color}.plainColor` }}>
{statusLabel}
</Typography>
</Box>
<TimingBadge historyEntry={entry} />
</Box>
<Divider sx={{ mb: 1.5 }} />
<Stack spacing={0}>
{/* Who performed it */}
{performer && (
<DetailRow icon={<Check sx={{ fontSize: 16 }} />} label='Performed by'>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
<Avatar src={performer.image} alt={performer.displayName} size='sm' sx={{ width: 20, height: 20 }} />
<Typography level='body-sm' fontWeight='md'>{performer.displayName}</Typography>
</Box>
</DetailRow>
)}
{/* Assigned to (only if different) */}
{isDifferentAssignee && assignedTo && (
<DetailRow icon={<Person sx={{ fontSize: 16 }} />} label='Assigned to' value={assignedTo.displayName} />
)}
<Divider />
{/* Performed at */}
{entry.performedAt && (
<DetailRow
icon={<AccessTime sx={{ fontSize: 16 }} />}
label={isFirstSchedule ? 'Scheduled on' : entry.status === 6 ? 'Rescheduled on' : entry.status === 2 ? 'Skipped on' : 'Completed on'}
value={fmt.dateTime(entry.performedAt)}
/>
)}
{/* Due date */}
{entry.dueDate && (
<DetailRow
icon={<CalendarMonth sx={{ fontSize: 16 }} />}
label={entry.status === 6 ? 'Previous due date' : entry.status === 5 ? 'Was due' : 'Due date'}
value={fmt.dateTime(entry.dueDate)}
/>
)}
{/* Last updated (only if meaningfully different from performedAt) */}
{showUpdatedAt && (
<DetailRow
icon={<Update sx={{ fontSize: 16 }} />}
label='Last updated'
value={fmt.dateTime(entry.updatedAt)}
/>
)}
{/* Duration */}
{entry.duration > 0 && (
<DetailRow
icon={<Schedule sx={{ fontSize: 16 }} />}
label='Duration'
value={formatDuration(entry.duration)}
/>
)}
{/* Points */}
{entry.points > 0 && (
<DetailRow
icon={<Typography sx={{ fontSize: 14 }}></Typography>}
label='Points earned'
value={`${entry.points} pt${entry.points > 1 ? 's' : ''}`}
/>
)}
{/* Notes */}
{entry.notes && (
<>
<Divider />
<Box sx={{ pt: 1 }}>
<Typography level='body-xs' sx={{ color: 'text.tertiary', mb: 0.5 }}>
{entry.status === 2 || entry.status === 4 ? 'Reason' : 'Notes'}
</Typography>
<Box sx={{ overflowY: 'auto', maxHeight: '60vh' }}>
<RichTextEditor value={entry.notes || ''} isEditable={false} />
</Box>
</Box>
</>
)}
</Stack>
{/* Action buttons */}
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: 'flex-end' }}>
{entry.choreId && (
<Button
variant='soft'
color='neutral'
size='sm'
startDecorator={<OpenInNew sx={{ fontSize: 16 }} />}
onClick={() => {
config?.onClose?.()
navigate(`/chores/${entry.choreId}`)
}}
>
Open Task
</Button>
)}
{config?.onEdit && (
<Button
variant='soft'
color='neutral'
size='md'
startDecorator={<Edit sx={{ fontSize: 16 }} />}
onClick={() => config.onEdit(entry)}
>
Edit Entry
</Button>
)}
</Box>
</ResponsiveModal>
)
}
export default HistoryDetailModal

View File

@@ -1,22 +1,21 @@
import { Add, Delete } from '@mui/icons-material'
import { Save } from '@mui/icons-material'
import {
Box,
Button,
Chip,
IconButton,
Divider,
Input,
List,
ListItem,
Option,
Select,
Textarea,
Typography,
} from '@mui/joy'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useResponsiveModal } from '../../../hooks/useResponsiveModal'
import { useEffect, useMemo, useState } from 'react'
import BottomSheetModal from '../../../components/common/BottomSheetModal'
import FilterBuilderContent, {
conditionsToSelections,
defaultSelections,
selectionsToConditions,
} from '../../Chores/components/FilterBuilderContent'
import { FILTER_COLORS } from '../../../utils/Colors'
import { applyFilter } from '../../../utils/FilterEngine'
import Priorities from '../../../utils/Priorities'
import { useFilters } from '../../Filters/FilterQueries'
const AdvancedFilterBuilder = ({
@@ -30,527 +29,149 @@ const AdvancedFilterBuilder = ({
userProfile = null,
editingFilter = null,
}) => {
const { ResponsiveModal } = useResponsiveModal()
const listContainerRef = useRef(null)
const conditionRefs = useRef([])
const [filterName, setFilterName] = useState('')
const [filterDescription, setFilterDescription] = useState('')
const [filterColor, setFilterColor] = useState(FILTER_COLORS[0].value)
const [conditions, setConditions] = useState([
{ type: 'assignee', operator: 'is', value: [] },
])
const [selections, setSelections] = useState(defaultSelections())
const [error, setError] = useState('')
const { data: existedFilters = [] } = useFilters()
const filterNameExists = (name, excludeId = null) => {
return existedFilters.some(
filter =>
filter.name.toLowerCase() === name.toLowerCase() &&
filter.id !== excludeId,
const filterNameExists = (name, excludeId = null) =>
existedFilters.some(
f => f.name.toLowerCase() === name.toLowerCase() && f.id !== excludeId,
)
}
// Initialize refs array when conditions change
useEffect(() => {
conditionRefs.current = conditionRefs.current.slice(0, conditions.length)
}, [conditions.length])
// Initialize state when editing a filter
useEffect(() => {
if (!isOpen) return
if (editingFilter) {
setFilterName(editingFilter.name)
setFilterDescription(editingFilter.description || '')
setFilterColor(editingFilter.color || FILTER_COLORS[0].value)
setConditions(editingFilter.conditions || [])
setError('')
setSelections(conditionsToSelections(editingFilter.conditions))
} else {
setFilterName('')
setFilterDescription('')
// find color no filter has it :
const potentialColor = FILTER_COLORS.find(
color => !existedFilters.some(filter => filter.color === color.value),
c => !existedFilters.some(f => f.color === c.value),
)
setFilterColor(
potentialColor ? potentialColor.value : FILTER_COLORS[0].value,
)
setConditions([{ type: 'assignee', operator: 'is', value: [] }])
setError('')
setFilterColor(potentialColor?.value ?? FILTER_COLORS[0].value)
setSelections(defaultSelections())
}
setError('')
}, [editingFilter, isOpen])
const conditions = useMemo(() => selectionsToConditions(selections), [selections])
const previewChores = useMemo(() => {
const validConditions = conditions.filter(c => {
if (c.type === 'dueDate' || c.type === 'points') return true
return c.value && (Array.isArray(c.value) ? c.value.length > 0 : true)
})
if (validConditions.length === 0) return []
const result = applyFilter(
if (conditions.length === 0) return []
return applyFilter(
allChores,
{ conditions: validConditions, operator: 'AND' },
{
userId: userProfile?.id,
members,
labels,
projects,
},
{ conditions, 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(),
c => c.nextDueDate && new Date(c.nextDueDate) < new Date(),
).length
const addCondition = () => {
setConditions([
...conditions,
{ type: 'assignee', operator: 'is', value: [] },
])
// Scroll to the new condition after it's rendered
setTimeout(() => {
const newIndex = conditions.length
const newConditionElement = conditionRefs.current[newIndex]
if (newConditionElement && listContainerRef.current) {
newConditionElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
})
}
}, 100)
}
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 = []
} else if (value === 'points') {
updated[index].operator = 'greaterThan'
updated[index].value = 0
}
}
setConditions(updated)
}
const activeConditionCount = conditions.length
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' || c.type === 'points') 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')
if (conditions.length === 0) {
setError('Please configure at least one filter condition')
return
}
const filterData = {
onSave({
name: filterName.trim(),
description: filterDescription.trim(),
description: editingFilter?.description ?? '',
color: filterColor,
conditions: validConditions,
conditions,
operator: 'AND',
}
// Include ID if editing
if (editingFilter) {
filterData.id = editingFilter.id
}
onSave(filterData)
...(editingFilter ? { id: editingFilter.id } : {}),
})
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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
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={`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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const priority = Priorities.find(p => p.value === value)
return (
<Chip key={`priority-${value}-${idx}`} size='sm'>
{priority?.name || `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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
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
multiple
value={condition.value || []}
onChange={(_, newValue) =>
updateCondition(index, 'value', newValue)
}
placeholder='Select statuses'
sx={{ width: '100%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{selected.map((selectedElement, idx) => {
const value = selectedElement.value
const statusLabels = {
0: 'Active',
1: 'Started',
2: 'In Progress',
3: 'Pending Approval',
}
return (
<Chip key={`status-chip-${value}-${idx}`} size='sm'>
{statusLabels[value] || 'Unknown'}
</Chip>
)
})}
</Box>
)}
>
<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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<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>
)
case 'points':
return (
<Box sx={{ display: 'flex', gap: 1, width: '100%' }}>
<Select
value={condition.operator}
onChange={(_, newValue) =>
updateCondition(index, 'operator', newValue)
}
sx={{ flex: 1 }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<Option value='equals'>Equals</Option>
<Option value='greaterThan'>Greater Than</Option>
<Option value='lessThan'>Less Than</Option>
<Option value='greaterThanOrEqual'>Greater Than or Equal</Option>
<Option value='lessThanOrEqual'>Less Than or Equal</Option>
</Select>
<Input
type='number'
value={condition.value ?? 0}
onChange={e =>
updateCondition(index, 'value', parseInt(e.target.value) || 0)
}
sx={{ flex: 1 }}
slotProps={{
input: {
min: 0,
},
}}
/>
</Box>
)
default:
return null
}
}
return (
<ResponsiveModal
<BottomSheetModal
open={isOpen}
onClose={onClose}
size='lg'
fullWidth={true}
title={editingFilter ? 'Edit Filter' : 'Create Advanced Filter'}
maxHeight='92vh'
title={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{editingFilter ? 'Edit Filter' : 'New Filter'}
{activeConditionCount > 0 && (
<Chip size='sm' variant='solid' color='primary'>
{activeConditionCount} condition{activeConditionCount !== 1 ? 's' : ''}
</Chip>
)}
</Box>
}
footer={
<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}>
Save
</Button>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
}}
>
{/* Preview */}
<Box sx={{ display: 'flex', gap: 1, flexShrink: 0 }}>
{conditions.length > 0 ? (
<>
<Chip size='sm' variant='soft' color='neutral'>
{previewCount} task{previewCount !== 1 ? 's' : ''}
</Chip>
{previewOverdueCount > 0 && (
<Chip size='sm' variant='solid' color='danger'>
{previewOverdueCount} overdue
</Chip>
)}
</>
) : (
<Typography level='body-xs' sx={{ color: 'text.tertiary' }}>
Add conditions to preview
</Typography>
)}
</Box>
{/* Actions */}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant='plain' color='neutral' size='sm' onClick={onClose}>
Cancel
</Button>
<Button
variant='solid'
color='primary'
size='sm'
startDecorator={<Save sx={{ fontSize: 16 }} />}
onClick={handleSave}
>
Save Filter
</Button>
</Box>
</Box>
}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 2,
height: '100%',
}}
>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0 }}>
{/* Name */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Filter Name
</Typography>
<Input
placeholder='e.g. Important Tasks due soon, Tasks for John, etc.'
placeholder='e.g. Overdue tasks for Alice'
value={filterName}
onChange={e => {
setFilterName(e.target.value)
@@ -560,253 +181,57 @@ const AdvancedFilterBuilder = ({
autoFocus
/>
{error && (
<Typography level='body-sm' color='danger' sx={{ mt: 0.5 }}>
<Typography level='body-xs' color='danger' sx={{ mt: 0.5 }}>
{error}
</Typography>
)}
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
Description (Optional)
</Typography>
<Textarea
placeholder='Optional description for this filter...'
value={filterDescription}
onChange={e => setFilterDescription(e.target.value)}
minRows={2}
maxRows={3}
/>
</Box>
<Box>
<Typography level='body-sm' sx={{ mb: 1 }}>
{/* Color */}
<Box sx={{ mb: 2 }}>
<Typography
level='body-xs'
sx={{ mb: 0.75, color: 'text.secondary', fontWeight: 600 }}
>
Color
</Typography>
<Select
value={filterColor}
onChange={(_, value) => value && setFilterColor(value)}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
renderValue={selected => (
<Typography
startDecorator={
<Box
sx={{
width: 16,
height: 16,
borderRadius: '50%',
background: selected.value,
}}
/>
}
>
{selected.label}
</Typography>
)}
>
{FILTER_COLORS.map(color => (
<Option key={color.value} value={color.value}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 20,
height: 20,
borderRadius: '50%',
background: color.value,
}}
/>
<Typography>{color.name}</Typography>
</Box>
</Option>
))}
</Select>
</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
ref={listContainerRef}
sx={{
gap: 1,
overflowY: 'auto',
overflowX: 'hidden',
maxHeight: { xs: '40vh', sm: '50vh' },
pr: 0.5,
position: 'relative',
}}
>
{conditions.map((condition, index) => (
<ListItem
key={index}
ref={el => (conditionRefs.current[index] = el)}
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{FILTER_COLORS.map(c => (
<Box
key={c.value}
title={c.name}
onClick={() => setFilterColor(c.value)}
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
p: 1.5,
bgcolor: 'background.level1',
borderRadius: 'sm',
position: 'relative',
width: 26,
height: 26,
borderRadius: '50%',
background: c.value,
cursor: 'pointer',
outline:
filterColor === c.value
? '3px solid var(--joy-palette-primary-500)'
: '2px solid transparent',
outlineOffset: '2px',
transition: 'all 0.15s ease',
flexShrink: 0,
'&:hover': { transform: 'scale(1.2)' },
}}
>
<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%' }}
slotProps={{
listbox: {
placement: 'bottom-start',
disablePortal: false,
},
}}
>
<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>
<Option value='points'>Points</Option>
</Select>
</Box>
<Box sx={{ width: '100%' }}>
<Typography level='body-xs' sx={{ mb: 0.5 }}>
{condition.type === 'dueDate' || condition.type === 'points'
? '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',
overflowX: 'hidden',
bgcolor: 'background.level1',
p: 1,
borderRadius: 'sm',
position: 'relative',
}}
>
{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>
<Divider sx={{ mb: 2.5 }} />
<FilterBuilderContent
selections={selections}
onSelectionsChange={setSelections}
members={members}
labels={labels}
projects={projects}
/>
</Box>
</ResponsiveModal>
</BottomSheetModal>
)
}

View File

@@ -2,14 +2,17 @@ import { Cell, Pie, PieChart, Tooltip } from 'recharts'
import {
AccessTime,
CalendarMonth,
Check,
Checklist,
EventBusy,
EventNote,
Group,
HourglassEmpty,
Person,
Redo,
RunningWithErrors,
Schedule,
Style,
ThumbDown,
Timeline,
Toll,
@@ -24,23 +27,27 @@ import {
Divider,
Grid,
Link,
Option,
Select,
Stack,
Tab,
TabList,
Tabs,
Typography,
} from '@mui/joy'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import FilterBar from '../../components/common/FilterBar'
import { useFilter } from '../../hooks/useFilter'
import { useLocalization } from '../../contexts/LocalizationContext'
import { useChores, useChoresHistory } from '../../queries/ChoreQueries'
import {
useChores,
useChoresHistory,
useDeleteChoreHistory,
useUpdateChoreHistory,
} from '../../queries/ChoreQueries'
import EditHistoryModal from '../Modals/EditHistoryModal'
import HistoryDetailModal from '../Modals/HistoryDetailModal'
import NoteViewerModal from '../Modals/Inputs/NoteViewerModal'
import { useCircleMembers, useUserProfile } from '../../queries/UserQueries.jsx'
import { useLabels } from '../Labels/LabelQueries'
import { ChoresGrouper } from '../../utils/Chores'
import { COLORS, TASK_COLOR } from '../../utils/Colors.jsx'
import { resolvePhotoURL } from '../../utils/Helpers.jsx'
import LoadingComponent from '../components/Loading'
const groupByDate = history => {
@@ -58,47 +65,57 @@ const groupByDate = history => {
return aggregated
}
const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onViewNote }) => {
const getStatusIcon = status => {
switch (status) {
case 0:
return <AccessTime color='primary' />
case 1:
return <Check color='success' />
case 2:
return <Redo color='warning' />
case 3:
return <HourglassEmpty color='neutral' />
case 4:
return <ThumbDown color='error' />
case 5:
return <RunningWithErrors color='error' />
case 6:
return <Schedule color='warning' />
default:
return <Check color='success' />
}
}
const statusConfig = {
0: { color: 'primary', icon: <AccessTime /> },
1: { color: 'success', icon: <Check /> },
2: { color: 'warning', icon: <Redo /> },
3: { color: 'neutral', icon: <HourglassEmpty /> },
4: { color: 'danger', icon: <ThumbDown /> },
5: { color: 'danger', icon: <RunningWithErrors /> },
6: { color: 'warning', icon: <Schedule /> },
}
const ChoreHistoryItem = ({
time,
name,
points,
status,
notes,
onViewNote,
onViewDetails,
}) => {
const cfg = statusConfig[status] ?? statusConfig[1]
return (
<Stack direction='row' alignItems='center' spacing={2}>
<Stack
direction='row'
alignItems='center'
spacing={1}
onClick={onViewDetails}
sx={{
cursor: onViewDetails ? 'pointer' : 'default',
borderRadius: 'sm',
'&:hover': onViewDetails
? { backgroundColor: 'background.level1' }
: {},
}}
>
<Typography level='body-md' sx={{ minWidth: 80 }}>
{time}
</Typography>
<Box
<Avatar
size='sm'
color={cfg.color}
variant='soft'
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 32,
minHeight: 32,
borderRadius: '50%',
backgroundColor: 'background.level2',
boxShadow: 'sm',
width: 32,
height: 32,
flexShrink: 0,
'& svg': { fontSize: '16px' },
}}
>
{getStatusIcon(status)}
</Box>
{cfg.icon}
</Avatar>
<Box
sx={{
display: 'flex',
@@ -143,25 +160,18 @@ const ChoreHistoryItem = ({ time, name, points, status, performer, notes, onView
)
}
const ChoreHistoryTimeline = ({ history, onViewNote }) => {
const ChoreHistoryTimeline = ({
history,
performers,
onViewNote,
onViewDetails,
}) => {
const { fmt } = useLocalization()
const groupedHistory = groupByDate(history)
const sortedEntries = Object.entries(groupedHistory).sort(
([a], [b]) => new Date(b) - new Date(a),
)
return (
<Container sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Timeline sx={{ fontSize: '1.5rem', color: 'primary.500' }} />
<Typography level='h4' sx={{ fontWeight: 'lg', color: 'text.primary' }}>
Activities Timeline
</Typography>
</Box>
<Box sx={{ py: 2, width: '100%' }}>
{Object.entries(groupedHistory).map(([date, items]) => (
<Box key={date} sx={{ mb: 4 }}>
<Typography level='title-sm' sx={{ mb: 0.5 }}>
@@ -170,25 +180,21 @@ const ChoreHistoryTimeline = ({ history, onViewNote }) => {
<Divider />
<Stack spacing={1}>
{items.map(record => (
<>
<ChoreHistoryItem
key={record.id}
time={fmt.time(
record.performedAt || record.updatedAt,
)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
/>
</>
<ChoreHistoryItem
key={record.id}
time={fmt.time(record.performedAt || record.updatedAt)}
name={record.choreName}
points={record.points}
status={record.status}
notes={record.notes}
onViewNote={onViewNote}
onViewDetails={() => onViewDetails?.(record, performers)}
/>
))}
</Stack>
</Box>
))}
</Container>
</Box>
)
}
@@ -402,6 +408,11 @@ const UserActivites = () => {
const [enrichedHistory, setEnrichedHistory] = React.useState([])
const [selectedChart, setSelectedChart] = React.useState('history')
const [noteViewerConfig, setNoteViewerConfig] = useState({ isOpen: false })
const [detailModalConfig, setDetailModalConfig] = useState({ isOpen: false })
const [editModalConfig, setEditModalConfig] = useState({ isOpen: false })
const [editHistoryRecord, setEditHistoryRecord] = useState(null)
const updateChoreHistory = useUpdateChoreHistory()
const deleteChoreHistory = useDeleteChoreHistory()
const [historyPieChartData, setHistoryPieChartData] = React.useState([])
const [choreDuePieChartData, setChoreDuePieChartData] = React.useState([])
@@ -416,6 +427,7 @@ const UserActivites = () => {
choresAssigneeBreakdownChartData,
setChoresAssigneeBreakdownChartData,
] = React.useState([])
const { data: userLabels } = useLabels()
const { data: choresData, isLoading: isChoresLoading } = useChores(true)
const {
data: choresHistory,
@@ -432,6 +444,142 @@ const UserActivites = () => {
}
}, [circleMembersData])
// Client-side filters applied on top of the user+time-window slice
const clientFilterDefs = useMemo(
() => [
{
id: 'status',
label: 'Status',
type: 'multi-select',
icon: <Checklist />,
options: [
{ value: 1, label: 'Completed', color: 'success', icon: <Check sx={{ fontSize: 14 }} /> },
{ value: 2, label: 'Skipped', color: 'warning', icon: <Redo sx={{ fontSize: 14 }} /> },
{ value: 3, label: 'Pending', color: 'neutral', icon: <HourglassEmpty sx={{ fontSize: 14 }} /> },
{ value: 4, label: 'Rejected', color: 'danger', icon: <ThumbDown sx={{ fontSize: 14 }} /> },
{ value: 5, label: 'Missed', color: 'danger', icon: <RunningWithErrors sx={{ fontSize: 14 }} /> },
{ value: 6, label: 'Rescheduled', color: 'warning', icon: <Schedule sx={{ fontSize: 14 }} /> },
],
filterFn: (item, values) => values.includes(item.status),
},
...(userLabels?.length > 0
? [
{
id: 'label',
label: 'Labels',
type: 'multi-select',
icon: <Style />,
options: userLabels.map(l => ({
value: l.id,
label: l.name,
icon: (
<Box
component='span'
sx={{
display: 'inline-block',
width: 10,
height: 10,
borderRadius: '50%',
bgcolor: l.color || '#90a4ae',
flexShrink: 0,
}}
/>
),
})),
filterFn: (item, values) =>
item.labelsV2?.some(l => values.includes(l.id)) ?? false,
},
]
: []),
{
id: 'hasNotes',
label: 'Has Notes',
type: 'boolean',
icon: <EventNote />,
filterFn: item => !!item.notes,
},
{
id: 'hasPoints',
label: 'Has Points',
type: 'boolean',
icon: <Toll />,
filterFn: item => (item.points ?? 0) > 0,
},
],
[userLabels],
)
const {
filteredData: filteredTimeline,
activeFilters: clientActiveFilters,
setFilter: setClientFilter,
clearAll: clearClientFilters,
} = useFilter(selectedHistory, clientFilterDefs)
// All filter defs merged for FilterBar display
const filterDefs = useMemo(
() => [
{
id: 'timePeriod',
label: 'Time Period',
type: 'single-select',
icon: <CalendarMonth />,
defaultValue: 7,
options: [
{ value: 7, label: '7 Days' },
{ value: 30, label: '30 Days' },
{ value: 90, label: '90 Days' },
{ value: 365, label: 'All Time' },
],
},
{
id: 'completedBy',
label: 'User',
type: 'single-select',
icon: <Person />,
options: circleUsers.map(u => ({
value: u.userId,
label: u.displayName,
avatar: u.image,
})),
},
...clientFilterDefs,
],
[circleUsers, clientFilterDefs],
)
// Merge server-driven and client-driven active filter states for the bar
const activeFilters = useMemo(
() => ({
timePeriod: tabValue,
...(selectedUser !== 'all' ? { completedBy: selectedUser } : {}),
...clientActiveFilters,
}),
[tabValue, selectedUser, clientActiveFilters],
)
const handleSetFilter = (id, value) => {
if (id === 'completedBy') {
const userId = value ?? 'all'
setSelectedUser(userId)
setSelectedHistory(enrichedHistory.filter(h => USER_FILTER(h, userId)))
} else if (id === 'timePeriod') {
const days = value ?? 7
setTabValue(days)
refetchHistory(days)
} else {
setClientFilter(id, value)
}
}
const handleClearAll = () => {
setSelectedUser('all')
setSelectedHistory(enrichedHistory)
setTabValue(7)
refetchHistory(7)
clearClientFilters()
}
useEffect(() => {
if (
!isChoresHistoryLoading &&
@@ -444,6 +592,7 @@ const UserActivites = () => {
return {
...item,
choreName: chore?.name,
labelsV2: chore?.labelsV2,
}
})
setEnrichedHistory(enrichedHistory)
@@ -823,222 +972,24 @@ const UserActivites = () => {
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
{/* <EmojiEvents sx={{ fontSize: '2rem', color: '#FFD700' }} /> */}
<Stack sx={{ flex: 1 }}>
<Typography
level='h3'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
User Activities
</Typography>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Overview of user activities and task statistics
</Typography>
</Stack>
</Box>
{/* Filter Controls - Always visible */}
<Card
variant='outlined'
sx={{
width: '100%',
p: 2,
mb: 3,
borderRadius: 12,
background:
'linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%)',
backdropFilter: 'blur(10px)',
}}
>
<Stack spacing={2}>
<Typography level='title-sm' sx={{ color: 'text.secondary' }}>
Filter Activities
</Typography>
<Stack
direction={{ xs: 'column', sm: 'row' }}
spacing={2}
alignItems={{ xs: 'stretch', sm: 'center' }}
>
{/* User Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Show activities for:
</Typography>
<Select
sx={{
width: '100%',
}}
variant='outlined'
value={selectedUser}
onChange={(e, selected) => {
setSelectedUser(selected)
setSelectedHistory(
enrichedHistory.filter(h => USER_FILTER(h, selected)),
)
}}
renderValue={() => {
if (selectedUser === undefined || selectedUser === 'all') {
return (
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
)
}
return (
<Typography
startDecorator={
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(
circleUsers.find(
user => user.userId === selectedUser,
)?.image,
)}
>
{circleUsers
.find(user => user.userId === selectedUser)
?.displayName?.charAt(0)}
</Avatar>
}
>
{
circleUsers.find(user => user.userId === selectedUser)
?.displayName
}
</Typography>
)
}}
>
<Option value='all'>
<Typography
startDecorator={
<Avatar color='primary' size='sm'>
<Group />
</Avatar>
}
>
All Users
</Typography>
</Option>
{circleUsers.map(user => (
<Option key={user.userId} value={user.userId}>
<Avatar
color='primary'
size='sm'
src={resolvePhotoURL(user.image)}
>
{user.displayName?.charAt(0)}
</Avatar>
<Typography>{user.displayName}</Typography>
<Chip
color='success'
size='sm'
variant='soft'
startDecorator={<Toll />}
>
{user.points - user.pointsRedeemed}
</Chip>
</Option>
))}
</Select>
</Box>
{/* Time Period Filter */}
<Box sx={{ flex: 1, minWidth: 200 }}>
<Typography level='body-sm' sx={{ mb: 1, fontWeight: 500 }}>
Time period:
</Typography>
<Tabs
onChange={(e, tabValue) => {
setTabValue(tabValue)
refetchHistory(tabValue)
}}
value={tabValue}
sx={{
borderRadius: 8,
backgroundColor: 'background.surface',
border: '1px solid',
borderColor: 'divider',
}}
>
<TabList
disableUnderline
sx={{
borderRadius: 8,
backgroundColor: 'transparent',
p: 0.5,
gap: 0.5,
}}
>
{[
{ label: '7 Days', value: 7 },
{ label: '30 Days', value: 30 },
{ label: '90 Days', value: 90 },
{ label: 'All Time', value: 365 },
].map((tab, index) => (
<Tab
key={index}
sx={{
borderRadius: 6,
minWidth: 'auto',
px: 2,
py: 1,
fontSize: 'sm',
fontWeight: 500,
color: 'text.secondary',
'&.Mui-selected': {
color: 'primary.plainColor',
backgroundColor: 'primary.softBg',
fontWeight: 600,
},
'&:hover': {
backgroundColor: 'neutral.softHoverBg',
},
}}
disableIndicator
value={tab.value}
>
{tab.label}
</Tab>
))}
</TabList>
</Tabs>
</Box>
</Stack>
</Stack>
</Card>
{/* Current Filter Summary */}
<Box sx={{ mb: 3, textAlign: 'center' }}>
<Typography level='body-sm' sx={{ color: 'text.secondary' }}>
Showing activities for{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{selectedUser === undefined || selectedUser === 'all'
? 'All Users'
: circleUsers.find(user => user.userId === selectedUser)
?.displayName || 'Unknown User'}
</Typography>{' '}
over the{' '}
<Typography
component='span'
sx={{ fontWeight: 600, color: 'primary.500' }}
>
{tabValue === 365 ? 'All Time' : `Last ${tabValue} Days`}
</Typography>
<Timeline sx={{ fontSize: '1.5rem' }} />
<Typography
level='title-md'
sx={{ fontWeight: 'lg', color: 'text.primary' }}
>
Activities
</Typography>
</Box>
<FilterBar
filterDefs={filterDefs}
activeFilters={activeFilters}
onSetFilter={handleSetFilter}
onClearAll={handleClearAll}
resultCount={filteredTimeline.length}
totalCount={selectedHistory.length}
/>
{/* Conditional Content Based on Data Availability */}
{!choresData.res?.length > 0 || !choresHistory?.length > 0 ? (
<Container
@@ -1103,7 +1054,8 @@ const UserActivites = () => {
{/* Left Side - Timeline (Mobile: Full width, Desktop: Flexible) */}
<Box sx={{ flex: 1, minWidth: 0, width: '100%' }}>
<ChoreHistoryTimeline
history={selectedHistory}
history={filteredTimeline}
performers={circleUsers}
onViewNote={notes => {
setNoteViewerConfig({
isOpen: true,
@@ -1112,19 +1064,68 @@ const UserActivites = () => {
onClose: () => setNoteViewerConfig({ isOpen: false }),
})
}}
onViewDetails={(entry, performers) => {
setDetailModalConfig({
isOpen: true,
entry,
performers,
onClose: () => setDetailModalConfig({ isOpen: false }),
onEdit: record => {
setDetailModalConfig(prev => ({ ...prev, isOpen: false }))
setEditHistoryRecord(record)
setEditModalConfig({
isOpen: true,
onClose: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
onSave: updated => {
updateChoreHistory.mutate(
{
choreId: record.choreId,
historyId: record.id,
historyData: {
performedAt: updated.performedAt,
dueDate: updated.dueDate,
notes: updated.notes,
},
},
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
onDelete: () => {
deleteChoreHistory.mutate(
{ choreId: record.choreId, historyId: record.id },
{
onSuccess: () => {
setEditModalConfig({ isOpen: false })
setEditHistoryRecord(null)
},
},
)
},
})
},
})
}}
/>
</Box>
{/* Right Sidebar - Charts (Mobile: Full width, Desktop: Fixed width + sticky) */}
{/* Right Sidebar - Charts (Desktop only, hidden on mobile) */}
<Box
sx={{
width: { xs: '100%', lg: '350px' },
position: { xs: 'static', lg: 'sticky' },
top: { lg: '60px' },
alignSelf: { lg: 'flex-start' },
maxHeight: { lg: 'calc(100vh - 40px)' },
overflowY: { lg: 'auto' },
order: { xs: -1, lg: 1 }, // Show charts first on mobile, last on desktop
display: { xs: 'none', lg: 'block' },
width: '350px',
position: 'sticky',
top: '60px',
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 40px)',
overflowY: 'auto',
}}
>
{/* Charts Container */}
@@ -1263,6 +1264,11 @@ const UserActivites = () => {
</>
)}
<NoteViewerModal config={noteViewerConfig} />
<HistoryDetailModal config={detailModalConfig} />
<EditHistoryModal
config={editModalConfig}
historyRecord={editHistoryRecord}
/>
</Container>
)
}