Archon/archon-ui-main/src/features/projects/tasks/components/TaskCard.tsx
DIY Smart Code c45842f0bb
feat: decouple task priority from task order (#652)
* feat: decouple task priority from task order

This implements a dedicated priority system that operates independently
from the existing task_order system, allowing users to set task priority
without affecting visual drag-and-drop positioning.

## Changes Made

### Database
- Add priority column to archon_tasks table with enum type (critical, high, medium, low)
- Create database migration with safe enum handling and data backfill
- Add proper indexing for performance

### Backend
- Update UpdateTaskRequest to include priority field
- Add priority validation in TaskService with enum checking
- Include priority field in task list responses and ETag generation
- Fix cache invalidation for priority updates

### Frontend
- Update TaskPriority type from "urgent" to "critical" for consistency
- Add changePriority method to useTaskActions hook
- Update TaskCard to use direct priority field instead of task_order conversion
- Update TaskEditModal priority form to use direct priority values
- Fix TaskPriorityComponent to use correct priority enum values
- Update buildTaskUpdates to include priority field changes
- Add priority field to Task interface as required field
- Update test fixtures to include priority field

## Key Features
-  Users can change task priority without affecting drag-and-drop order
-  Users can drag tasks to reorder without changing priority level
-  Priority persists correctly in database with dedicated column
-  All existing priority functionality continues working identically
-  Cache invalidation works properly for priority changes
-  Both TaskCard priority button and TaskEditModal priority work

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add priority column to complete_setup.sql for fresh installations

- Add task_priority enum type (low, medium, high, critical)
- Add priority column to archon_tasks table with default 'medium'
- Add index for priority column performance
- Add documentation comment for priority field

This ensures fresh installations include the priority system without
needing to run the separate migration.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: include priority field in task creation payload

When creating new tasks via TaskEditModal, the buildCreateRequest function
was not including the priority field, causing new tasks to fall back to
the database default ('medium') instead of respecting the user's selected
priority in the modal.

Added priority: localTask.priority || 'medium' to ensure the user's
chosen priority is sent to the API during task creation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: make priority migration safe and idempotent

Replaced destructive DROP TYPE CASCADE with safe migration patterns:

- Use DO blocks with EXCEPTION handling for enum and column creation
- Prevent conflicts with complete_setup.sql for fresh installations
- Enhanced backfill logic to preserve user-modified priorities
- Only update tasks that haven't been modified (updated_at = created_at)
- Add comprehensive error handling with informative notices
- Migration can now be run multiple times safely

This ensures the migration works for both existing installations
(incremental migration) and fresh installations (complete_setup.sql)
without data loss or conflicts.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: enforce NOT NULL constraint on priority column

Data integrity improvements:

Migration (add_priority_column_to_tasks.sql):
- Add column as nullable first with DEFAULT 'medium'
- Update any NULL values to 'medium'
- Set NOT NULL constraint to enforce application invariants
- Safe handling for existing columns with proper constraint checking

Complete Setup (complete_setup.sql):
- Priority column now DEFAULT 'medium' NOT NULL for fresh installations
- Ensures consistency between migration and fresh install paths

Both paths now enforce priority field as required, matching the
frontend Task interface where priority is a required field.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: add priority support to task creation API

Complete priority support for task creation:

API Routes (projects_api.py):
- Add priority field to CreateTaskRequest Pydantic model
- Pass request.priority to TaskService.create_task call

Task Service (task_service.py):
- Add priority parameter to create_task method signature
- Add priority validation using existing validate_priority method
- Include priority field in database INSERT task_data
- Include priority field in API response task object

This ensures that new tasks created via TaskEditModal respect the
user's selected priority instead of falling back to database default.

Validation ensures only valid priority values (low, medium, high, critical)
are accepted and stored in the database.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* feat: implement clean slate priority migration (no backward compatibility)

Remove all task_order to priority mapping logic for true decoupling:

- All existing tasks get 'medium' priority (clean slate)
- No complex CASE logic or task_order relationships
- Users explicitly set priorities as needed after migration
- Truly independent priority and visual ordering systems
- Simpler, safer migration with no coupling logic

This approach prioritizes clean architecture over preserving
implied user intentions from the old coupled system.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: rename TaskPriority.tsx to TaskPriorityComponent.tsx for consistency

- Renamed file to match the exported component name
- Updated import in index.ts barrel export
- Maintains consistency with other component naming patterns

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Rasmus Widing <rasmus.widing@gmail.com>
2025-09-17 13:44:25 +03:00

231 lines
7.6 KiB
TypeScript

import { Tag } from "lucide-react";
import type React from "react";
import { useCallback } from "react";
import { useDrag, useDrop } from "react-dnd";
import { useTaskActions } from "../hooks";
import type { Assignee, Task, TaskPriority } from "../types";
import { getOrderColor, getOrderGlow, ItemTypes } from "../utils/task-styles";
import { TaskAssignee } from "./TaskAssignee";
import { TaskCardActions } from "./TaskCardActions";
import { TaskPriorityComponent } from ".";
export interface TaskCardProps {
task: Task;
index: number;
projectId: string; // Need this for mutations
onTaskReorder: (taskId: string, targetIndex: number, status: Task["status"]) => void;
onEdit?: (task: Task) => void; // Optional edit handler
onDelete?: (task: Task) => void; // Optional delete handler
hoveredTaskId?: string | null;
onTaskHover?: (taskId: string | null) => void;
selectedTasks?: Set<string>;
onTaskSelect?: (taskId: string) => void;
}
export const TaskCard: React.FC<TaskCardProps> = ({
task,
index,
projectId,
onTaskReorder,
onEdit,
onDelete,
hoveredTaskId,
onTaskHover,
selectedTasks,
onTaskSelect,
}) => {
// Use business logic hook with changePriority
const { changeAssignee, changePriority, isUpdating } = useTaskActions(projectId);
// Handlers - now just call hook methods
const handleEdit = useCallback(() => {
// Call the onEdit prop if provided, otherwise log
if (onEdit) {
onEdit(task);
} else {
// Edit task - no handler provided
}
}, [onEdit, task]);
const handleDelete = useCallback(() => {
if (onDelete) {
onDelete(task);
} else {
// Delete task - no handler provided
}
}, [onDelete, task]);
const handlePriorityChange = useCallback(
(priority: TaskPriority) => {
changePriority(task.id, priority);
},
[changePriority, task.id],
);
const handleAssigneeChange = useCallback(
(newAssignee: Assignee) => {
changeAssignee(task.id, newAssignee);
},
[changeAssignee, task.id],
);
const [{ isDragging }, drag] = useDrag({
type: ItemTypes.TASK,
item: { id: task.id, status: task.status, index },
collect: (monitor) => ({
isDragging: !!monitor.isDragging(),
}),
});
const [, drop] = useDrop({
accept: ItemTypes.TASK,
hover: (draggedItem: { id: string; status: Task["status"]; index: number }, monitor) => {
if (!monitor.isOver({ shallow: true })) return;
if (draggedItem.id === task.id) return;
if (draggedItem.status !== task.status) return;
const draggedIndex = draggedItem.index;
const hoveredIndex = index;
if (draggedIndex === hoveredIndex) return;
// Move the task immediately for visual feedback
onTaskReorder(draggedItem.id, hoveredIndex, task.status);
// Update the dragged item's index to prevent re-triggering
draggedItem.index = hoveredIndex;
},
});
const isHighlighted = hoveredTaskId === task.id;
const isSelected = selectedTasks?.has(task.id) || false;
const handleMouseEnter = () => {
onTaskHover?.(task.id);
};
const handleMouseLeave = () => {
onTaskHover?.(null);
};
const handleTaskClick = (e: React.MouseEvent) => {
if (e.ctrlKey || e.metaKey) {
e.stopPropagation();
onTaskSelect?.(task.id);
}
};
// Glassmorphism styling constants
const cardBaseStyles =
"bg-gradient-to-b from-white/80 to-white/60 dark:from-white/10 dark:to-black/30 border border-gray-200 dark:border-gray-700 rounded-lg backdrop-blur-md";
const transitionStyles = "transition-all duration-200 ease-in-out";
// Subtle highlight effect for related tasks
const highlightGlow = isHighlighted ? "border-cyan-400/50 shadow-[0_0_8px_rgba(34,211,238,0.2)]" : "";
// Selection styling with glassmorphism
const selectionGlow = isSelected
? "border-blue-500 shadow-[0_0_12px_rgba(59,130,246,0.4)] bg-blue-50/30 dark:bg-blue-900/20"
: "";
// Beautiful hover effect with glowing borders
const hoverEffectClasses =
"group-hover:border-cyan-400/70 dark:group-hover:border-cyan-500/50 group-hover:shadow-[0_0_15px_rgba(34,211,238,0.4)] dark:group-hover:shadow-[0_0_15px_rgba(34,211,238,0.6)]";
return (
// biome-ignore lint/a11y/useSemanticElements: Drag-and-drop card with react-dnd - requires div for drag handle
<div
ref={(node) => drag(drop(node))}
role="button"
tabIndex={0}
className={`w-full min-h-[140px] cursor-move relative ${isDragging ? "opacity-50 scale-90" : "scale-100 opacity-100"} ${transitionStyles} group`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={handleTaskClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (onEdit) {
onEdit(task);
}
}
}}
>
<div
className={`${cardBaseStyles} ${transitionStyles} ${hoverEffectClasses} ${highlightGlow} ${selectionGlow} w-full min-h-[140px] h-full`}
>
{/* Priority indicator with beautiful glow */}
<div
className={`absolute left-0 top-0 bottom-0 w-[3px] ${getOrderColor(task.task_order)} ${getOrderGlow(task.task_order)} rounded-l-lg opacity-80 group-hover:w-[4px] group-hover:opacity-100 transition-all duration-300`}
/>
{/* Content container with fixed padding */}
<div className="flex flex-col h-full p-3">
{/* Header with feature and actions */}
<div className="flex items-center gap-2 mb-2 pl-1.5">
{task.feature && (
<div
className="px-2 py-1 rounded-md text-xs font-medium flex items-center gap-1 backdrop-blur-md"
style={{
backgroundColor: `${task.featureColor}20`,
color: task.featureColor,
boxShadow: `0 0 10px ${task.featureColor}20`,
}}
>
<Tag className="w-3 h-3" />
{task.feature}
</div>
)}
{/* Action buttons group */}
<div className="ml-auto flex items-center gap-1.5">
<TaskCardActions
taskId={task.id}
taskTitle={task.title}
onEdit={handleEdit}
onDelete={handleDelete}
isDeleting={false}
/>
</div>
</div>
{/* Title */}
<h4
className="text-xs font-medium text-gray-900 dark:text-white mb-2 pl-1.5 line-clamp-2 overflow-hidden"
title={task.title}
>
{task.title}
</h4>
{/* Description - visible when task has description */}
{task.description && (
<div className="pl-1.5 pr-3 mb-2 flex-1">
<p
className="text-xs text-gray-600 dark:text-gray-400 line-clamp-3 break-words whitespace-pre-wrap opacity-75"
style={{ fontSize: "11px" }}
>
{task.description}
</p>
</div>
)}
{/* Spacer when no description */}
{!task.description && <div className="flex-1"></div>}
{/* Footer with assignee - glassmorphism styling */}
<div className="flex items-center justify-between mt-auto pt-2 pl-1.5 pr-3">
<TaskAssignee assignee={task.assignee} onAssigneeChange={handleAssigneeChange} isLoading={isUpdating} />
{/* Priority display connected to database */}
<TaskPriorityComponent
priority={task.priority}
onPriorityChange={handlePriorityChange}
isLoading={isUpdating}
/>
</div>
</div>
</div>
</div>
);
};