Consolidate category/project filters into a shared TaskFiltersContext + add List filter

Was duplicating categoryFilter/projectFilter useState + filter logic in every
view (Board/List/Today/Shared/Assigned/Dispatch). Moved to one global context
(same pattern as SearchContext) with a single <TaskFilterBar/> drop-in, and
added the missing List filter dimension on top.

List filter is deliberately omitted (showList=false) on ListView, Shared,
Assigned, and Dispatch -- ListView is already scoped to one list by its
route, and the other three show tasks whose list_id doesn't belong to the
viewer's own lists (shared tasks live in their owner's lists; assigned tasks
are always unfiled after transfer; dispatch is cross-user) -- filtering any
of them by "my lists" could only ever return zero results.
This commit is contained in:
jtricerolph 2026-09-17 18:39:26 +00:00
parent 4eab3c29ed
commit 3145c1fc07
9 changed files with 137 additions and 52 deletions

View file

@ -9,6 +9,7 @@ import { StatusesProvider } from './context/StatusesContext'
import { CategoriesProvider } from './context/CategoriesContext'
import { ProjectsProvider } from './context/ProjectsContext'
import { SearchProvider } from './context/SearchContext'
import { TaskFiltersProvider } from './context/TaskFiltersContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
import TodayView from './pages/TodayView'
import ListView from './pages/ListView'
@ -30,6 +31,7 @@ export default function App() {
<CategoriesProvider>
<ProjectsProvider>
<SearchProvider>
<TaskFiltersProvider>
<TaskDrawerProvider>
<Layout>
<Routes>
@ -47,6 +49,7 @@ export default function App() {
</Routes>
</Layout>
</TaskDrawerProvider>
</TaskFiltersProvider>
</SearchProvider>
</ProjectsProvider>
</CategoriesProvider>

View file

@ -1,9 +1,11 @@
import { useState } from 'react'
import { Check, ChevronDown, Tag, FolderKanban, type LucideIcon } from 'lucide-react'
import { Check, ChevronDown, Tag, FolderKanban, ListChecks, type LucideIcon } from 'lucide-react'
import type { Priority } from '../types'
import { PRIORITY_LABELS } from '../types'
import { useCategories } from '../context/CategoriesContext'
import { useProjects } from '../context/ProjectsContext'
import { useLists } from '../context/ListsContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
@ -75,6 +77,34 @@ export function ProjectFilter({ selected, onChange }: { selected: number[]; onCh
return <FilterChips items={projects} selected={selected} onChange={onChange} label="Project" icon={FolderKanban} />
}
export function ListFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
const { lists } = useLists()
return <FilterChips items={lists} selected={selected} onChange={onChange} label="List" icon={ListChecks} />
}
// One drop-in filter row for category + project + (optionally) list, reading
// straight from the global TaskFiltersContext — the "easy way to filter any
// view" version. Pass showList={false} for views already scoped to one list
// (e.g. ListView itself), where a list chip would be redundant/confusing.
export function TaskFilterBar({ showList = true }: { showList?: boolean }) {
const { categoryIds, setCategoryIds, projectIds, setProjectIds, listIds, setListIds } = useTaskFilters()
return (
<div className="filter-row">
<div className="filter-section">
<CategoryFilter selected={categoryIds} onChange={setCategoryIds} />
</div>
<div className="filter-section">
<ProjectFilter selected={projectIds} onChange={setProjectIds} />
</div>
{showList && (
<div className="filter-section">
<ListFilter selected={listIds} onChange={setListIds} />
</div>
)}
</div>
)
}
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })

View file

