diff --git a/src/App.tsx b/src/App.tsx
index 59bd39f..47087c9 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import Stack from '@mui/material/Stack'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'
import { AddColumnButton } from './components/AddColumnButton'
+import { AllTasksDialog } from './components/AllTasksDialog'
import { BoardColumn } from './components/BoardColumn'
import { BoardToolbar } from './components/BoardToolbar'
import { TaskDialog } from './components/TaskDialog'
@@ -46,6 +47,7 @@ function App() {
+
diff --git a/src/components/AllTasksDialog.tsx b/src/components/AllTasksDialog.tsx
new file mode 100644
index 0000000..0e7d4c9
--- /dev/null
+++ b/src/components/AllTasksDialog.tsx
@@ -0,0 +1,14 @@
+import Dialog from '@mui/material/Dialog'
+import { useBoardContext } from '../context/boardContext'
+import { AllTasksPanel } from './AllTasksPanel'
+
+export function AllTasksDialog() {
+ const { allTasksOpen, closeAllTasks } = useBoardContext()
+
+ return (
+
+ )
+}
diff --git a/src/components/AllTasksPanel.tsx b/src/components/AllTasksPanel.tsx
new file mode 100644
index 0000000..c99af3a
--- /dev/null
+++ b/src/components/AllTasksPanel.tsx
@@ -0,0 +1,283 @@
+import { useMemo, useState } from 'react'
+import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
+import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
+import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined'
+import Button from '@mui/material/Button'
+import Checkbox from '@mui/material/Checkbox'
+import Chip from '@mui/material/Chip'
+import DialogContent from '@mui/material/DialogContent'
+import DialogTitle from '@mui/material/DialogTitle'
+import IconButton from '@mui/material/IconButton'
+import MenuItem from '@mui/material/MenuItem'
+import Stack from '@mui/material/Stack'
+import Table from '@mui/material/Table'
+import TableBody from '@mui/material/TableBody'
+import TableCell from '@mui/material/TableCell'
+import TableHead from '@mui/material/TableHead'
+import TableRow from '@mui/material/TableRow'
+import TextField from '@mui/material/TextField'
+import Tooltip from '@mui/material/Tooltip'
+import Typography from '@mui/material/Typography'
+import { useBoardContext } from '../context/boardContext'
+import {
+ columnLabel,
+ deleteTaskPrompt,
+ displayTitle,
+ filterTasksByStatus,
+ SORT_KEYS,
+ sortTasks,
+ type SortDirection,
+ type SortKey,
+} from '../lib/board'
+import { describeDueDate, formatDate, isOverdue } from '../lib/dates'
+import type { Status, Task } from '../types'
+import { ConfirmDialog } from './ConfirmDialog'
+import { DialogCloseButton } from './DialogCloseButton'
+import { EmptyState } from './EmptyState'
+
+const SORT_LABELS: Record = {
+ title: 'Title',
+ status: 'Status',
+ due_at: 'Due date',
+}
+
+/** What the confirm dialog is about to remove: one row, or the whole selection. */
+type Pending = { ids: string[]; title: string; description: string }
+
+/** The contents of the all-tasks modal: every task as one flat, filterable list. */
+export function AllTasksPanel() {
+ const { board, dispatch, closeAllTasks } = useBoardContext()
+ // Empty is "All" — the value the filter starts on, and the one the helper
+ // already reads as "no filter".
+ const [status, setStatus] = useState('')
+ const [sortKey, setSortKey] = useState('status')
+ const [direction, setDirection] = useState('asc')
+ const [selected, setSelected] = useState>(new Set())
+ const [pending, setPending] = useState(null)
+
+ // Ticking a checkbox or opening the confirm dialog re-renders the panel; the
+ // whole board does not need re-filtering and re-sorting for either.
+ const rows = useMemo(
+ () =>
+ sortTasks(
+ filterTasksByStatus(board.tasks, status ? [status] : []),
+ board.columns,
+ sortKey,
+ direction,
+ ),
+ [board.tasks, board.columns, status, sortKey, direction],
+ )
+
+ // Asked of the rows rather than compared by count, so it stays right if the
+ // selection ever outlives the rows it was made from.
+ const allSelected = rows.length > 0 && rows.every((task) => selected.has(task.id))
+
+ // Changing what is listed starts the selection over, so a task the filter has
+ // taken off screen can never stay ticked out of sight.
+ const changeFilter = (next: Status | '') => {
+ setStatus(next)
+ setSelected(new Set())
+ }
+
+ const toggleRow = (id: string) =>
+ setSelected((current) => {
+ const next = new Set(current)
+ if (!next.delete(id)) next.add(id)
+ return next
+ })
+
+ // Select-all covers what the filter is showing, not the whole board.
+ const toggleAll = () =>
+ setSelected(allSelected ? new Set() : new Set(rows.map((task) => task.id)))
+
+ const confirmOne = (task: Task) => setPending({ ids: [task.id], ...deleteTaskPrompt(task) })
+
+ const confirmSelection = () => {
+ const count = `${selected.size} ${selected.size === 1 ? 'task' : 'tasks'}`
+ setPending({
+ ids: [...selected],
+ title: `Delete ${count}?`,
+ description: `${count} will be removed from the board. This cannot be undone.`,
+ })
+ }
+
+ const remove = (ids: string[]) => {
+ dispatch({ type: 'delete_tasks', ids })
+ // Only the deleted ids leave the selection — deleting one row must not
+ // clear a batch the user has been ticking up.
+ const doomed = new Set(ids)
+ setSelected((current) => new Set([...current].filter((id) => !doomed.has(id))))
+ }
+
+ return (
+ <>
+ All tasks
+
+
+
+
+ changeFilter(event.target.value)}
+ sx={{ width: 160 }}
+ >
+
+ {board.columns.map((column) => (
+
+ ))}
+
+
+ setSortKey(event.target.value as SortKey)}
+ sx={{ ml: 'auto', width: 140 }}
+ >
+ {SORT_KEYS.map((key) => (
+
+ ))}
+
+
+
+ setDirection(direction === 'asc' ? 'desc' : 'asc')}
+ >
+ {direction === 'asc' ? : }
+
+
+
+
+
+
+ {rows.length} of {board.tasks.length} {board.tasks.length === 1 ? 'task' : 'tasks'}
+
+
+ {selected.size > 0 && (
+ }
+ onClick={confirmSelection}
+ sx={{ ml: 'auto' }}
+ >
+ Delete {selected.size} selected
+
+ )}
+
+
+ {/* The table sticks its headings to `DialogContent`, the scroll box. */}
+ {rows.length === 0 ? (
+
+ ) : (
+
+
+
+
+ 0 && !allSelected}
+ onChange={toggleAll}
+ slotProps={{ input: { 'aria-label': 'Select all listed tasks' } }}
+ />
+
+ Title
+ Created
+ Due
+ Status
+
+
+
+
+
+ {rows.map((task) => {
+ const overdue = isOverdue(task.due_at)
+ return (
+
+
+ toggleRow(task.id)}
+ slotProps={{ input: { 'aria-label': `Select ${displayTitle(task)}` } }}
+ />
+
+
+
+ {displayTitle(task)}
+
+
+
+ {formatDate(task.created_at)}
+
+
+
+ {describeDueDate(task.due_at) || '—'}
+
+
+
+
+
+
+
+
+ confirmOne(task)}
+ sx={{ '&:hover': { color: 'error.main' } }}
+ >
+
+
+
+
+
+ )
+ })}
+
+
+ )}
+
+
+ pending && remove(pending.ids)}
+ onClose={() => setPending(null)}
+ />
+ >
+ )
+}
diff --git a/src/components/BoardToolbar.tsx b/src/components/BoardToolbar.tsx
index 0099882..72c5adf 100644
--- a/src/components/BoardToolbar.tsx
+++ b/src/components/BoardToolbar.tsx
@@ -3,9 +3,12 @@ import ClearIcon from '@mui/icons-material/Clear'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import SearchIcon from '@mui/icons-material/Search'
+import ViewListIcon from '@mui/icons-material/ViewList'
import AppBar from '@mui/material/AppBar'
+import Button from '@mui/material/Button'
import IconButton from '@mui/material/IconButton'
import InputAdornment from '@mui/material/InputAdornment'
+import Stack from '@mui/material/Stack'
import TextField from '@mui/material/TextField'
import Toolbar from '@mui/material/Toolbar'
import Tooltip from '@mui/material/Tooltip'
@@ -15,7 +18,7 @@ import { useBoardContext } from '../context/boardContext'
import { SearchResults } from './SearchResults'
export function BoardToolbar() {
- const { query, setQuery } = useBoardContext()
+ const { query, setQuery, openAllTasks } = useBoardContext()
const [anchorEl, setAnchorEl] = useState(null)
const { mode, systemMode, setMode } = useColorScheme()
@@ -37,32 +40,44 @@ export function BoardToolbar() {
Task Board
- setQuery(event.target.value)}
- onKeyDown={(event) => event.key === 'Escape' && setQuery('')}
- sx={{ ml: 'auto', width: { xs: 160, sm: 260 } }}
- slotProps={{
- htmlInput: { 'aria-label': 'Search tasks' },
- input: {
- startAdornment: (
-
-
-
- ),
- endAdornment: query && (
-
- setQuery('')}>
-
-
-
- ),
- },
- }}
- />
+ {/* Stretched, so the button takes its height from the field beside it. */}
+
+ setQuery(event.target.value)}
+ onKeyDown={(event) => event.key === 'Escape' && setQuery('')}
+ sx={{ width: { xs: 160, sm: 260 } }}
+ slotProps={{
+ htmlInput: { 'aria-label': 'Search tasks' },
+ input: {
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: query && (
+
+ setQuery('')}>
+
+
+
+ ),
+ },
+ }}
+ />
+
+ }
+ onClick={openAllTasks}
+ sx={{ flexShrink: 0 }}
+ >
+ Show all
+
+
diff --git a/src/components/DialogCloseButton.tsx b/src/components/DialogCloseButton.tsx
new file mode 100644
index 0000000..352e9d7
--- /dev/null
+++ b/src/components/DialogCloseButton.tsx
@@ -0,0 +1,28 @@
+import CloseIcon from '@mui/icons-material/Close'
+import IconButton from '@mui/material/IconButton'
+import Tooltip from '@mui/material/Tooltip'
+
+type DialogCloseButtonProps = {
+ /** Names the button for screen readers, e.g. "Close task details". */
+ label: string
+ onClose: () => void
+}
+
+/**
+ * Overlays the dialog's top-right corner rather than sitting inside
+ * `DialogTitle`. MUI points the dialog's `aria-labelledby` at the whole title
+ * element, so a button in there gets read out as part of the dialog's name.
+ */
+export function DialogCloseButton({ label, onClose }: DialogCloseButtonProps) {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/src/components/SearchResults.tsx b/src/components/SearchResults.tsx
index 0240b87..7d686d7 100644
--- a/src/components/SearchResults.tsx
+++ b/src/components/SearchResults.tsx
@@ -6,8 +6,7 @@ import Paper from '@mui/material/Paper'
import Popper from '@mui/material/Popper'
import Typography from '@mui/material/Typography'
import { useBoardContext } from '../context/boardContext'
-import { displayTitle, searchTasks } from '../lib/board'
-import type { Status } from '../types'
+import { columnLabel, displayTitle, searchTasks } from '../lib/board'
/** Results for the toolbar search, listed beneath the field it is anchored to. */
export function SearchResults({ anchorEl }: { anchorEl: HTMLElement | null }) {
@@ -15,9 +14,6 @@ export function SearchResults({ anchorEl }: { anchorEl: HTMLElement | null }) {
const results = searchTasks(board.tasks, query)
const open = query.trim().length > 0 && anchorEl !== null
- const columnLabel = (status: Status) =>
- board.columns.find((column) => column.status === status)?.label ?? status
-
return (
dispatch({ type: 'delete_task', id: task.id })}
onClose={() => setConfirmingDelete(false)}
/>
diff --git a/src/components/TaskForm.tsx b/src/components/TaskForm.tsx
index 821532b..2fd93ab 100644
--- a/src/components/TaskForm.tsx
+++ b/src/components/TaskForm.tsx
@@ -11,6 +11,7 @@ import dayjs, { type Dayjs } from 'dayjs';
import { useBoardContext } from '../context/boardContext';
import { UNTITLED_LABEL } from '../lib/board';
import type { Task } from '../types';
+import { DialogCloseButton } from './DialogCloseButton';
/**
* The task details form. Mounted only while its dialog is open, so its fields
@@ -39,6 +40,8 @@ export function TaskForm({ task }: { task: Task }) {
return (
Task details
+ {/* Discards the draft, like Cancel. */}
+
diff --git a/src/context/BoardProvider.tsx b/src/context/BoardProvider.tsx
index 1c36596..6bc4390 100644
--- a/src/context/BoardProvider.tsx
+++ b/src/context/BoardProvider.tsx
@@ -9,6 +9,7 @@ export function BoardProvider({ children }: { children: ReactNode }) {
const [focusTaskId, setFocusTaskId] = useState(null)
const [detailsTaskId, setDetailsTaskId] = useState(null)
const [query, setQuery] = useState('')
+ const [allTasksOpen, setAllTasksOpen] = useState(false)
const createTask = useCallback(
(status: Status) => {
@@ -23,6 +24,8 @@ export function BoardProvider({ children }: { children: ReactNode }) {
const clearFocus = useCallback(() => setFocusTaskId(null), [])
const openDetails = useCallback((task: Task) => setDetailsTaskId(task.id), [])
const closeDetails = useCallback(() => setDetailsTaskId(null), [])
+ const openAllTasks = useCallback(() => setAllTasksOpen(true), [])
+ const closeAllTasks = useCallback(() => setAllTasksOpen(false), [])
// Looked up rather than stored, so the dialog never shows a stale task.
const detailsTask = board.tasks.find((task) => task.id === detailsTaskId) ?? null
@@ -39,6 +42,9 @@ export function BoardProvider({ children }: { children: ReactNode }) {
closeDetails,
query,
setQuery,
+ allTasksOpen,
+ openAllTasks,
+ closeAllTasks,
}),
[
board,
@@ -50,6 +56,9 @@ export function BoardProvider({ children }: { children: ReactNode }) {
openDetails,
closeDetails,
query,
+ allTasksOpen,
+ openAllTasks,
+ closeAllTasks,
],
)
diff --git a/src/context/boardContext.ts b/src/context/boardContext.ts
index 9c98468..03a3ca6 100644
--- a/src/context/boardContext.ts
+++ b/src/context/boardContext.ts
@@ -17,6 +17,10 @@ export type BoardContextValue = {
/** Search text narrowing every column at once. */
query: string
setQuery: (query: string) => void
+ /** Whether the flat list of every task is open. Its own filters live inside it. */
+ allTasksOpen: boolean
+ openAllTasks: () => void
+ closeAllTasks: () => void
}
export const BoardContext = createContext(null)
diff --git a/src/lib/board.test.ts b/src/lib/board.test.ts
index cf64919..f68f2db 100644
--- a/src/lib/board.test.ts
+++ b/src/lib/board.test.ts
@@ -1,6 +1,14 @@
import { describe, expect, it } from 'vitest'
import type { BoardState, Task } from '../types'
-import { boardReducer, createEmptyBoard, displayTitle, searchTasks, UNTITLED_LABEL } from './board'
+import {
+ boardReducer,
+ createEmptyBoard,
+ displayTitle,
+ filterTasksByStatus,
+ searchTasks,
+ sortTasks,
+ UNTITLED_LABEL,
+} from './board'
function boardWith(tasks: Task[]): BoardState {
return { ...createEmptyBoard(), tasks }
@@ -57,6 +65,13 @@ describe('boardReducer', () => {
expect(boardReducer(next, { type: 'delete_column', id: next.columns[0].id })).toBe(next)
})
+
+ it('deletes exactly the tasks named in a bulk delete', () => {
+ const tasks = [task({ id: 'a' }), task({ id: 'b' }), task({ id: 'c' })]
+
+ const next = boardReducer(boardWith(tasks), { type: 'delete_tasks', ids: ['a', 'c', 'ghost'] })
+ expect(next.tasks.map((t) => t.id)).toEqual(['b'])
+ })
})
describe('searchTasks', () => {
@@ -72,3 +87,76 @@ describe('searchTasks', () => {
expect(searchTasks(tasks, ' ')).toEqual([])
})
})
+
+describe('filterTasksByStatus', () => {
+ it('treats an empty status list as no filter rather than no results', () => {
+ // The all-tasks modal has one state for "All" and "nothing picked".
+ const tasks = [task({ id: 'a' }), task({ id: 'b', status: 'done' })]
+
+ expect(filterTasksByStatus(tasks, [])).toEqual(tasks)
+ expect(filterTasksByStatus(tasks, ['done']).map((t) => t.id)).toEqual(['b'])
+ })
+})
+
+describe('sortTasks', () => {
+ const columns = createEmptyBoard().columns
+
+ it('sorts titles case-insensitively, with untitled tasks under their placeholder', () => {
+ const tasks = [
+ task({ id: 'a', title: 'apple' }),
+ task({ id: 'z', title: 'Zebra' }),
+ task({ id: 'u', title: '' }),
+ ]
+
+ // 'u' renders as "Untitled task", so it lands between apple and Zebra.
+ expect(sortTasks(tasks, columns, 'title', 'asc').map((t) => t.id)).toEqual(['a', 'u', 'z'])
+ })
+
+ it('sorts by column order rather than alphabetically by status key', () => {
+ const tasks = [
+ task({ id: 'd', status: 'done' }),
+ task({ id: 'p', status: 'in_progress' }),
+ task({ id: 't', status: 'todo' }),
+ ]
+
+ // Alphabetically this would be done, in_progress, todo — the board reads
+ // todo, in_progress, done, and that is the order the list should follow.
+ expect(sortTasks(tasks, columns, 'status', 'asc').map((t) => t.id)).toEqual(['t', 'p', 'd'])
+ expect(sortTasks(tasks, columns, 'status', 'desc').map((t) => t.id)).toEqual(['d', 'p', 't'])
+ })
+
+ it('breaks ties on creation date, newest first, whichever way it is pointed', () => {
+ // Without this the order of equal keys would be whatever `sort` happened to
+ // do, and the list would reshuffle for no visible reason.
+ const tasks = [
+ task({ id: 'old', title: 'Same', created_at: '2026-07-01T10:00:00.000Z' }),
+ task({ id: 'new', title: 'Same', created_at: '2026-07-09T10:00:00.000Z' }),
+ ]
+
+ expect(sortTasks(tasks, columns, 'title', 'asc').map((t) => t.id)).toEqual(['new', 'old'])
+ // The tie-break sits outside the direction flip, so it does not reverse.
+ expect(sortTasks(tasks, columns, 'title', 'desc').map((t) => t.id)).toEqual(['new', 'old'])
+ })
+
+ it('keeps undated tasks last in both directions', () => {
+ const tasks = [
+ task({ id: 'none' }),
+ task({ id: 'late', due_at: '2026-09-01T00:00:00.000Z' }),
+ task({ id: 'soon', due_at: '2026-08-01T00:00:00.000Z' }),
+ ]
+
+ // A missing due date is absent, not later than every date — flipping the
+ // direction must not float those tasks to the top.
+ expect(sortTasks(tasks, columns, 'due_at', 'asc').map((t) => t.id))
+ .toEqual(['soon', 'late', 'none'])
+ expect(sortTasks(tasks, columns, 'due_at', 'desc').map((t) => t.id))
+ .toEqual(['late', 'soon', 'none'])
+ })
+
+ it('returns a sorted copy rather than reordering the board in place', () => {
+ const tasks = [task({ id: 'z', title: 'Zebra' }), task({ id: 'a', title: 'apple' })]
+
+ sortTasks(tasks, columns, 'title', 'asc')
+ expect(tasks.map((t) => t.id)).toEqual(['z', 'a'])
+ })
+})
diff --git a/src/lib/board.ts b/src/lib/board.ts
index 00f6229..8ada728 100644
--- a/src/lib/board.ts
+++ b/src/lib/board.ts
@@ -135,6 +135,77 @@ export function visibleTasks(tasks: Task[], status: Status): Task[] {
return tasks.filter((task) => task.status === status).sort(newestFirst)
}
+/**
+ * What the confirm dialog says before a delete. One wording, whether the task
+ * is being removed from its card or from the all-tasks list.
+ */
+export function deleteTaskPrompt(task: Task): { title: string; description: string } {
+ return {
+ title: 'Delete this task?',
+ description: `"${displayTitle(task)}" will be removed from the board. This cannot be undone.`,
+ }
+}
+
+/** The label of the column a task belongs to, falling back to the raw key. */
+export function columnLabel(columns: Column[], status: Status): string {
+ return columns.find((column) => column.status === status)?.label ?? status
+}
+
+export const SORT_KEYS = ['title', 'status', 'due_at'] as const
+export type SortKey = (typeof SORT_KEYS)[number]
+export type SortDirection = 'asc' | 'desc'
+
+/** An empty list means no filter, so "all" and "nothing picked" are one state. */
+export function filterTasksByStatus(tasks: Task[], statuses: Status[]): Task[] {
+ if (statuses.length === 0) return tasks
+ return tasks.filter((task) => statuses.includes(task.status))
+}
+
+const compareTitles = (a: Task, b: Task) =>
+ displayTitle(a).localeCompare(displayTitle(b), undefined, { sensitivity: 'base' })
+
+/** Column order, so ascending reads left to right like the board does. */
+const compareStatuses = (columns: Column[]) => {
+ const rank = (task: Task) => {
+ const index = columns.findIndex((column) => column.status === task.status)
+ return index === -1 ? columns.length : index
+ }
+ return (a: Task, b: Task) => rank(a) - rank(b)
+}
+
+/** Only ever reached for two dated tasks; `sortTasks` strands the rest first. */
+const compareDueDates = (a: Task, b: Task) => (a.due_at ?? '').localeCompare(b.due_at ?? '')
+
+/** A sorted copy — `newestFirst` breaks ties so the order is never arbitrary. */
+export function sortTasks(
+ tasks: Task[],
+ columns: Column[],
+ key: SortKey,
+ direction: SortDirection,
+): Task[] {
+ const comparators: Record number> = {
+ title: compareTitles,
+ status: compareStatuses(columns),
+ due_at: compareDueDates,
+ }
+
+ const compare = comparators[key]
+ /**
+ * An undated task is absent from the due-date order, not later than every
+ * date, so it is pinned to the bottom outside the direction flip below.
+ */
+ const undated = (task: Task) => (key === 'due_at' && !task.due_at ? 1 : 0)
+ const sign = direction === 'desc' ? -1 : 1
+
+ return [...tasks].sort((a, b) => {
+ const stranded = undated(a) - undated(b)
+ if (stranded !== 0) return stranded
+
+ const primary = compare(a, b)
+ return primary === 0 ? newestFirst(a, b) : primary * sign
+ })
+}
+
/**
* Tasks matching a search, across every column. Empty for a blank query, so an
* untouched search box shows no results rather than the whole board.
@@ -165,6 +236,8 @@ export type BoardAction =
| { type: 'set_due_date'; id: string; due_at?: string }
| { type: 'move_task'; id: string; status: Status }
| { type: 'delete_task'; id: string }
+ /** Bulk clear-out from the all-tasks list, in one pass rather than one each. */
+ | { type: 'delete_tasks'; ids: string[] }
| { type: 'add_column'; label: string }
| { type: 'delete_column'; id: string }
@@ -228,6 +301,11 @@ export function boardReducer(state: BoardState, action: BoardAction): BoardState
case 'delete_task':
return { ...state, tasks: state.tasks.filter((task) => task.id !== action.id) }
+ case 'delete_tasks': {
+ const doomed = new Set(action.ids)
+ return { ...state, tasks: state.tasks.filter((task) => !doomed.has(task.id)) }
+ }
+
case 'add_column': {
if (validateColumnLabel(action.label, state.columns)) return state
return { ...state, columns: [...state.columns, createColumn(action.label)] }