diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9d844e5..57c0da1 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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,23 +31,25 @@ export default function App() {
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
diff --git a/frontend/src/components/shared.tsx b/frontend/src/components/shared.tsx
index b2662ba..b771665 100644
--- a/frontend/src/components/shared.tsx
+++ b/frontend/src/components/shared.tsx
@@ -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
}
+export function ListFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
+ const { lists } = useLists()
+ return
+}
+
+// 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 (
+
+
+
+
+
+ {showList && (
+
+
+
+ )}
+
+ )
+}
+
export function formatDate(iso: string | null | undefined): string {
if (!iso) return '—'
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
diff --git a/frontend/src/context/TaskFiltersContext.tsx b/frontend/src/context/TaskFiltersContext.tsx
new file mode 100644
index 0000000..05e4c01
--- /dev/null
+++ b/frontend/src/context/TaskFiltersContext.tsx
@@ -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(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([])
+ const [projectIds, setProjectIds] = useState([])
+ const [listIds, setListIds] = useState([])
+
+ 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 (
+
+ {children}
+
+ )
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index d0e837d..b3742b7 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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 {
diff --git a/frontend/src/pages/BoardView.tsx b/frontend/src/pages/BoardView.tsx
index 52a3125..7f011b1 100644
--- a/frontend/src/pages/BoardView.tsx
+++ b/frontend/src/pages/BoardView.tsx
@@ -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([])
const [error, setError] = useState(null)
const [over, setOver] = useState(null)
- const [categoryFilter, setCategoryFilter] = useState([])
- const [projectFilter, setProjectFilter] = useState([])
const dragId = useRef(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 (
Board
-
-
+
{error &&
{error}
}
diff --git a/frontend/src/pages/DispatchView.tsx b/frontend/src/pages/DispatchView.tsx
index a2c37c9..641265d 100644
--- a/frontend/src/pages/DispatchView.tsx
+++ b/frontend/src/pages/DispatchView.tsx
@@ -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
([])
const [error, setError] = useState(null)
const [movingId, setMovingId] = useState(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 (
@@ -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.
+
{error && {error}
}
{open.length === 0 && Nothing open anywhere.
}
@@ -66,6 +71,12 @@ export default function DispatchView() {
{t.category_name}
)}
+ {t.project_name && (
+
+
+ {t.project_name}
+
+ )}
{due && {due.label}}
diff --git a/frontend/src/pages/FilteredListView.tsx b/frontend/src/pages/FilteredListView.tsx
index f04ea48..cc169bd 100644
--- a/frontend/src/pages/FilteredListView.tsx
+++ b/frontend/src/pages/FilteredListView.tsx
@@ -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
([])
const [error, setError] = useState(null)
- const [categoryFilter, setCategoryFilter] = useState([])
- const [projectFilter, setProjectFilter] = useState([])
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
{title}
-
-
+
{error && {error}
}
{open.length === 0 && done.length === 0 && (
diff --git a/frontend/src/pages/ListView.tsx b/frontend/src/pages/ListView.tsx
index 0b90680..1e2d3cb 100644
--- a/frontend/src/pages/ListView.tsx
+++ b/frontend/src/pages/ListView.tsx
@@ -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(null)
const [newTitle, setNewTitle] = useState('')
const [editing, setEditing] = useState(false)
- const [categoryFilter, setCategoryFilter] = useState([])
- const [projectFilter, setProjectFilter] = useState([])
const dragId = useRef(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() {
)}
-
-
+
{error && {error}
}
diff --git a/frontend/src/pages/TodayView.tsx b/frontend/src/pages/TodayView.tsx
index 166f087..6a1d74b 100644
--- a/frontend/src/pages/TodayView.tsx
+++ b/frontend/src/pages/TodayView.tsx
@@ -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
([])
const [error, setError] = useState(null)
- const [categoryFilter, setCategoryFilter] = useState([])
- const [projectFilter, setProjectFilter] = useState([])
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() {
Today
-
-
+
{error && {error}
}
{visible.length === 0 && (