@ -0,0 +1,47 @@
import { createContext, useContext, useState, useCallback } from 'react'
import type { Task } from '../types'
interface TaskFiltersCtx {
categoryIds: number[]; setCategoryIds: (ids: number[]) => void
projectIds: number[]; setProjectIds: (ids: number[]) => void
listIds: number[]; setListIds: (ids: number[]) => void
categoryMatches: (t: Task) => boolean
projectMatches: (t: Task) => boolean
listMatches: (t: Task) => boolean
// All three combined — the common case for views that show every dimension.
matches: (t: Task) => boolean
}
const Ctx = createContext<TaskFiltersCtx | null>(null)
export function useTaskFilters() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useTaskFilters must be used inside TaskFiltersProvider')
return ctx
}
// Global (not per-view) so a filter selection persists as you move between
// Board/Today/Shared/etc — same pattern as SearchContext.
export function TaskFiltersProvider({ children }: { children: React.ReactNode }) {
const [categoryIds, setCategoryIds] = useState<number[]>([])
const [projectIds, setProjectIds] = useState<number[]>([])
const [listIds, setListIds] = useState<number[]>([])
const categoryMatches = useCallback((t: Task) =>
categoryIds.length === 0 || (t.category_id !== null && categoryIds.includes(t.category_id)), [categoryIds])
const projectMatches = useCallback((t: Task) =>
projectIds.length === 0 || (t.project_id !== null && projectIds.includes(t.project_id)), [projectIds])
const listMatches = useCallback((t: Task) =>
listIds.length === 0 || (t.list_id !== null && listIds.includes(t.list_id)), [listIds])
const matches = useCallback((t: Task) => categoryMatches(t) && projectMatches(t) && listMatches(t),
[categoryMatches, projectMatches, listMatches])
return (
<Ctx.Provider value={{
categoryIds, setCategoryIds, projectIds, setProjectIds, listIds, setListIds,
categoryMatches, projectMatches, listMatches, matches,
}}>
{children}
</Ctx.Provider>
)
}

View file

