Was duplicating categoryFilter/projectFilter useState + filter logic in every view (Board/List/Today/Shared/Assigned/Dispatch). Moved to one global context (same pattern as SearchContext) with a single <TaskFilterBar/> drop-in, and added the missing List filter dimension on top. List filter is deliberately omitted (showList=false) on ListView, Shared, Assigned, and Dispatch -- ListView is already scoped to one list by its route, and the other three show tasks whose list_id doesn't belong to the viewer's own lists (shared tasks live in their owner's lists; assigned tasks are always unfiled after transfer; dispatch is cross-user) -- filtering any of them by "my lists" could only ever return zero results.
148 lines
6.4 KiB
TypeScript
148 lines
6.4 KiB
TypeScript
import { useState } from '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
|
|
return <span className={`badge badge-prio-${priority}`}>{PRIORITY_LABELS[priority]}</span>
|
|
}
|
|
|
|
// Round completion checkbox. Driven by the status's is_done flag rather than
|
|
// a fixed literal, since the pipeline is admin-editable.
|
|
export function Check_({ isDone, onClick }: { isDone: boolean; onClick: (e: React.MouseEvent) => void }) {
|
|
return (
|
|
<button className={isDone ? 'check checked' : 'check'} onClick={onClick} aria-label="toggle done" type="button">
|
|
{isDone && <Check size={13} strokeWidth={3} />}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
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 (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 ${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)}>
|
|
<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>
|
|
{open && (
|
|
<div className="chip-bar" style={{ marginTop: 6 }}>
|
|
{selected.length > 0 && (
|
|
<button className="chip" onClick={() => onChange([])}>Clear</button>
|
|
)}
|
|
{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>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 ListFilter({ selected, onChange }: { selected: number[]; onChange: (ids: number[]) => void }) {
|
|
const { lists } = useLists()
|
|
return <FilterChips items={lists} selected={selected} onChange={onChange} label="List" icon={ListChecks} />
|
|
}
|
|
|
|
// 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 (
|
|
<div className="filter-row">
|
|
<div className="filter-section">
|
|
<CategoryFilter selected={categoryIds} onChange={setCategoryIds} />
|
|
</div>
|
|
<div className="filter-section">
|
|
<ProjectFilter selected={projectIds} onChange={setProjectIds} />
|
|
</div>
|
|
{showList && (
|
|
<div className="filter-section">
|
|
<ListFilter selected={listIds} onChange={setListIds} />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function formatDate(iso: string | null | undefined): string {
|
|
if (!iso) return '—'
|
|
return new Date(iso).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
|
|
}
|
|
|
|
export function formatDateTime(iso: string | null | undefined): string {
|
|
if (!iso) return '—'
|
|
const d = new Date(iso)
|
|
return d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) + ' ' +
|
|
d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
}
|
|
|
|
function startOfDay(d: Date) { const x = new Date(d); x.setHours(0, 0, 0, 0); return x }
|
|
|
|
// Human relative due label + whether it's overdue.
|
|
export function dueInfo(iso: string | null): { label: string; overdue: boolean } | null {
|
|
if (!iso) return null
|
|
const due = new Date(iso)
|
|
const today = startOfDay(new Date())
|
|
const dueDay = startOfDay(due)
|
|
const days = Math.round((dueDay.getTime() - today.getTime()) / 86400000)
|
|
const overdue = days < 0
|
|
let label: string
|
|
if (days === 0) label = 'Today'
|
|
else if (days === 1) label = 'Tomorrow'
|
|
else if (days === -1) label = 'Yesterday'
|
|
else if (days < 0) label = `${-days} days ago`
|
|
else if (days < 7) label = due.toLocaleDateString('en-GB', { weekday: 'short' })
|
|
else label = due.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' })
|
|
const hasTime = due.getHours() !== 0 || due.getMinutes() !== 0
|
|
if (hasTime) label += ' ' + due.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' })
|
|
return { label, overdue }
|
|
}
|
|
|
|
// Convert an ISO timestamp to the value a datetime-local input expects.
|
|
export function toLocalInput(iso: string | null): string {
|
|
if (!iso) return ''
|
|
const d = new Date(iso)
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
}
|