Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/FRONTEND_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 51 additions & 0 deletions e2e/task-id-visibility.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 9 additions & 27 deletions src/components/TaskBoard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -96,32 +97,6 @@ export const TaskBoard: React.FC<TaskBoardProps> = ({
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 (
<h4 className={`font-medium ${theme === 'dark' ? 'text-gray-100' : 'text-gray-900'} mb-1`}>
{truncated}
<button
onClick={() => onEdit(task)}
className="text-indigo-600 hover:text-indigo-800 font-medium ml-1 transition-colors duration-200"
>
...
</button>
</h4>
);
}

return (
<h4 className={`font-medium ${theme === 'dark' ? 'text-gray-100' : 'text-gray-900'} truncate mb-1`}>
{task.title}
</h4>
);
};

const renderDescription = (task: Task) => {
if (!task.description) return null;

Expand Down Expand Up @@ -213,7 +188,14 @@ export const TaskBoard: React.FC<TaskBoardProps> = ({
</span>
</div>
)}
{renderTitle(task)}
<TruncatedTaskTitle
task={task}
maxLength={40}
onEdit={onEdit}
as="h4"
className="mb-1"
titleClassName={`font-medium ${task.status === 'Done' ? 'text-gray-500 line-through' : ''}`}
/>
{renderDescription(task)}
</div>

Expand Down
10 changes: 9 additions & 1 deletion src/components/TaskDetailModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -64,7 +65,14 @@ export const TaskDetailModal: React.FC<TaskDetailModalProps> = ({
{/* Header */}
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
<h2 className="text-2xl font-bold leading-tight break-words pr-8">{task.title}</h2>
<div className="flex-1 min-w-0 pr-8">
{task.id != null && (
<TaskIdBadge id={task.id} size="lg" className="mb-2" />
)}
<h2 className="text-2xl font-bold leading-tight break-words">
{task.title}
</h2>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => {
Expand Down
47 changes: 9 additions & 38 deletions src/components/TaskItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ChevronRight, ChevronDown, MoreHorizontal, Calendar, User, Circle, Cloc
import { TaskTimer } from './TaskTimer';
import { useTheme } from '../contexts/ThemeContext';
import { DeleteConfirmationModal } from './DeleteConfirmationModal';
import { TruncatedTaskTitle } from './ui/TruncatedTaskTitle';

interface TaskItemProps {
task: Task;
Expand Down Expand Up @@ -116,35 +117,6 @@ export const TaskItem: React.FC<TaskItemProps> = ({
setDeleteModalOpen(false);
};

const renderTitle = () => {
const maxLength = 60;
const isLong = task.title.length > maxLength;

if (isLong) {
const truncated = task.title.substring(0, maxLength);
return (
<h3 className={`font-medium ${theme === 'dark' ? 'text-gray-100' : 'text-gray-900'}`}>
{truncated}
<button
onClick={(e) => {
e.stopPropagation();
onEdit(task);
}}
className={`${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-800'} font-medium ml-1 transition-colors duration-200`}
>
...
</button>
</h3>
);
}

return (
<h3 className={`font-medium ${theme === 'dark' ? 'text-gray-100' : 'text-gray-900'} truncate`}>
{task.title}
</h3>
);
};

const renderDescription = () => {
if (!task.description) return null;

Expand Down Expand Up @@ -256,15 +228,14 @@ export const TaskItem: React.FC<TaskItemProps> = ({
}
`}
/>
<h3
className={`
text-base sm:text-lg font-bold truncate leading-tight cursor-pointer
${theme === 'dark' ? 'text-gray-100' : 'text-gray-800'}
${task.status === 'Done' ? 'line-through text-gray-500 decoration-gray-400' : ''}
`}
>
{task.title}
</h3>
<TruncatedTaskTitle
task={task}
maxLength={60}
onEdit={onEdit}
idSize="sm"
as="h3"
titleClassName={`text-base sm:text-lg font-bold leading-tight cursor-pointer ${task.status === 'Done' ? 'text-gray-500 line-through decoration-gray-400' : ''}`}
/>
</div>
</div>
</div>
Expand Down
38 changes: 38 additions & 0 deletions src/components/ui/TaskIdBadge.tsx
Original file line number Diff line number Diff line change
@@ -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<TaskIdBadgeProps> = ({
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 (
<span
className={`font-mono mr-2 ${sizeClasses[size]} ${opacityClass} truncate ${maxWidth} ${className}`}
title={String(id)}
>
#{id}
</span>
);
};
68 changes: 68 additions & 0 deletions src/components/ui/TruncatedTaskTitle.tsx
Original file line number Diff line number Diff line change
@@ -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<TruncatedTaskTitleProps> = ({
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 (
<div className={`flex items-center min-w-0 ${className}`}>
<Component
className={`font-medium flex items-center min-w-0 ${colorClasses} ${titleClassName}`}
title={isLong ? title : undefined}
>
{task.id != null && <TaskIdBadge id={task.id} size={idSize} />}
<span className="truncate">{titleContent}</span>
</Component>
{isLong && (
<button
type="button"
aria-label={safeAriaLabel}
onClick={(e) => {
e.stopPropagation();
if (onEdit) onEdit(task);
}}
className={`${buttonThemeClasses} font-medium ml-1 hover:underline focus:outline-none transition-colors duration-200 flex-shrink-0 cursor-pointer`}
>
...
</button>
)}
</div>
);
};
8 changes: 4 additions & 4 deletions src/test/components/TaskTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
42 changes: 42 additions & 0 deletions src/test/components/ui/TaskIdBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ThemeProvider>
<TaskIdBadge id="123" />
</ThemeProvider>
);
expect(screen.getByText('#123')).toBeDefined();
});

it('renders nothing if id is undefined', () => {
const { container } = render(
<ThemeProvider>
<TaskIdBadge id={undefined} />
</ThemeProvider>
);
expect(container.firstChild).toBeNull();
});

it('applies correct size classes', () => {
const { container: xsContainer } = render(
<ThemeProvider>
<TaskIdBadge id="1" size="xs" />
</ThemeProvider>
);
// 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(
<ThemeProvider>
<TaskIdBadge id="1" size="lg" />
</ThemeProvider>
);
expect(lgContainer.querySelector('.text-lg')).toBeDefined();
});
});
Loading