diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index f94273ef3..888a07f6e 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1503,7 +1503,8 @@ "reviewLatest": "Review latest", "latestReviewed": "Latest reviewed", "discard": "Discard your unsaved focus changes?", - "saveError": "Focus could not be saved. Try again." + "saveError": "Focus could not be saved. Try again.", + "progress": "{{done}}/{{total}} done" }, "loadErrorTitle": "Couldn't load the board", "loadErrorText": "Check your connection and try again.", @@ -1564,6 +1565,20 @@ "assigned": "{{assignee}} · not claimed", "expired": "lease expired · {{holder}}", "claimed": "{{holder}} · claimed" + }, + "filter": { + "label": "Filters", + "mine": "Mine", + "p0": "P0", + "unclaimed": "Unclaimed" + }, + "search": { + "label": "Search tasks", + "placeholder": "Search tasks" + }, + "doneRail": { + "show": "Show", + "hide": "Hide" } }, "approvalCard": { diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 82edfeac9..89f487f05 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -1495,7 +1495,8 @@ "reviewLatest": "查看最新内容", "latestReviewed": "已查看最新内容", "discard": "放弃未保存的焦点更改?", - "saveError": "无法保存焦点,请重试。" + "saveError": "无法保存焦点,请重试。", + "progress": "{{done}}/{{total}}已完成" }, "loadErrorTitle": "看板加载失败", "loadErrorText": "请检查网络连接后重试。", @@ -1556,6 +1557,20 @@ "assigned": "{{assignee}} · 尚未认领", "expired": "认领已到期 · {{holder}}", "claimed": "{{holder}} · 已认领" + }, + "filter": { + "label": "筛选", + "mine": "我的", + "p0": "P0", + "unclaimed": "未认领" + }, + "search": { + "label": "搜索任务", + "placeholder": "搜索任务" + }, + "doneRail": { + "show": "展开", + "hide": "收起" } }, "approvalCard": { diff --git a/frontend/src/v2/__tests__/V2PodBoard.test.tsx b/frontend/src/v2/__tests__/V2PodBoard.test.tsx index 48d51b344..10c1bd4a2 100644 --- a/frontend/src/v2/__tests__/V2PodBoard.test.tsx +++ b/frontend/src/v2/__tests__/V2PodBoard.test.tsx @@ -244,6 +244,21 @@ describe('V2PodBoard', () => { }); }); + test('the P0 chip narrows the columns and the done rail shows the done column', async () => { + renderBoard(); + const pending = await screen.findByRole('region', { name: 'Pending' }); + expect(within(pending).getAllByRole('article').length).toBeGreaterThan(0); + + fireEvent.click(screen.getByRole('button', { name: 'P0' })); + expect(screen.getByRole('button', { name: 'P0' })).toHaveAttribute('aria-pressed', 'true'); + expect(within(pending).queryAllByRole('article')).toHaveLength(0); + + const rail = screen.getByRole('complementary', { name: 'Done' }); + expect(screen.getByTestId('v2-board').querySelector('.v2-board__columns')).not.toHaveClass('is-showdone'); + fireEvent.click(within(rail).getByRole('button', { name: 'Show' })); + expect(screen.getByTestId('v2-board').querySelector('.v2-board__columns')).toHaveClass('is-showdone'); + }); + test('creates a task from the dialog', async () => { axios.post.mockResolvedValue({ data: { task: { taskId: 'N-9', title: 'Ship it', status: 'pending' } } }); renderBoard(); diff --git a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts index 5214c436c..b4c3b3796 100644 --- a/frontend/src/v2/__tests__/v2-layout-invariants.test.ts +++ b/frontend/src/v2/__tests__/v2-layout-invariants.test.ts @@ -5320,4 +5320,18 @@ describe('pod board column scroll (TASK-698)', () => { expect(block).toMatch(/\.v2-pod-header__invite span\s*\{[^}]*clip-path:\s*inset\(50%\)/); expect(block).toMatch(/\.v2-pod-header__invite\s*\{[^}]*width:\s*28px/); }); + + test('the board keeps three columns plus a 120px done rail, and tabs only on phones (TASK-687)', () => { + const css = fs.readFileSync(path.join(__dirname, '..', 'v2.css'), 'utf8'); + expect(css).toMatch(/\.v2-board__columns \{[^}]*grid-template-columns: repeat\(3, minmax\(0, 1fr\)\) 120px/); + expect(css).toMatch(/\.v2-board__tabs \{ display: none; \}/); + // Card actions stay on the id row (off the title); card buttons keep a visible focus ring. + expect(css).toMatch(/\.v2-board__card-actions \{\s*position: absolute; right: 6px; top: 4px;/); + expect(css).toMatch(/button\.v2-board__card-main:focus-visible[^{]*\{[^}]*outline: 2px solid/); + // The column body is the scroller; the page itself does not scroll. + expect(css).toMatch(/\.v2-board__col-body \{[^}]*overflow-y: auto;[^}]*flex: 1;[^}]*min-height: 0;/); + // Phones get the tab bar back: scope to the TASK-687 mobile block. + const mobile = css.slice(css.indexOf('/* TASK-687 / D223')); + expect(mobile).toMatch(/@media \(max-width: 760px\) \{[\s\S]*\.v2-board__tabs \{ display: flex;/); + }); }); diff --git a/frontend/src/v2/components/V2PodBoard.tsx b/frontend/src/v2/components/V2PodBoard.tsx index 28474df9d..508c7c491 100644 --- a/frontend/src/v2/components/V2PodBoard.tsx +++ b/frontend/src/v2/components/V2PodBoard.tsx @@ -17,6 +17,8 @@ import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Trans, useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import SearchIcon from '@mui/icons-material/Search'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { useV2Api } from '../hooks/useV2Api'; import { useSocket } from '../../context/SocketContext'; import V2Avatar from './V2Avatar'; @@ -26,6 +28,13 @@ type TaskPriority = 'P0' | 'P1' | 'P2' | 'P3'; const PRIORITIES: TaskPriority[] = ['P0', 'P1', 'P2', 'P3']; type CanonicalStatus = 'pending' | 'claimed' | 'blocked' | 'done'; +type BoardFilter = 'mine' | 'p0' | 'unclaimed'; +const FILTERS: Array<{ key: BoardFilter; labelKey: string }> = [ + { key: 'mine', labelKey: 'board.filter.mine' }, + { key: 'p0', labelKey: 'board.filter.p0' }, + { key: 'unclaimed', labelKey: 'board.filter.unclaimed' }, +]; +const DONE_STATUSES = ['done', 'completed', 'complete']; interface BoardTask { taskId: string; @@ -623,14 +632,35 @@ const V2PodBoard: React.FC = () => { } }, [api, podId, newTitle, newAssignee, newPriority, creating, load, t]); + const [filters, setFilters] = useState([]); + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(''); + const [showDone, setShowDone] = useState(false); + const [mobileTab, setMobileTab] = useState('pending'); + const toggleFilter = (key: BoardFilter) => setFilters((prev) => ( + prev.includes(key) ? prev.filter((item) => item !== key) : [...prev, key] + )); + const visibleTasks = useMemo(() => { + const needle = query.trim().toLowerCase(); + return tasks.filter((task) => { + if (filters.includes('mine')) { + const me = [currentUser?.username, currentUser?._id].filter(Boolean); + if (!me.some((id) => id === task.assignee || id === task.claimedBy)) return false; + } + if (filters.includes('p0') && task.priority !== 'P0') return false; + if (filters.includes('unclaimed') && (task.claimedBy || task.assignee)) return false; + if (needle && !`${task.taskId} ${task.title}`.toLowerCase().includes(needle)) return false; + return true; + }); + }, [tasks, filters, query, currentUser]); const columnTasks = useMemo(() => { const byColumn = new Map(); COLUMNS.forEach((col) => { - byColumn.set(col.key, tasks.filter((task) => col.statuses.includes(task.status)).sort((a, b) => (a.priority || 'P2').localeCompare(b.priority || 'P2') + byColumn.set(col.key, visibleTasks.filter((task) => col.statuses.includes(task.status)).sort((a, b) => (a.priority || 'P2').localeCompare(b.priority || 'P2') || (a.taskNum || 0) - (b.taskNum || 0) || a.taskId.localeCompare(b.taskId))); }); return byColumn; - }, [tasks]); + }, [visibleTasks]); return (
@@ -641,7 +671,7 @@ const V2PodBoard: React.FC = () => { onClick={() => navigate(`/v2/pods/${podId}`)} >