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 && (