diff --git a/docs/FRONTEND_GUIDE.md b/docs/FRONTEND_GUIDE.md index 1939de4..c9157b4 100644 --- a/docs/FRONTEND_GUIDE.md +++ b/docs/FRONTEND_GUIDE.md @@ -55,6 +55,15 @@ This guide provides comprehensive documentation for frontend development in the --- +## Task IDs (Visible in UI) + +To improve human↔automation coordination, each task now displays its unique ID with a `#` prefix across the UI: +- Board View task cards +- Tree View task rows +- Task Detail Modal header + +--- + ## Project Structure ### Current Directory Structure diff --git a/e2e/task-id-visibility.spec.ts b/e2e/task-id-visibility.spec.ts new file mode 100644 index 0000000..349490f --- /dev/null +++ b/e2e/task-id-visibility.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test'; +import { AppPage } from './page-objects/app.page'; +import { TaskPage } from './page-objects/task.page'; +import { BoardPage } from './page-objects/board.page'; + +test.describe('Task ID visibility', () => { + let appPage: AppPage; + let taskPage: TaskPage; + let boardPage: BoardPage; + + test.beforeEach(async ({ page }) => { + appPage = new AppPage(page); + taskPage = new TaskPage(page); + boardPage = new BoardPage(page); + + await appPage.goto(); + + // Start clean + await page.evaluate(() => localStorage.clear()); + await appPage.page.reload(); + }); + + test('should show a #ID in board cards and in task detail modal header', async ({ page }) => { + const title = 'Task With Visible ID'; + + await appPage.openAddTaskModal(); + await taskPage.createTask({ title }); + + await appPage.switchToView('board'); + const card = boardPage.getTaskCard(title); + + // Board card should show a mono badge starting with '#' + const idBadge = card.locator('span.font-mono').first(); + await expect(idBadge).toBeVisible(); + await expect(idBadge).toHaveText(/^#.+/); + + // Open modal and validate header contains same ID + await card.click(); + + const modalHeaderBadge = page.locator('[role="dialog"] h2 span.font-mono').first(); + await expect(modalHeaderBadge).toBeVisible(); + await expect(modalHeaderBadge).toHaveText(/^#.+/); + + // Same ID in both places + const boardId = (await idBadge.textContent())?.trim(); + const modalId = (await modalHeaderBadge.textContent())?.trim(); + expect(boardId).toBeTruthy(); + expect(modalId).toBeTruthy(); + expect(modalId).toBe(boardId); + }); +}); diff --git a/src/components/TaskBoard.tsx b/src/components/TaskBoard.tsx index 28e7731..b015cdc 100644 --- a/src/components/TaskBoard.tsx +++ b/src/components/TaskBoard.tsx @@ -7,6 +7,7 @@ import { Plus, Calendar, Circle, Clock, CheckCircle, Eye, Edit2, Trash2 } from ' import { TaskTimer } from './TaskTimer'; import { DeleteConfirmationModal } from './DeleteConfirmationModal'; import { SpotlightCard } from './ui/SpotlightCard'; +import { TruncatedTaskTitle } from './ui/TruncatedTaskTitle'; interface TaskBoardProps { tasks: Task[]; @@ -96,32 +97,6 @@ export const TaskBoard: React.FC = ({ setTaskToDelete(null); }; - const renderTitle = (task: Task) => { - const maxLength = 40; - const isLong = task.title.length > maxLength; - - if (isLong) { - const truncated = task.title.substring(0, maxLength); - return ( -

- {truncated} - -

