Add project filter, Shared/Assigned views, and task search

- Backend: GET /api/tasks accepts ?search=, ILIKE on title/notes
- Generalized CategoryFilter into a reusable FilterChips component,
  added ProjectFilter alongside it (both now on Board/List/Today)
- New Shared-with-me / Assigned-to-me nav views (FilteredListView),
  surfacing the view=shared/view=assigned params that already existed
  backend-side but had no UI
- Global debounced (300ms) search bar in the layout header, wired into
  Board/List/Today/Dispatch's task fetches via SearchContext
This commit is contained in:
jtricerolph 2026-09-17 09:25:12 +00:00
parent 017f6dfdc1
commit 4eab3c29ed
12 changed files with 227 additions and 53 deletions

View file

@ -74,9 +74,9 @@ export async function taskRoutes(app) {
// ?view=shared = tasks I've been tagged into (not mine, not assigned to me).
// ?view=all = every user's tasks, dispatcher board for the 'reallocate' cap.
// Otherwise defaults to everything I own. ?list_id/?status/?category_id/
// ?project_id narrow further.
// ?project_id/?search narrow further (search matches title or notes, case-insensitive).
app.get('/api/tasks', { preHandler: requireCap('use') }, async (req, reply) => {
const { list_id, view, status, category_id, project_id } = req.query
const { list_id, view, status, category_id, project_id, search } = req.query
const vals = [req.user.email]
let i = 2
const clauses = []
@ -101,6 +101,7 @@ export async function taskRoutes(app) {
if (status) { clauses.push(`t.status = $${i++}`); vals.push(status) }
if (category_id) { clauses.push(`t.category_id = $${i++}`); vals.push(category_id) }
if (project_id) { clauses.push(`t.project_id = $${i++}`); vals.push(project_id) }
if (search) { const idx = i++; clauses.push(`(t.title ILIKE $${idx} OR t.notes ILIKE $${idx})`); vals.push(`%${search}%`) }
if (view === 'today') {
clauses.push(`NOT s.is_done`)
clauses.push(`t.due_at IS NOT NULL`)

View file

@ -8,6 +8,7 @@ import { ListsProvider } from './context/ListsContext'
import { StatusesProvider } from './context/StatusesContext'
import { CategoriesProvider } from './context/CategoriesContext'
import { ProjectsProvider } from './context/ProjectsContext'
import { SearchProvider } from './context/SearchContext'
import { TaskDrawerProvider } from './components/TaskDrawer'
import TodayView from './pages/TodayView'
import ListView from './pages/ListView'
@ -15,6 +16,7 @@ import BoardView from './pages/BoardView'
import ProjectsView from './pages/ProjectsView'
import ProjectDetailView from './pages/ProjectDetailView'
import DispatchView from './pages/DispatchView'
import FilteredListView from './pages/FilteredListView'
import Settings from './pages/Settings'
export default function App() {
@ -27,6 +29,7 @@ export default function App() {
<StatusesProvider>
<CategoriesProvider>
<ProjectsProvider>
<SearchProvider>
<TaskDrawerProvider>
<Layout>
<Routes>
@ -36,12 +39,15 @@ export default function App() {
<Route path="/list/:id" element={<ListView />} />
<Route path="/projects" element={<ProjectsView />} />
<Route path="/project/:id" element={<ProjectDetailView />} />
<Route path="/shared" element={<FilteredListView view="shared" title="Shared with me" />} />
<Route path="/assigned" element={<FilteredListView view="assigned" title="Assigned to me" />} />
<Route path="/dispatch" element={<DispatchView />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/today" replace />} />
</Routes>
</Layout>
</TaskDrawerProvider>
</SearchProvider>
</ProjectsProvider>
</CategoriesProvider>
</StatusesProvider>

View file

@ -41,6 +41,7 @@ export interface TaskFilters {
status?: string
category_id?: number
project_id?: number
search?: string
}
export function fetchTasks(params: TaskFilters = {}): Promise<Task[]> {
const qs = new URLSearchParams()

View file

@ -1,8 +1,9 @@
import { useState, useEffect } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Settings, Menu, LogOut, Plus } from 'lucide-react'
import { ListChecks, CalendarDays, Columns3, FolderKanban, Send, Share2, UserCheck, Settings, Menu, LogOut, Plus, Search, X } from 'lucide-react'
import { useAuth } from './AuthGate'
import { useLists } from '../context/ListsContext'
import { useSearch } from '../context/SearchContext'
import { can } from '../types'
import type { List } from '../types'
import ListEditor from './ListEditor'
@ -12,6 +13,7 @@ const ICON_PROPS = { size: 16, strokeWidth: 1.75 }
export default function Layout({ children }: { children: React.ReactNode }) {
const { user } = useAuth()
const { lists } = useLists()
const { query, setQuery } = useSearch()
const location = useLocation()
const [menuOpen, setMenuOpen] = useState(false)
const [editing, setEditing] = useState<null | 'new' | List>(null)
@ -41,6 +43,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<NavLink to="/projects" className={({ isActive }) => isActive ? 'active' : ''}>
<FolderKanban {...ICON_PROPS} /> Projects
</NavLink>
<NavLink to="/shared" className={({ isActive }) => isActive ? 'active' : ''}>
<Share2 {...ICON_PROPS} /> Shared with me
</NavLink>
<NavLink to="/assigned" className={({ isActive }) => isActive ? 'active' : ''}>
<UserCheck {...ICON_PROPS} /> Assigned to me
</NavLink>
{can(user, 'reallocate') && (
<NavLink to="/dispatch" className={({ isActive }) => isActive ? 'active' : ''}>
<Send {...ICON_PROPS} /> Dispatch
@ -100,6 +108,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</header>
<main className="page-content">
<div className="search-bar">
<Search size={15} strokeWidth={1.75} />
<input value={query} placeholder="Search tasks…" onChange={e => setQuery(e.target.value)} />
{query && (
<button className="btn-ghost-sm" onClick={() => setQuery('')} aria-label="Clear search">
<X size={14} strokeWidth={1.75} />
</button>
)}
</div>
{children}
</main>

View file

@ -1,8 +1,9 @@
import { useState } from 'react'
import { Check, ChevronDown, Tag } from 'lucide-react'
import type { Priority, Category } from '../types'
import { Check, ChevronDown, Tag, FolderKanban, 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'
export function PriorityBadge({ priority }: { priority: Priority }) {
if (priority === 'medium') return null
@ -19,26 +20,31 @@ export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: Reac
)
}
// Collapsible multi-select category filter chip-bar — used on Board/List views.
// `selected` empty means "no filter" (show everything).
export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
const { categories } = useCategories()
interface FilterableEntity { id: number; name: string; colour: string }
// Collapsible multi-select filter chip-bar — generic over anything with an
// id/name/colour (categories, projects, ...). `selected` empty means "no
// filter" (show everything).
function FilterChips<T extends FilterableEntity>({ items, selected, onChange, label, icon: Icon }: {
items: T[]; selected: number[]; onChange: (ids: number[]) => void
label: string; icon: LucideIcon
}) {
const [open, setOpen] = useState(false)
if (categories.length === 0) return null
if (items.length === 0) return null
function toggle(id: number) {
onChange(selected.includes(id) ? selected.filter(i => i !== id) : [...selected, id])
}
const summary = selected.length === 0
? 'All categories'
: categories.filter(c => selected.includes(c.id)).map(c => c.name).join(', ')
? `All ${label.toLowerCase()}s`
: items.filter(x => selected.includes(x.id)).map(x => x.name).join(', ')
return (
<div className="filter-section">
<div className="filter-section-header" onClick={() => setOpen(o => !o)}>
<Tag size={13} strokeWidth={1.75} />
<span className="filter-section-label">Category</span>
<Icon size={13} strokeWidth={1.75} />
<span className="filter-section-label">{label}</span>
<span className={`filter-section-summary${selected.length === 0 ? ' muted' : ''}`}>{summary}</span>
<ChevronDown size={14} strokeWidth={1.75} className={`filter-section-chevron${open ? ' open' : ''}`} />
</div>
@ -47,10 +53,10 @@ export function CategoryFilter({ selected, onChange }: { selected: number[]; onC
{selected.length > 0 && (
<button className="chip" onClick={() => onChange([])}>Clear</button>
)}
{categories.map((c: Category) => (
<button key={c.id} className={`chip${selected.includes(c.id) ? ' active' : ''}`} onClick={() => toggle(c.id)}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: c.colour, display: 'inline-block' }} />
{c.name}
{items.map(x => (
<button key={x.id} className={`chip${selected.includes(x.id) ? ' active' : ''}`} onClick={() => toggle(x.id)}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: x.colour, display: 'inline-block' }} />
{x.name}
</button>
))}
</div>
@ -59,6 +65,16 @@ export function CategoryFilter({ selected, onChange }: { selected: number[]; onC
)
}
export function CategoryFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
const { categories } = useCategories()
return <FilterChips items={categories} selected={selected} onChange={onChange} label="Category" icon={Tag} />
}
export function ProjectFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
const { projects } = useProjects()
return <FilterChips items={projects} selected={selected} onChange={onChange} label="Project" icon={FolderKanban} />
}
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,28 @@
import { createContext, useContext, useState, useEffect } from 'react'
interface SearchCtx {
query: string
setQuery: (q: string) => void
// Debounced 300ms so views don't refetch on every keystroke.
debouncedQuery: string
}
const Ctx = createContext<SearchCtx | null>(null)
export function useSearch() {
const ctx = useContext(Ctx)
if (!ctx) throw new Error('useSearch must be used inside SearchProvider')
return ctx
}
export function SearchProvider({ children }: { children: React.ReactNode }) {
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query.trim()), 300)
return () => clearTimeout(t)
}, [query])
return <Ctx.Provider value={{ query, setQuery, debouncedQuery }}>{children}</Ctx.Provider>
}

View file

@ -570,6 +570,20 @@ table.data tr.clickable:hover td { background: var(--body-bg); }
.board-col-head { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--text-mid); margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
.board-col-count { margin-left: auto; font-weight: 500; }
/* ── Global search bar (top of page-content, above every routed view) ── */
.search-bar {
display: flex; align-items: center; gap: 8px;
padding: 10px 20px;
background: var(--card-bg);
border-bottom: 1px solid var(--card-border);
color: var(--text-mid);
position: sticky; top: 0; z-index: 10;
}
.search-bar input {
flex: 1; border: none; outline: none; background: none;
font-size: 13.5px; font-family: var(--font); color: var(--text-dark);
}
/* Agenda groups (Today view) */
.agenda-group-title { font-size: 13px; font-weight: 700; color: var(--text-dark); margin: 16px 0 8px; display: flex; align-items: center; gap: 8px; }
.agenda-group-title.overdue { color: var(--danger); }

View file

@ -4,20 +4,23 @@ import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { PriorityBadge, dueInfo, CategoryFilter } from '../components/shared'
import { useSearch } from '../context/SearchContext'
import { PriorityBadge, dueInfo, CategoryFilter, ProjectFilter } from '../components/shared'
export default function BoardView() {
const { openTask, changeVersion } = useTaskDrawer()
const { statuses } = useStatuses()
const { debouncedQuery } = useSearch()
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(() => {
fetchTasks({}).then(setTasks).catch(e => setError(e.message))
}, [])
fetchTasks({ search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
}, [debouncedQuery])
useEffect(() => { load() }, [load, changeVersion])
async function drop(status: string) {
@ -32,9 +35,9 @@ export default function BoardView() {
load()
}
const visibleTasks = categoryFilter.length === 0
? tasks
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
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)))
return (
<div className="page" style={{ maxWidth: 1200 }}>
@ -42,6 +45,7 @@ export default function BoardView() {
<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} />
{error && <div className="error-banner">{error}</div>}
<div className="board">

View file

@ -3,6 +3,7 @@ import { Send } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, reallocateTask } from '../api'
import { useAuth } from '../components/AuthGate'
import { useSearch } from '../context/SearchContext'
import { can } from '../types'
import { dueInfo, PriorityBadge } from '../components/shared'
@ -12,14 +13,15 @@ import { dueInfo, PriorityBadge } from '../components/shared'
// only needs enough per-row info to decide where a task should go.
export default function DispatchView() {
const { user } = useAuth()
const { debouncedQuery } = useSearch()
const [tasks, setTasks] = useState<Task[]>([])
const [error, setError] = useState<string | null>(null)
const [movingId, setMovingId] = useState<number | null>(null)
const [target, setTarget] = useState('')
const load = useCallback(() => {
fetchTasks({ view: 'all' }).then(setTasks).catch(e => setError(e.message))
}, [])
fetchTasks({ view: 'all', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
}, [debouncedQuery])
useEffect(() => { load() }, [load])
if (!can(user, 'reallocate')) {

View file

@ -0,0 +1,70 @@
import { useEffect, useState, useCallback } from 'react'
import { Share2, UserCheck } from 'lucide-react'
import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import TaskRow from '../components/TaskRow'
import { CategoryFilter, ProjectFilter } from '../components/shared'
const ICONS = { shared: Share2, assigned: UserCheck }
export default function FilteredListView({ view, title }: { view: 'shared' | 'assigned'; title: string }) {
const { openTask, changeVersion } = useTaskDrawer()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
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(() => {
fetchTasks({ view, search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
}, [view, debouncedQuery])
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
const next = t.is_done ? firstNotDoneValue() : firstDoneValue()
if (!next) return
await updateTask(t.id, { status: next }).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 open = visible.filter(t => !t.is_done)
const done = visible.filter(t => t.is_done)
return (
<div className="page">
<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} />
{error && <div className="error-banner">{error}</div>}
{open.length === 0 && done.length === 0 && (
<div className="empty-state">
{view === 'shared' ? "Nobody's tagged you on anything." : "Nothing's been handed to you."}
</div>
)}
{open.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
{done.length > 0 && (
<>
<div className="section-title">Completed ({done.length})</div>
{done.map(t => (
<TaskRow key={t.id} task={t} showList onToggle={toggle} onOpen={() => openTask(t.id)} />
))}
</>
)}
</div>
)
}

View file

@ -5,16 +5,18 @@ import type { Task, List } from '../types'
import { fetchTasks, createTask, updateTask, reorderTasks } from '../api'
import { useLists } from '../context/ListsContext'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import { useTaskDrawer } from '../components/TaskDrawer'
import TaskRow from '../components/TaskRow'
import ListEditor from '../components/ListEditor'
import { CategoryFilter } from '../components/shared'
import { CategoryFilter, ProjectFilter } from '../components/shared'
export default function ListView() {
const { id } = useParams()
const listId = Number(id)
const { lists } = useLists()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
const { openTask, changeVersion } = useTaskDrawer()
const list = lists.find(l => l.id === listId)
@ -23,12 +25,13 @@ export default function ListView() {
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(() => {
if (!listId) return
fetchTasks({ list_id: listId }).then(setTasks).catch(e => setError(e.message))
}, [listId])
fetchTasks({ list_id: listId, search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
}, [listId, debouncedQuery])
useEffect(() => { load() }, [load, changeVersion])
async function add() {
@ -59,9 +62,9 @@ export default function ListView() {
reorderTasks(ids).catch(() => load())
}
const visible = categoryFilter.length === 0
? tasks
: tasks.filter(t => t.category_id !== null && categoryFilter.includes(t.category_id))
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 open = visible.filter(t => !t.is_done)
const done = visible.filter(t => t.is_done)
@ -79,6 +82,7 @@ export default function ListView() {
)}
</div>
<CategoryFilter selected={categoryFilter} onChange={setCategoryFilter} />
<ProjectFilter selected={projectFilter} onChange={setProjectFilter} />
{error && <div className="error-banner">{error}</div>}
<div className="task-add">

View file

@ -4,19 +4,24 @@ import type { Task } from '../types'
import { fetchTasks, updateTask } from '../api'
import { useTaskDrawer } from '../components/TaskDrawer'
import { useStatuses } from '../context/StatusesContext'
import { useSearch } from '../context/SearchContext'
import TaskRow from '../components/TaskRow'
import { CategoryFilter, ProjectFilter } from '../components/shared'
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
export default function TodayView() {
const { openTask, changeVersion } = useTaskDrawer()
const { firstDoneValue, firstNotDoneValue } = useStatuses()
const { debouncedQuery } = useSearch()
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' }).then(setTasks).catch(e => setError(e.message))
}, [])
fetchTasks({ view: 'today', search: debouncedQuery || undefined }).then(setTasks).catch(e => setError(e.message))
}, [debouncedQuery])
useEffect(() => { load() }, [load, changeVersion])
async function toggle(t: Task) {
@ -26,13 +31,17 @@ 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 now = new Date()
const todayStart = startOfDay(now)
const tomorrowStart = new Date(todayStart); tomorrowStart.setDate(tomorrowStart.getDate() + 1)
const overdue = tasks.filter(t => t.due_at && new Date(t.due_at) < todayStart)
const today = tasks.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
const upcoming = tasks.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
const overdue = visible.filter(t => t.due_at && new Date(t.due_at) < todayStart)
const today = visible.filter(t => t.due_at && new Date(t.due_at) >= todayStart && new Date(t.due_at) < tomorrowStart)
const upcoming = visible.filter(t => t.due_at && new Date(t.due_at) >= tomorrowStart)
const groups: Array<{ key: string; title: string; items: Task[]; overdue?: boolean }> = [
{ key: 'overdue', title: 'Overdue', items: overdue, overdue: true },
@ -45,9 +54,11 @@ 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} />
{error && <div className="error-banner">{error}</div>}
{tasks.length === 0 && (
{visible.length === 0 && (
<div className="empty-state">Nothing due in the next 7 days. Enjoy the calm. ☕</div>
)}