@ -132,6 +132,7 @@ html, body, #root { height: 100%; margin: 0; font-size: 14px; }
.top-bar { display: flex; }
.app-shell { flex-direction: column; }
.field-row { flex-direction: column; }
.filter-row { flex-direction: column; }
}
.menu-backdrop {

View file

@ -5,17 +5,17 @@ import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import { PriorityBadge, dueInfo, CategoryFilter, ProjectFilter } from '../components/shared'
import { useTaskFilters } from '../context/TaskFiltersContext'
import { PriorityBadge, dueInfo, TaskFilterBar } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const { statuses } = useStatuses()
const { debouncedQuery } = useSearch()
const { matches } = useTaskFilters()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [over, setOver] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const [projectFilter, setProjectFilter] = useState<number[]>([])
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
@ -35,17 +35,14 @@ export default function BoardView() {
load()
}
const visibleTasks = tasks
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
const visibleTasks = tasks.filter(matches)
return (
<div className="page" style={{ maxWidth: 1200 }}>
<div className="page-header">
<h1><Columns3 size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Board</h1>
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
<TaskFilterBar />
{error && <div className="error-banner">{error}</div>}
<div className="board">

View file

@ -4,8 +4,9 @@ import type { Task } from '../types'
import { fetchTasks, reallocateTask } from '../api'
import { useAuth } from '../components/AuthGate'
import { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import { can } from '../types'
import { dueInfo, PriorityBadge } from '../components/shared'
import { dueInfo, PriorityBadge, TaskFilterBar } from '../components/shared'
// A flat, cross-user list for the 'reallocate' cap — deliberately not routed
// through the shared task drawer, since GET /api/tasks/:id only grants
@ -14,6 +15,9 @@ import { dueInfo, PriorityBadge } from '../components/shared'
export default function DispatchView() {
const { user } = useAuth()
const { debouncedQuery } = useSearch()
// No list filter here either — cross-user list_id values belong to
// arbitrary owners, not "my lists" (see FilteredListView for the same call).
const { categoryMatches, projectMatches } = useTaskFilters()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [movingId, setMovingId] = useState<number | null>(null)
@ -36,7 +40,7 @@ export default function DispatchView() {
load()
}
const open = tasks.filter(t => !t.is_done)
const open = tasks.filter(t => !t.is_done && categoryMatches(t) && projectMatches(t))
return (
<div className="page" style={{ maxWidth: 900 }}>
@ -47,6 +51,7 @@ export default function DispatchView() {
Every open task across every user — move any of them to someone else. Only shows title/owner/status; open the
full task from that person's own view for details.
</div>
<TaskFilterBar showList={false} />
{error && <div className="error-banner">{error}</div>}
{open.length === 0 && <div className="empty-state">Nothing open anywhere.</div>}
@ -66,6 +71,12 @@ export default function DispatchView() {
{t.category_name}
</span>
)}
{t.project_name && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<span className="list-dot" style={{ background: t.project_colour, width: 8, height: 8 }} />
{t.project_name}
</span>
)}
{due && <span className={`due-chip${due.overdue ? ' overdue' : ''}`}>{due.label}</span>}
<PriorityBadge priority={t.priority} />
</div>

View file

@ -5,8 +5,9 @@ import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import TaskRow from '../components/TaskRow'
import { CategoryFilter, ProjectFilter } from '../components/shared'
import { TaskFilterBar } from '../components/shared'
const ICONS = { shared: Share2, assigned: UserCheck }
@ -14,10 +15,12 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
const { openTask, changeVersion } = useTaskDrawer()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
// No list filter here — a shared task belongs to its owner's list (not
// mine), and an assigned task is always unfiled (list_id null) right after
// transfer, so filtering either by "my lists" could only ever return zero.
const { categoryMatches, projectMatches } = useTaskFilters()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const [projectFilter, setProjectFilter] = useState<number[]>([])
const Icon = ICONS[view]
const load = useCallback(() => {
@ -32,9 +35,7 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
load()
}
const visible = tasks
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
const open = visible.filter(t => !t.is_done)
const done = visible.filter(t => t.is_done)
@ -43,8 +44,7 @@ export default function FilteredListView({ view, title }: { view: 'shared' | 'as
<div className="page-header">
<h1><Icon size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />{title}</h1>
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
<TaskFilterBar showList={false} />
{error && <div className="error-banner">{error}</div>}
{open.length === 0 && done.length === 0 && (

View file

@ -6,10 +6,11 @@ import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
import { useLists } from '../context/ListsContext'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
import { CategoryFilter, ProjectFilter } from '../components/shared'
import { TaskFilterBar } from '../components/shared'
export default function ListView() {
const { id } = useParams()
@ -17,6 +18,9 @@ export default function ListView() {
const { lists } = useLists()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
// List filter is irrelevant here — the route itself already scopes to one
// list — so only category/project matter for this view.
const { categoryMatches, projectMatches } = useTaskFilters()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
@ -24,8 +28,6 @@ export default function ListView() {
const [error, setError] = useState<string | null>(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const [projectFilter, setProjectFilter] = useState<number[]>([])
const dragId = useRef<number | null>(null)
const load = useCallback(() => {
@ -62,9 +64,7 @@ export default function ListView() {
reorderTasks(ids).catch(() => load())
}
const visible = tasks
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
const visible = tasks.filter(t => categoryMatches(t) && projectMatches(t))
const open = visible.filter(t => !t.is_done)
const done = visible.filter(t => t.is_done)
@ -81,8 +81,7 @@ export default function ListView() {
</button>
)}
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
<TaskFilterBar showList={false} />
{error && <div className="error-banner">{error}</div>}
<div className="task-add">

View file

@ -5,8 +5,9 @@ import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import { useTaskFilters } from '../context/TaskFiltersContext'
import TaskRow from '../components/TaskRow'
import { CategoryFilter, ProjectFilter } from '../components/shared'
import { TaskFilterBar } from '../components/shared'
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
@ -14,10 +15,9 @@ export default function TodayView() {
const { openTask, changeVersion } = useTaskDrawer()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
const { matches } = useTaskFilters()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<number[]>([])
const [projectFilter, setProjectFilter] = useState<number[]>([])
const load = useCallback(() => {
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
@ -31,9 +31,7 @@ export default function TodayView() {
load()
}
const visible = tasks
.filter(t => categoryFilter.length === 0 || (t.category_id !== null && categoryFilter.includes(t.category_id)))
.filter(t => projectFilter.length === 0 || (t.project_id !== null && projectFilter.includes(t.project_id)))
const visible = tasks.filter(matches)
const now = new Date()
const todayStart = startOfDay(now)
@ -54,8 +52,7 @@ export default function TodayView() {
<div className="page-header">
<h1><CalendarDays size={18} strokeWidth={1.75} style={{ verticalAlign: -3, marginRight: 6 }} />Today</h1>
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
<TaskFilterBar />
{error && <div className="error-banner">{error}</div>}
{visible.length === 0 && (