tududi/frontend/components/Task/TaskHeader.tsx
Chris a01dd9f41c
Fix subtask icons invisible on Today page (#839) (#935)
The SubtasksToggleButton was placed inside the same flex container as
the task name with truncate, causing it to be squeezed to zero width.
Wrap the task name in an inner flex container (matching the upcoming
view layout) so the button stays visible.
2026-03-10 17:15:06 +02:00

794 lines
40 KiB
TypeScript

import React, { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
CalendarDaysIcon,
CalendarIcon,
ArrowPathIcon,
ListBulletIcon,
ChevronDownIcon,
CheckIcon,
} from '@heroicons/react/24/outline';
import { TagIcon, FolderIcon, FireIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import TaskPriorityIcon from '../Shared/Icons/TaskPriorityIcon';
import { Project } from '../../entities/Project';
import { Task } from '../../entities/Task';
import { fetchSubtasks } from '../../utils/tasksService';
import { isTaskCompleted, isTaskInProgress } from '../../constants/taskStatus';
import TaskStatusControl from './TaskStatusControl';
import { parseDateString, getTodayDateString, getTomorrowDateString, getYesterdayDateString } from '../../utils/dateUtils';
interface TaskHeaderProps {
task: Task;
project?: Project;
onTaskClick: (e: React.MouseEvent) => void;
onToggleCompletion?: () => void;
hideProjectName?: boolean;
onToggleToday?: (taskId: number, task?: Task) => Promise<void>;
onTaskUpdate?: (task: Task) => Promise<void>;
isOverdue?: boolean;
showSubtasks?: boolean;
hasSubtasks?: boolean;
onSubtasksToggle?: (e: React.MouseEvent) => void;
// Props for edit and delete functionality
onEdit?: (e: React.MouseEvent) => void;
onDelete?: (e: React.MouseEvent) => void;
isUpcomingView?: boolean;
onMenuOpenChange?: (isOpen: boolean) => void;
}
const TaskHeader: React.FC<TaskHeaderProps> = ({
task,
project,
onTaskClick,
onToggleCompletion,
hideProjectName = false,
onToggleToday: _onToggleToday,
onTaskUpdate,
showSubtasks,
hasSubtasks,
onSubtasksToggle,
// Props for edit and delete functionality
onEdit: _onEdit,
onDelete: _onDelete,
isUpcomingView = false,
onMenuOpenChange,
}) => {
const { t } = useTranslation();
void _onToggleToday;
void _onEdit;
void _onDelete;
const SubtasksToggleButton = () => {
if (!hasSubtasks || !onSubtasksToggle) return null;
return (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onSubtasksToggle(e);
}}
className={`ml-1 flex items-center justify-center h-5 px-1.5 rounded-full border transition-colors duration-150 gap-0.5 ${
showSubtasks
? 'bg-blue-50 text-blue-600 border-blue-200 dark:bg-blue-900/40 dark:border-blue-700 dark:text-blue-100'
: 'text-gray-400 border-transparent hover:border-gray-200 hover:text-gray-600 dark:hover:border-gray-600'
}`}
aria-pressed={!!showSubtasks}
title={
showSubtasks
? t('tasks.hideSubtasks', 'Hide subtasks')
: t('tasks.showSubtasks', 'Show subtasks')
}
>
<ListBulletIcon className="h-3.5 w-3.5" />
<ChevronDownIcon
className={`h-3 w-3 transition-transform ${
showSubtasks ? 'rotate-180' : ''
}`}
/>
</button>
);
};
const formatDueDate = (dueDate: string) => {
const today = getTodayDateString();
const tomorrow = getTomorrowDateString();
const yesterday = getYesterdayDateString();
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
if (dueDate === tomorrow)
return t('dateIndicators.tomorrow', 'TOMORROW');
if (dueDate === yesterday)
return t('dateIndicators.yesterday', 'YESTERDAY');
const date = parseDateString(dueDate);
if (!date) return dueDate;
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const formatDeferUntil = (deferUntil: string): string | null => {
const date = new Date(deferUntil);
if (Number.isNaN(date.getTime())) {
return null;
}
const datePart = date.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const timePart = date.toLocaleTimeString(undefined, {
hour: 'numeric',
minute: '2-digit',
});
return `${datePart}${timePart}`;
};
const formatRecurrence = (recurrenceType: string) => {
switch (recurrenceType) {
case 'daily':
return t('recurrence.daily', 'Daily');
case 'weekly':
return t('recurrence.weekly', 'Weekly');
case 'monthly':
return t('recurrence.monthly', 'Monthly');
case 'monthly_weekday':
return t('recurrence.monthlyWeekday', 'Monthly');
case 'monthly_last_day':
return t('recurrence.monthlyLastDay', 'Monthly');
default:
return t('recurrence.recurring', 'Recurring');
}
};
const formattedDeferUntil = task.defer_until
? formatDeferUntil(task.defer_until)
: null;
// Check if task has metadata (project, tags, due_date, completed_at, recurrence_type, recurring_parent_id, or defer_until)
const hasMetadata =
(project && !hideProjectName) ||
(task.tags && task.tags.length > 0) ||
task.due_date ||
(isTaskCompleted(task.status) && task.completed_at) ||
(task.recurrence_type && task.recurrence_type !== 'none') ||
task.recurring_parent_id ||
!!formattedDeferUntil;
return (
<div
className={`${hasMetadata ? 'py-2' : 'py-3'} px-4 cursor-pointer group`}
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onTaskClick(e);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onTaskClick(e as any);
}
}}
>
{/* Full view (md and larger) */}
<div className="hidden md:flex flex-col md:flex-row md:items-center md:relative">
<div
className={`flex items-center space-x-3 mb-2 md:mb-0 flex-1 min-w-0 ${!isUpcomingView ? 'pr-44' : ''}`}
>
<div className="hidden">
<TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
testIdSuffix="-desktop"
/>
</div>
<div className="flex flex-col flex-1 min-w-0">
{isUpcomingView ? (
<div className="flex-1 min-w-0">
{/* Full width title that wraps */}
<div className="mb-0.5 flex items-center gap-1.5 min-w-0">
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{task.habit_mode && (
<FireIcon
className="h-4 w-4 text-orange-500 flex-shrink-0"
title="Habit"
/>
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 tracking-tight truncate">
{task.original_name || task.name}
</span>
</div>
<SubtasksToggleButton />
</div>
{/* Show project and tags info in upcoming view */}
{project && !hideProjectName && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
<FolderIcon className="h-3 w-3 mr-1" />
<Link
to={
project.uid
? `/project/${project.uid}-${project.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
: `/project/${project.id}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) => {
// Prevent navigation if we're already on this project's page
if (
window.location.pathname ===
`/project/${project.id}`
) {
e.preventDefault();
}
e.stopPropagation();
}}
>
{project.name}
</Link>
</div>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
<TagIcon className="h-3 w-3 mr-1" />
<span>
{task.tags.map((tag, index) => (
<React.Fragment key={tag.name}>
<Link
to={
tag.uid
? `/tag/${tag.uid}-${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
: `/tag/${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) =>
e.stopPropagation()
}
>
{tag.name}
</Link>
{index <
task.tags!.length - 1 &&
', '}
</React.Fragment>
))}
</span>
</div>
)}
</div>
) : (
<div className="flex items-center gap-1.5 min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{task.habit_mode && (
<FireIcon
className="h-4 w-4 text-orange-500 flex-shrink-0"
title="Habit"
/>
)}
<span className="text-md font-medium text-gray-900 dark:text-gray-300 truncate">
{task.original_name || task.name}
</span>
</div>
<SubtasksToggleButton />
</div>
)}
{/* Project, tags, due date, and recurrence in same row, with spacing when they exist */}
{!isUpcomingView && (
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap overflow-x-auto">
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<Link
to={
project.uid
? `/project/${project.uid}-${project.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
: `/project/${project.id}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) => {
// Prevent navigation if we're already on this project's page
if (
window.location.pathname ===
`/project/${project.id}`
) {
e.preventDefault();
}
e.stopPropagation();
}}
>
{project.name}
</Link>
</div>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
<span>
{task.tags.map((tag, index) => (
<React.Fragment key={tag.name}>
<Link
to={
tag.uid
? `/tag/${tag.uid}-${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
: `/tag/${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) =>
e.stopPropagation()
}
>
{tag.name}
</Link>
{index <
task.tags!.length - 1 &&
', '}
</React.Fragment>
))}
</span>
</div>
)}
{task.due_date && (
<div className="flex items-center whitespace-nowrap">
<CalendarIcon className="h-3 w-3 mr-1" />
<span>
{formatDueDate(task.due_date)}
</span>
</div>
)}
{isTaskCompleted(task.status) &&
task.completed_at && (
<div className="flex items-center whitespace-nowrap">
<CheckIcon className="h-3 w-3 mr-1" />
<span>
{formatDueDate(
task.completed_at.split(
'T'
)[0]
)}
</span>
</div>
)}
{task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div
className="flex items-center"
title={
task.due_date
? `${t('next', 'Next')}: ${formatDueDate(task.due_date)}`
: undefined
}
>
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div>
)}
{task.recurring_parent_id && (
<div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>
{t(
'recurrence.instance',
'Recurring task instance'
)}
</span>
</div>
)}
{formattedDeferUntil && (
<div className="flex items-center">
<CalendarDaysIcon className="h-3 w-3 mr-1" />
<span>{formattedDeferUntil}</span>
</div>
)}
</div>
)}
</div>
</div>
{!isUpcomingView && !task.habit_mode && onToggleCompletion && (
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center">
<TaskStatusControl
task={task}
onToggleCompletion={onToggleCompletion}
onTaskUpdate={onTaskUpdate}
showMobileVariant={false}
className=""
onMenuOpenChange={onMenuOpenChange}
/>
</div>
)}
</div>
{/* Mobile view (below md breakpoint) */}
<div className="block md:hidden">
<div className="flex items-center">
{/* Priority Icon - Centered vertically with entire card */}
<div className="hidden">
<TaskPriorityIcon
priority={task.priority}
status={task.status}
onToggleCompletion={onToggleCompletion}
testIdSuffix="-mobile"
/>
</div>
{/* Task content - full width */}
<div className="ml-3 flex-1 min-w-0">
{/* Task Title */}
<div className="font-medium text-md text-gray-900 dark:text-gray-300">
<span className="inline-flex items-center gap-1.5 w-full min-w-0">
{task.habit_mode && (
<FireIcon
className="h-4 w-4 text-orange-500 flex-shrink-0"
title="Habit"
/>
)}
<span className="truncate flex-1">
{task.original_name || task.name}
</span>
<SubtasksToggleButton />
</span>
</div>
{/* Project, tags, due date, and recurrence */}
<div
className={`flex flex-col text-xs text-gray-500 dark:text-gray-400 space-y-1 ${hasMetadata ? 'mt-1' : 'hidden'}`}
>
{project && !hideProjectName && (
<div className="flex items-center">
<FolderIcon className="h-3 w-3 mr-1" />
<Link
to={
project.uid
? `/project/${project.uid}-${project.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(/^-|-$/g, '')}`
: `/project/${project.id}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) => {
// Prevent navigation if we're already on this project's page
if (
window.location.pathname ===
`/project/${project.id}`
) {
e.preventDefault();
}
e.stopPropagation();
}}
>
{project.name}
</Link>
</div>
)}
{task.tags && task.tags.length > 0 && (
<div className="flex items-center">
<TagIcon className="h-3 w-3 mr-1" />
<span>
{task.tags.map((tag, index) => (
<React.Fragment key={tag.name}>
<Link
to={
tag.uid
? `/tag/${tag.uid}-${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
: `/tag/${tag.name
.toLowerCase()
.replace(
/[^a-z0-9]+/g,
'-'
)
.replace(
/^-|-$/g,
''
)}`
}
className="text-gray-500 dark:text-gray-400 hover:underline transition-colors"
onClick={(e) =>
e.stopPropagation()
}
>
{tag.name}
</Link>
{index <
task.tags!.length - 1 &&
', '}
</React.Fragment>
))}
</span>
</div>
)}
{!isUpcomingView && task.due_date && (
<div className="flex items-center whitespace-nowrap">
<CalendarIcon className="h-3 w-3 mr-1" />
<span>{formatDueDate(task.due_date)}</span>
</div>
)}
{isTaskCompleted(task.status) &&
task.completed_at && (
<div className="flex items-center whitespace-nowrap">
<CheckIcon className="h-3 w-3 mr-1" />
<span>
{formatDueDate(
task.completed_at.split('T')[0]
)}
</span>
</div>
)}
{task.recurrence_type &&
task.recurrence_type !== 'none' && (
<div
className="flex items-center"
title={
task.due_date
? `${t('next', 'Next')}: ${formatDueDate(task.due_date)}`
: undefined
}
>
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>
{formatRecurrence(
task.recurrence_type
)}
</span>
</div>
)}
{task.recurring_parent_id && (
<div className="flex items-center">
<ArrowPathIcon className="h-3 w-3 mr-1" />
<span>
{t(
'recurrence.instance',
'Recurring task instance'
)}
</span>
</div>
)}
{formattedDeferUntil && (
<div className="flex items-center whitespace-nowrap">
<CalendarDaysIcon className="h-3 w-3 mr-1" />
<span>{formattedDeferUntil}</span>
</div>
)}
</div>
{onToggleCompletion && (
<div className="mt-2">
<TaskStatusControl
task={task}
onToggleCompletion={onToggleCompletion}
onTaskUpdate={onTaskUpdate}
hoverRevealQuickActions={false}
showMobileVariant={false}
onMenuOpenChange={onMenuOpenChange}
/>
</div>
)}
</div>
</div>
</div>
</div>
);
};
// Subtasks Display Component
interface SubtasksDisplayProps {
showSubtasks: boolean;
loadingSubtasks: boolean;
subtasks: Task[];
onTaskClick: (e: React.MouseEvent, task: Task) => void;
}
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
showSubtasks,
loadingSubtasks,
subtasks,
onTaskClick,
}) => {
const { t } = useTranslation();
if (!showSubtasks) return null;
return (
<div className="mt-1 space-y-1">
{loadingSubtasks ? (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
) : subtasks.length > 0 ? (
subtasks.map((subtask) => (
<div key={subtask.id} className="ml-[10%] group">
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border cursor-pointer transition-all duration-200 ${
isTaskInProgress(subtask.status)
? 'border-blue-500/60 dark:border-blue-600/60'
: 'border-gray-50 dark:border-gray-800'
}`}
onClick={(e) => {
e.stopPropagation();
onTaskClick(e, subtask);
}}
>
<div className="px-3 py-2.5 flex items-center justify-between">
{/* Left side - Task info */}
<div className="flex items-center space-x-2 flex-1 min-w-0">
<TaskPriorityIcon
priority={subtask.priority}
status={subtask.status}
/>
<span
className={`text-sm truncate min-w-0 ${
isTaskCompleted(subtask.status)
? 'text-gray-500 dark:text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'
}`}
>
{subtask.original_name || subtask.name}
</span>
</div>
{/* Right side - Status indicator */}
<div className="flex items-center space-x-1">
{isTaskCompleted(subtask.status) ? (
<span className="text-xs text-green-600 dark:text-green-400">
</span>
) : null}
</div>
</div>
</div>
</div>
))
) : (
<div className="ml-[10%] text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks found')}
</div>
)}
</div>
);
};
// TaskWithSubtasks Component that combines both
interface TaskWithSubtasksProps extends TaskHeaderProps {
onSubtaskClick?: (subtask: Task) => void;
}
const TaskWithSubtasks: React.FC<TaskWithSubtasksProps> = (props) => {
const [showSubtasks, setShowSubtasks] = useState(false);
const [subtasks, setSubtasks] = useState<Task[]>([]);
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
const loadSubtasks = useCallback(async () => {
if (!props.task.uid) return;
setLoadingSubtasks(true);
try {
const subtasksData = await fetchSubtasks(props.task.uid);
setSubtasks(subtasksData);
setShowSubtasks(subtasksData.length > 0);
} catch (error) {
console.error('Failed to load subtasks:', error);
setSubtasks([]);
setShowSubtasks(false);
} finally {
setLoadingSubtasks(false);
}
}, [props.task.id]);
useEffect(() => {
const subtasksData = props.task.subtasks || [];
const hasSubtasksFromData = subtasksData.length > 0;
setSubtasks(subtasksData);
setShowSubtasks(hasSubtasksFromData);
if (!hasSubtasksFromData) {
void loadSubtasks();
}
}, [props.task.id, props.task.subtasks, loadSubtasks]);
return (
<>
<TaskHeader
{...props}
showSubtasks={showSubtasks}
hasSubtasks={subtasks.length > 0 || loadingSubtasks}
onSubtasksToggle={(e) => {
e.stopPropagation();
setShowSubtasks((prev) => !prev);
}}
/>
<SubtasksDisplay
showSubtasks={showSubtasks}
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e, task) => {
e.stopPropagation();
// Call the parent's onSubtaskClick handler if provided
if (props.onSubtaskClick) {
props.onSubtaskClick(task);
}
}}
/>
</>
);
};
export { TaskWithSubtasks };
export default React.memo(TaskHeader);