From 6f12ea085652529eb196d41037065514a745a3c1 Mon Sep 17 00:00:00 2001 From: Jay Porta <15250836+jayporta@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:15:29 -0700 Subject: [PATCH 1/3] [task-board] add all tasks modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat, cross-column list of every task behind a "Show all" button in the toolbar: title, created date, due date, and status, with status filter chips, a sort control, and single or bulk delete. Filter and sort are local to the modal — the board underneath is untouched. Co-Authored-By: Claude Opus 5 --- src/App.tsx | 2 + src/components/AllTasksDialog.tsx | 14 ++ src/components/AllTasksPanel.tsx | 286 ++++++++++++++++++++++++++++++ src/components/BoardToolbar.tsx | 69 ++++--- src/components/SearchResults.tsx | 8 +- src/components/TaskForm.tsx | 18 +- src/context/BoardProvider.tsx | 9 + src/context/boardContext.ts | 4 + src/lib/board.test.ts | 76 +++++++- src/lib/board.ts | 71 ++++++++ src/lib/theme.ts | 12 ++ 11 files changed, 534 insertions(+), 35 deletions(-) create mode 100644 src/components/AllTasksDialog.tsx create mode 100644 src/components/AllTasksPanel.tsx 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 ( + + {/* Mounting the panel only while open resets its filters, sort, and selection. */} + {allTasksOpen && } + + ) +} diff --git a/src/components/AllTasksPanel.tsx b/src/components/AllTasksPanel.tsx new file mode 100644 index 0000000..76e395b --- /dev/null +++ b/src/components/AllTasksPanel.tsx @@ -0,0 +1,286 @@ +import { useState } from 'react' +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' +import CloseIcon from '@mui/icons-material/Close' +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, + displayTitle, + filterTasksByStatus, + 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 { 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() + const [statuses, setStatuses] = useState([]) + const [sortKey, setSortKey] = useState('status') + const [direction, setDirection] = useState('asc') + const [selected, setSelected] = useState([]) + const [pending, setPending] = useState(null) + + const rows = sortTasks( + filterTasksByStatus(board.tasks, statuses), + board.columns, + sortKey, + direction, + ) + + const visibleIds = rows.map((task) => task.id) + const selectedHere = selected.filter((id) => visibleIds.includes(id)) + const allSelected = rows.length > 0 && selectedHere.length === rows.length + + const toggleStatus = (status: Status) => + setStatuses((current) => + current.includes(status) + ? current.filter((value) => value !== status) + : [...current, status], + ) + + const toggleRow = (id: string) => + setSelected((current) => + current.includes(id) ? current.filter((value) => value !== id) : [...current, id], + ) + + // Select-all covers what the filter is showing, not the whole board. + const toggleAll = () => setSelected(allSelected ? [] : visibleIds) + + const confirmOne = (task: Task) => + setPending({ + ids: [task.id], + title: 'Delete this task?', + description: `"${displayTitle(task)}" will be removed from the board. This cannot be undone.`, + }) + + const confirmSelection = () => { + const count = `${selectedHere.length} ${selectedHere.length === 1 ? 'task' : 'tasks'}` + setPending({ + ids: selectedHere, + title: `Delete ${count}?`, + description: `${count} will be removed from the board. This cannot be undone.`, + }) + } + + const remove = (ids: string[]) => { + dispatch({ type: 'delete_tasks', ids }) + // Dropping the whole selection keeps deleted ids from lingering in it. + setSelected([]) + } + + return ( + <> + + All tasks + + + + + + + + + + setStatuses([])} + /> + + {board.columns.map((column) => { + const active = statuses.includes(column.status) + return ( + toggleStatus(column.status)} + /> + ) + })} + + setSortKey(event.target.value as SortKey)} + sx={{ ml: 'auto', width: 140 }} + > + {(Object.keys(SORT_LABELS) as SortKey[]).map((key) => ( + + {SORT_LABELS[key]} + + ))} + + + + setDirection(direction === 'asc' ? 'desc' : 'asc')} + > + {direction === 'asc' ? : } + + + + + + + {rows.length} of {board.tasks.length} {board.tasks.length === 1 ? 'task' : 'tasks'} + + + {selectedHere.length > 0 && ( + + )} + + + {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('')}> + + + + ), + }, + }} + /> + + + 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 ( - Task details + + Task details + + {/* Discards the draft, like Cancel — `type` stays "button" so the + form is not submitted. */} + + + + + 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..961a4fd 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,62 @@ 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']) + }) + + 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..07ce6a1 100644 --- a/src/lib/board.ts +++ b/src/lib/board.ts @@ -135,6 +135,70 @@ export function visibleTasks(tasks: Task[], status: Status): Task[] { return tasks.filter((task) => task.status === status).sort(newestFirst) } +/** 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) +} + +/** + * Undated tasks sort last whichever way the direction points — a missing due + * date is absent, not later than every date, so flipping to descending should + * not float them to the top. + */ +const compareDueDates = (a: Task, b: Task) => { + if (!a.due_at || !b.due_at) return Number(!a.due_at) - Number(!b.due_at) + return 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] + const undated = (task: Task) => (key === 'due_at' && !task.due_at ? 1 : 0) + const sign = direction === 'desc' ? -1 : 1 + + return [...tasks].sort((a, b) => { + // Kept out of the direction flip, so they stay pinned to the bottom. + 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 +229,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 +294,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)] } diff --git a/src/lib/theme.ts b/src/lib/theme.ts index fe309c3..f847030 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -19,6 +19,18 @@ export const theme = createTheme({ asterisk: ({ theme }) => ({ color: theme.palette.error.main }), }, }, + // Titles carry a close button on the right, so the row is a flex pair. A + // title on its own is unaffected — one item still sits where it always did. + MuiDialogTitle: { + styleOverrides: { + root: ({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: theme.spacing(2), + }), + }, + }, // Every dialog in the app wants the same roomier action row. MuiDialogActions: { styleOverrides: { From 054fcab584ccac5f7155a89cde8fab7ba7b8d853 Mon Sep 17 00:00:00 2001 From: Jay Porta <15250836+jayporta@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:38:10 -0700 Subject: [PATCH 2/3] [task-board] refine all tasks modal filter and dialog close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the status filter chips with a single-select "Filter by" dropdown matching the sort control, defaulting to All. Changing it clears the row selection, so a task the filter hides can never stay ticked out of sight — which lets the selection hold only visible ids and drops a per-render scan. Deleting one row no longer clears a batch the user has ticked up. Move both dialog close buttons out of DialogTitle into a shared DialogCloseButton: MUI names a dialog after its whole title element, so a button in there was being read out as part of that name. The MuiDialogTitle override existed only for that layout and is no longer needed. Also: memoise the filtered and sorted rows, stick the table headings to the scroll box, build the sort menu from SORT_KEYS rather than a cast, share the delete confirmation copy from lib, and cover the sort tie-break with a test. Co-Authored-By: Claude Opus 5 --- src/components/AllTasksPanel.tsx | 117 +++++++++++++-------------- src/components/DialogCloseButton.tsx | 28 +++++++ src/components/TaskCard.tsx | 5 +- src/components/TaskForm.tsx | 21 +---- src/lib/board.test.ts | 14 ++++ src/lib/board.ts | 27 ++++--- src/lib/theme.ts | 12 --- 7 files changed, 119 insertions(+), 105 deletions(-) create mode 100644 src/components/DialogCloseButton.tsx diff --git a/src/components/AllTasksPanel.tsx b/src/components/AllTasksPanel.tsx index 76e395b..189ee21 100644 --- a/src/components/AllTasksPanel.tsx +++ b/src/components/AllTasksPanel.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward' import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward' -import CloseIcon from '@mui/icons-material/Close' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutlined' import Button from '@mui/material/Button' import Checkbox from '@mui/material/Checkbox' @@ -22,8 +21,10 @@ import Typography from '@mui/material/Typography' import { useBoardContext } from '../context/boardContext' import { columnLabel, + deleteTaskPrompt, displayTitle, filterTasksByStatus, + SORT_KEYS, sortTasks, type SortDirection, type SortKey, @@ -31,6 +32,7 @@ import { 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 = { @@ -45,29 +47,35 @@ 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() - const [statuses, setStatuses] = useState([]) + // 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([]) const [pending, setPending] = useState(null) - const rows = sortTasks( - filterTasksByStatus(board.tasks, statuses), - board.columns, - sortKey, - direction, + // 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], ) - const visibleIds = rows.map((task) => task.id) - const selectedHere = selected.filter((id) => visibleIds.includes(id)) - const allSelected = rows.length > 0 && selectedHere.length === rows.length + const allSelected = rows.length > 0 && selected.length === rows.length - const toggleStatus = (status: Status) => - setStatuses((current) => - current.includes(status) - ? current.filter((value) => value !== status) - : [...current, status], - ) + // 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([]) + } const toggleRow = (id: string) => setSelected((current) => @@ -75,19 +83,14 @@ export function AllTasksPanel() { ) // Select-all covers what the filter is showing, not the whole board. - const toggleAll = () => setSelected(allSelected ? [] : visibleIds) + const toggleAll = () => setSelected(allSelected ? [] : rows.map((task) => task.id)) - const confirmOne = (task: Task) => - setPending({ - ids: [task.id], - title: 'Delete this task?', - description: `"${displayTitle(task)}" will be removed from the board. This cannot be undone.`, - }) + const confirmOne = (task: Task) => setPending({ ids: [task.id], ...deleteTaskPrompt(task) }) const confirmSelection = () => { - const count = `${selectedHere.length} ${selectedHere.length === 1 ? 'task' : 'tasks'}` + const count = `${selected.length} ${selected.length === 1 ? 'task' : 'tasks'}` setPending({ - ids: selectedHere, + ids: selected, title: `Delete ${count}?`, description: `${count} will be removed from the board. This cannot be undone.`, }) @@ -95,20 +98,15 @@ export function AllTasksPanel() { const remove = (ids: string[]) => { dispatch({ type: 'delete_tasks', ids }) - // Dropping the whole selection keeps deleted ids from lingering in it. - setSelected([]) + // Only the deleted ids leave the selection — deleting one row must not + // clear a batch the user has been ticking up. + setSelected((current) => current.filter((id) => !ids.includes(id))) } return ( <> - - All tasks - - - - - - + All tasks + - setStatuses([])} - /> - - {board.columns.map((column) => { - const active = statuses.includes(column.status) - return ( - toggleStatus(column.status)} - /> - ) - })} + label="Filter by" + value={status} + onChange={(event) => changeFilter(event.target.value)} + sx={{ width: 160 }} + > + All + {board.columns.map((column) => ( + + {column.label} + + ))} + setSortKey(event.target.value as SortKey)} sx={{ ml: 'auto', width: 140 }} > - {(Object.keys(SORT_LABELS) as SortKey[]).map((key) => ( + {SORT_KEYS.map((key) => ( {SORT_LABELS[key]} @@ -175,7 +165,7 @@ export function AllTasksPanel() { {rows.length} of {board.tasks.length} {board.tasks.length === 1 ? 'task' : 'tasks'} - {selectedHere.length > 0 && ( + {selected.length > 0 && ( )} + {/* The table sticks its headings to `DialogContent`, the scroll box. */} {rows.length === 0 ? ( ) : ( - +
0 && !allSelected} + indeterminate={selected.length > 0 && !allSelected} onChange={toggleAll} slotProps={{ input: { 'aria-label': 'Select all listed tasks' } }} /> 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/TaskCard.tsx b/src/components/TaskCard.tsx index 54acc8d..2d4f354 100644 --- a/src/components/TaskCard.tsx +++ b/src/components/TaskCard.tsx @@ -11,7 +11,7 @@ import Tooltip from '@mui/material/Tooltip' import Typography from '@mui/material/Typography' import { useBoardContext } from '../context/boardContext' import { useTaskDrag } from '../hooks/useTaskDrag' -import { displayTitle, normalizeTitle, UNTITLED_LABEL } from '../lib/board' +import { deleteTaskPrompt, displayTitle, normalizeTitle, UNTITLED_LABEL } from '../lib/board' import { formatDate } from '../lib/dates' import type { Task } from '../types' import { ConfirmDialog } from './ConfirmDialog' @@ -165,8 +165,7 @@ export function TaskCard({ task }: { task: Task }) { dispatch({ type: 'delete_task', id: task.id })} onClose={() => setConfirmingDelete(false)} /> diff --git a/src/components/TaskForm.tsx b/src/components/TaskForm.tsx index 2555c6e..2fd93ab 100644 --- a/src/components/TaskForm.tsx +++ b/src/components/TaskForm.tsx @@ -1,19 +1,17 @@ import { useState } from 'react'; -import CloseIcon from '@mui/icons-material/Close'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; -import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; import TextField from '@mui/material/TextField'; -import Tooltip from '@mui/material/Tooltip'; import { DatePicker } from '@mui/x-date-pickers/DatePicker'; 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 @@ -41,20 +39,9 @@ export function TaskForm({ task }: { task: Task }) { return ( - - Task details - - {/* Discards the draft, like Cancel — `type` stays "button" so the - form is not submitted. */} - - - - - + Task details + {/* Discards the draft, like Cancel. */} + diff --git a/src/lib/board.test.ts b/src/lib/board.test.ts index 961a4fd..f68f2db 100644 --- a/src/lib/board.test.ts +++ b/src/lib/board.test.ts @@ -122,6 +122,20 @@ describe('sortTasks', () => { // 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', () => { diff --git a/src/lib/board.ts b/src/lib/board.ts index 07ce6a1..8ada728 100644 --- a/src/lib/board.ts +++ b/src/lib/board.ts @@ -135,6 +135,17 @@ 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 @@ -162,15 +173,8 @@ const compareStatuses = (columns: Column[]) => { return (a: Task, b: Task) => rank(a) - rank(b) } -/** - * Undated tasks sort last whichever way the direction points — a missing due - * date is absent, not later than every date, so flipping to descending should - * not float them to the top. - */ -const compareDueDates = (a: Task, b: Task) => { - if (!a.due_at || !b.due_at) return Number(!a.due_at) - Number(!b.due_at) - return a.due_at.localeCompare(b.due_at) -} +/** 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( @@ -186,11 +190,14 @@ export function sortTasks( } 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) => { - // Kept out of the direction flip, so they stay pinned to the bottom. const stranded = undated(a) - undated(b) if (stranded !== 0) return stranded diff --git a/src/lib/theme.ts b/src/lib/theme.ts index f847030..fe309c3 100644 --- a/src/lib/theme.ts +++ b/src/lib/theme.ts @@ -19,18 +19,6 @@ export const theme = createTheme({ asterisk: ({ theme }) => ({ color: theme.palette.error.main }), }, }, - // Titles carry a close button on the right, so the row is a flex pair. A - // title on its own is unaffected — one item still sits where it always did. - MuiDialogTitle: { - styleOverrides: { - root: ({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: theme.spacing(2), - }), - }, - }, // Every dialog in the app wants the same roomier action row. MuiDialogActions: { styleOverrides: { From c86bf2fb6f9407817ae1dd2c305586ac02e6c32f Mon Sep 17 00:00:00 2001 From: Jay Porta <15250836+jayporta@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:54:59 -0700 Subject: [PATCH 3/3] [task-board] hold all tasks selection in a set Compare select-all against the rows themselves rather than a length match, which was only correct while the selection stayed a subset of what the filter shows. Co-Authored-By: Claude Opus 5 --- src/components/AllTasksPanel.tsx | 36 +++++++++++++++++++------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/src/components/AllTasksPanel.tsx b/src/components/AllTasksPanel.tsx index 189ee21..c99af3a 100644 --- a/src/components/AllTasksPanel.tsx +++ b/src/components/AllTasksPanel.tsx @@ -52,7 +52,7 @@ export function AllTasksPanel() { const [status, setStatus] = useState('') const [sortKey, setSortKey] = useState('status') const [direction, setDirection] = useState('asc') - const [selected, setSelected] = useState([]) + const [selected, setSelected] = useState>(new Set()) const [pending, setPending] = useState(null) // Ticking a checkbox or opening the confirm dialog re-renders the panel; the @@ -68,29 +68,34 @@ export function AllTasksPanel() { [board.tasks, board.columns, status, sortKey, direction], ) - const allSelected = rows.length > 0 && selected.length === rows.length + // 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([]) + setSelected(new Set()) } const toggleRow = (id: string) => - setSelected((current) => - current.includes(id) ? current.filter((value) => value !== id) : [...current, id], - ) + 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 ? [] : rows.map((task) => task.id)) + 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.length} ${selected.length === 1 ? 'task' : 'tasks'}` + const count = `${selected.size} ${selected.size === 1 ? 'task' : 'tasks'}` setPending({ - ids: selected, + ids: [...selected], title: `Delete ${count}?`, description: `${count} will be removed from the board. This cannot be undone.`, }) @@ -100,7 +105,8 @@ export function AllTasksPanel() { 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. - setSelected((current) => current.filter((id) => !ids.includes(id))) + const doomed = new Set(ids) + setSelected((current) => new Set([...current].filter((id) => !doomed.has(id)))) } return ( @@ -165,7 +171,7 @@ export function AllTasksPanel() { {rows.length} of {board.tasks.length} {board.tasks.length === 1 ? 'task' : 'tasks'} - {selected.length > 0 && ( + {selected.size > 0 && ( )} @@ -196,7 +202,7 @@ export function AllTasksPanel() { 0 && !allSelected} + indeterminate={selected.size > 0 && !allSelected} onChange={toggleAll} slotProps={{ input: { 'aria-label': 'Select all listed tasks' } }} /> @@ -213,11 +219,11 @@ export function AllTasksPanel() { {rows.map((task) => { const overdue = isOverdue(task.due_at) return ( - + toggleRow(task.id)} slotProps={{ input: { 'aria-label': `Select ${displayTitle(task)}` } }} />