- ); - } - - return ( -

- {task.title} -

- ); - }; - const renderDescription = (task: Task) => { if (!task.description) return null; @@ -213,7 +188,14 @@ export const TaskBoard: React.FC = ({ )} - {renderTitle(task)} + {renderDescription(task)} diff --git a/src/components/TaskDetailModal.tsx b/src/components/TaskDetailModal.tsx index 5a5b3db..c862cd7 100644 --- a/src/components/TaskDetailModal.tsx +++ b/src/components/TaskDetailModal.tsx @@ -9,6 +9,7 @@ import { X, Calendar, Clock, Edit2, AlertCircle, CheckCircle, Calculator, User } import { extractAttachments } from '../utils/attachmentUtils'; import { AttachmentList } from './AttachmentList'; import { TaskComments } from './TaskComments'; +import { TaskIdBadge } from './ui/TaskIdBadge'; interface TaskDetailModalProps { @@ -64,7 +65,14 @@ export const TaskDetailModal: React.FC = ({ {/* Header */}
-

{task.title}

+
+ {task.id != null && ( + + )} +

+ {task.title} +

+
- - ); - } - - return ( -

- {task.title} -

- ); - }; - const renderDescription = () => { if (!task.description) return null; @@ -256,15 +228,14 @@ export const TaskItem: React.FC = ({ } `} /> -

- {task.title} -

+
diff --git a/src/components/ui/TaskIdBadge.tsx b/src/components/ui/TaskIdBadge.tsx new file mode 100644 index 0000000..a1ca607 --- /dev/null +++ b/src/components/ui/TaskIdBadge.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; + +interface TaskIdBadgeProps { + id: string | number | undefined; + className?: string; + size?: 'xs' | 'sm' | 'md' | 'lg'; + maxWidth?: string; +} + +export const TaskIdBadge: React.FC = ({ + id, + className = '', + size = 'xs', + maxWidth = 'max-w-[140px]' +}) => { + const { theme } = useTheme(); + + if (id === undefined || id === null) return null; + + const sizeClasses = { + xs: 'text-xs scale-90 origin-left', + sm: 'text-xs', + md: 'text-sm', + lg: 'text-lg', + }; + + const opacityClass = theme === 'dark' ? 'text-gray-400 opacity-70' : 'text-gray-500 opacity-80'; + + return ( + + #{id} + + ); +}; diff --git a/src/components/ui/TruncatedTaskTitle.tsx b/src/components/ui/TruncatedTaskTitle.tsx new file mode 100644 index 0000000..8727e13 --- /dev/null +++ b/src/components/ui/TruncatedTaskTitle.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Task } from '../../types/Task'; +import { TaskIdBadge } from './TaskIdBadge'; + +interface TruncatedTaskTitleProps { + task: Task; + maxLength: number; + onEdit?: (task: Task) => void; + className?: string; + titleClassName?: string; + idSize?: 'xs' | 'sm' | 'md' | 'lg'; + as?: 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span'; +} + +export const TruncatedTaskTitle: React.FC = ({ + task, + maxLength, + onEdit, + className = '', + titleClassName = '', + idSize = 'xs', + as: Component = 'h3' +}) => { + const { theme } = useTheme(); + const title = task.title ?? ''; + const safeMaxLength = Math.max(1, maxLength); + const isLong = title.length > safeMaxLength; + + const titleContent = isLong ? Array.from(title).slice(0, safeMaxLength).join('') : title; + const safeAriaLabel = `Edit task: ${Array.from(title).slice(0, 100).join('')}${title.length > 100 ? '...' : ''}`; + + const buttonThemeClasses = theme === 'dark' + ? 'text-indigo-400 hover:text-indigo-300' + : 'text-indigo-600 hover:text-indigo-800'; + + const hasColorClass = /\b(?:[a-z0-9]+:)?text-(?:gray|red|blue|indigo|green|yellow|white|black|transparent|current|inherit|slate|zinc|neutral|stone|orange|amber|lime|emerald|teal|cyan|sky|violet|purple|fuchsia|pink|rose)(?:-\d+)?\b/.test(titleClassName) || + titleClassName.includes('text-[') || + ['text-current', 'text-transparent', 'text-inherit'].some(c => titleClassName.includes(c)); + const colorClasses = hasColorClass + ? '' + : (theme === 'dark' ? 'text-gray-100' : 'text-gray-900'); + + return ( +
+ + {task.id != null && } + {titleContent} + + {isLong && ( + + )} +
+ ); +}; diff --git a/src/test/components/TaskTree.test.tsx b/src/test/components/TaskTree.test.tsx index bd7c829..5ec5e90 100644 --- a/src/test/components/TaskTree.test.tsx +++ b/src/test/components/TaskTree.test.tsx @@ -121,10 +121,10 @@ describe('TaskTree Component', () => { ); // Assert - verificar que se muestran las tareas - expect(screen.getByRole('heading', { name: 'Parent Task' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Child Task 1' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Child Task 2' })).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: 'Grandchild Task' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Parent Task/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Child Task 1/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Child Task 2/ })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /Grandchild Task/ })).toBeInTheDocument(); }); it('should apply correct indentation for nested tasks', () => { diff --git a/src/test/components/ui/TaskIdBadge.test.tsx b/src/test/components/ui/TaskIdBadge.test.tsx new file mode 100644 index 0000000..4427dfc --- /dev/null +++ b/src/test/components/ui/TaskIdBadge.test.tsx @@ -0,0 +1,42 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { TaskIdBadge } from '../../../components/ui/TaskIdBadge'; +import { ThemeProvider } from '../../../contexts/ThemeContext'; + +describe('TaskIdBadge', () => { + it('renders the task id with a # prefix', () => { + render( + + + + ); + expect(screen.getByText('#123')).toBeDefined(); + }); + + it('renders nothing if id is undefined', () => { + const { container } = render( + + + + ); + expect(container.firstChild).toBeNull(); + }); + + it('applies correct size classes', () => { + const { container: xsContainer } = render( + + + + ); + // xs uses tailwind text-xs plus scale-90 for subtle badge sizing + expect(xsContainer.querySelector('.text-xs')).toBeDefined(); + expect(xsContainer.querySelector('.scale-90')).toBeDefined(); + + const { container: lgContainer } = render( + + + + ); + expect(lgContainer.querySelector('.text-lg')).toBeDefined(); + }); +});