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:
parent
017f6dfdc1
commit
4eab3c29ed
12 changed files with 227 additions and 53 deletions
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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' })
|
||||
|
|
|
|||
28
frontend/src/context/SearchContext.tsx
Normal file
28
frontend/src/context/SearchContext.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
70
frontend/src/pages/FilteredListView.tsx
Normal file
70
frontend/src/pages/FilteredListView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue