diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index ff59fa0..bdd44d4 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -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`) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c8eff1d..9d844e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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,21 +29,25 @@ export default function App() { - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d2f13e6..4f6e943 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -41,6 +41,7 @@ export interface TaskFilters { status?: string category_id?: number project_id?: number + search?: string } export function fetchTasks(params: TaskFilters = {}): Promise { const qs = new URLSearchParams() diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 498b1e4..227bfcf 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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) @@ -41,6 +43,12 @@ export default function Layout({ children }: { children: React.ReactNode }) { isActive ? 'active' : ''}> Projects + isActive ? 'active' : ''}> + Shared with me + + isActive ? 'active' : ''}> + Assigned to me + {can(user, 'reallocate') && ( isActive ? 'active' : ''}> Dispatch @@ -100,6 +108,15 @@ export default function Layout({ children }: { children: React.ReactNode }) {
+
+ + setQuery(e.target.value)} /> + {query && ( + + )} +
{children}
diff --git a/frontend/src/components/shared.tsx b/frontend/src/components/shared.tsx index 7b43bf6..b2662ba 100644 --- a/frontend/src/components/shared.tsx +++ b/frontend/src/components/shared.tsx @@ -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({ 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 (
setOpen(o => !o)}> - - Category + + {label} {summary}
@@ -47,10 +53,10 @@ export function CategoryFilter({ selected, onChange }: { selected: number[]; onC {selected.length > 0 && ( )} - {categories.map((c: Category) => ( - ))}
@@ -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 +} + +export function ProjectFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) { + const { projects } = useProjects() + return +} + 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/SearchContext.tsx b/frontend/src/context/SearchContext.tsx new file mode 100644 index 0000000..efaf721 --- /dev/null +++ b/frontend/src/context/SearchContext.tsx @@ -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(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 {children} +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 3c63bc6..d0e837d 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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); } diff --git a/frontend/src/pages/BoardView.tsx b/frontend/src/pages/BoardView.tsx index 428b7bb..52a3125 100644 --- a/frontend/src/pages/BoardView.tsx +++ b/frontend/src/pages/BoardView.tsx @@ -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([]) 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(() => { - 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 (
@@ -42,6 +45,7 @@ export default function BoardView() {

Board

+ {error &&
{error}
}
diff --git a/frontend/src/pages/DispatchView.tsx b/frontend/src/pages/DispatchView.tsx index 6ec220c..a2c37c9 100644 --- a/frontend/src/pages/DispatchView.tsx +++ b/frontend/src/pages/DispatchView.tsx @@ -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([]) const [error, setError] = useState(null) const [movingId, setMovingId] = useState(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')) { diff --git a/frontend/src/pages/FilteredListView.tsx b/frontend/src/pages/FilteredListView.tsx new file mode 100644 index 0000000..f04ea48 --- /dev/null +++ b/frontend/src/pages/FilteredListView.tsx @@ -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([]) + const [error, setError] = useState(null) + const [categoryFilter, setCategoryFilter] = useState([]) + const [projectFilter, setProjectFilter] = useState([]) + 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 ( +
+
+

{title}

+
+ + + {error &&
{error}
} + + {open.length === 0 && done.length === 0 && ( +
+ {view === 'shared' ? "Nobody's tagged you on anything." : "Nothing's been handed to you."} +
+ )} + + {open.map(t => ( + openTask(t.id)} /> + ))} + + {done.length > 0 && ( + <> +
Completed ({done.length})
+ {done.map(t => ( + openTask(t.id)} /> + ))} + + )} +
+ ) +} diff --git a/frontend/src/pages/ListView.tsx b/frontend/src/pages/ListView.tsx index 042d41a..0b90680 100644 --- a/frontend/src/pages/ListView.tsx +++ b/frontend/src/pages/ListView.tsx @@ -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([]) + const [projectFilter, setProjectFilter] = useState([]) const dragId = useRef(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() { )}
+ {error &&
{error}
}
diff --git a/frontend/src/pages/TodayView.tsx b/frontend/src/pages/TodayView.tsx index 41afa29..166f087 100644 --- a/frontend/src/pages/TodayView.tsx +++ b/frontend/src/pages/TodayView.tsx @@ -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([]) const [error, setError] = useState(null) + const [categoryFilter, setCategoryFilter] = useState([]) + const [projectFilter, setProjectFilter] = useState([]) 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() {

Today

+ + {error &&
{error}
} - {tasks.length === 0 && ( + {visible.length === 0 && (
Nothing due in the next 7 days. Enjoy the calm. ☕
)}