Scaffold subtasks

This commit is contained in:
Chris Veleris 2025-07-16 13:49:07 +03:00 committed by Chris
parent 35ac0d7852
commit aab58f9718
8 changed files with 1035 additions and 33 deletions

View file

@ -0,0 +1,24 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up (queryInterface, Sequelize) {
await queryInterface.addColumn('tasks', 'parent_task_id', {
type: Sequelize.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id'
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE'
});
await queryInterface.addIndex('tasks', ['parent_task_id']);
},
async down (queryInterface) {
await queryInterface.removeIndex('tasks', ['parent_task_id']);
await queryInterface.removeColumn('tasks', 'parent_task_id');
}
};

View file

@ -124,6 +124,14 @@ module.exports = (sequelize) => {
key: 'id',
},
},
parent_task_id: {
type: DataTypes.INTEGER,
allowNull: true,
references: {
model: 'tasks',
key: 'id',
},
},
completed_at: {
type: DataTypes.DATE,
allowNull: true,
@ -144,6 +152,9 @@ module.exports = (sequelize) => {
{
fields: ['last_generated_date'],
},
{
fields: ['parent_task_id'],
},
],
}
);
@ -160,6 +171,17 @@ module.exports = (sequelize) => {
as: 'RecurringChildren',
foreignKey: 'recurring_parent_id',
});
// Self-referencing association for subtasks
Task.belongsTo(models.Task, {
as: 'ParentTask',
foreignKey: 'parent_task_id',
});
Task.hasMany(models.Task, {
as: 'Subtasks',
foreignKey: 'parent_task_id',
});
};
// Define enum constants

View file

@ -608,6 +608,36 @@ router.get('/task/:id', async (req, res) => {
}
});
// GET /api/task/:id/subtasks
router.get('/task/:id/subtasks', async (req, res) => {
try {
const subtasks = await Task.findAll({
where: {
parent_task_id: req.params.id,
user_id: req.currentUser.id,
},
include: [
{
model: Tag,
attributes: ['id', 'name'],
through: { attributes: [] },
},
{ model: Project, attributes: ['name'], required: false },
],
order: [['created_at', 'ASC']],
});
const serializedSubtasks = await Promise.all(
subtasks.map((subtask) => serializeTask(subtask))
);
res.json(serializedSubtasks);
} catch (error) {
console.error('Error fetching subtasks:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// POST /api/task
router.post('/task', async (req, res) => {
try {
@ -618,8 +648,10 @@ router.post('/task', async (req, res) => {
status,
note,
project_id,
parent_task_id,
tags,
Tags,
subtasks,
today,
recurrence_type,
recurrence_interval,
@ -683,9 +715,38 @@ router.post('/task', async (req, res) => {
taskAttributes.project_id = project_id;
}
// Handle parent task assignment
if (parent_task_id && parent_task_id.toString().trim()) {
const parentTask = await Task.findOne({
where: { id: parent_task_id, user_id: req.currentUser.id },
});
if (!parentTask) {
return res.status(400).json({ error: 'Invalid parent task.' });
}
taskAttributes.parent_task_id = parent_task_id;
}
const task = await Task.create(taskAttributes);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Handle subtasks creation
if (subtasks && Array.isArray(subtasks)) {
const subtaskPromises = subtasks
.filter(subtask => subtask.name && subtask.name.trim())
.map(subtask => Task.create({
name: subtask.name.trim(),
parent_task_id: task.id,
user_id: req.currentUser.id,
priority: Task.PRIORITY.MEDIUM,
status: Task.STATUS.NOT_STARTED,
today: false,
recurrence_type: 'none',
completion_based: false,
}));
await Promise.all(subtaskPromises);
}
// Log task creation event
try {
await TaskEventService.logTaskCreated(
@ -747,8 +808,10 @@ router.patch('/task/:id', async (req, res) => {
note,
due_date,
project_id,
parent_task_id,
tags,
Tags,
subtasks,
today,
recurrence_type,
recurrence_interval,
@ -927,9 +990,87 @@ router.patch('/task/:id', async (req, res) => {
taskAttributes.project_id = null;
}
// Handle parent task assignment
if (parent_task_id && parent_task_id.toString().trim()) {
const parentTask = await Task.findOne({
where: { id: parent_task_id, user_id: req.currentUser.id },
});
if (!parentTask) {
return res.status(400).json({ error: 'Invalid parent task.' });
}
taskAttributes.parent_task_id = parent_task_id;
} else if (parent_task_id === null || parent_task_id === '') {
taskAttributes.parent_task_id = null;
}
await task.update(taskAttributes);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Handle subtasks updates
if (subtasks && Array.isArray(subtasks)) {
// Delete existing subtasks that are not in the new list
const existingSubtasks = await Task.findAll({
where: { parent_task_id: task.id, user_id: req.currentUser.id },
});
const subtasksToKeep = subtasks.filter((s) => s.id && !s.isNew);
const subtasksToDelete = existingSubtasks.filter(
(existing) =>
!subtasksToKeep.find((keep) => keep.id === existing.id)
);
// Delete removed subtasks
if (subtasksToDelete.length > 0) {
await Task.destroy({
where: {
id: subtasksToDelete.map((s) => s.id),
user_id: req.currentUser.id,
},
});
}
// Update edited subtasks
const editedSubtasks = subtasks.filter(
(s) => s.isEdited && s.id && s.name && s.name.trim()
);
if (editedSubtasks.length > 0) {
const updatePromises = editedSubtasks.map((subtask) =>
Task.update(
{ name: subtask.name.trim() },
{
where: {
id: subtask.id,
user_id: req.currentUser.id,
},
}
)
);
await Promise.all(updatePromises);
}
// Create new subtasks
const newSubtasks = subtasks.filter(
(s) => s.isNew && s.name && s.name.trim()
);
if (newSubtasks.length > 0) {
const subtaskPromises = newSubtasks.map((subtask) =>
Task.create({
name: subtask.name.trim(),
parent_task_id: task.id,
user_id: req.currentUser.id,
priority: Task.PRIORITY.MEDIUM,
status: Task.STATUS.NOT_STARTED,
today: false,
recurrence_type: 'none',
completion_based: false,
})
);
await Promise.all(subtaskPromises);
}
}
// Log task update events
try {
const changes = {};

View file

@ -0,0 +1,240 @@
import React, { useState, useEffect, useRef } from 'react';
import { Task } from '../../../entities/Task';
import { PlusIcon, TrashIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import { fetchSubtasks } from '../../../utils/tasksService';
interface SubtaskData {
id?: number;
name: string;
isNew?: boolean;
isEdited?: boolean;
}
interface TaskSubtasksSectionProps {
parentTaskId: number;
subtasks: SubtaskData[];
onSubtasksChange: (subtasks: SubtaskData[]) => void;
onSectionMount?: () => void;
}
const TaskSubtasksSection: React.FC<TaskSubtasksSectionProps> = ({
parentTaskId,
subtasks,
onSubtasksChange,
onSectionMount,
}) => {
const [newSubtaskName, setNewSubtaskName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editingName, setEditingName] = useState('');
const { t } = useTranslation();
const subtasksSectionRef = useRef<HTMLDivElement>(null);
const addInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (parentTaskId && subtasks.length === 0) {
loadExistingSubtasks();
}
}, [parentTaskId, subtasks.length]);
useEffect(() => {
// Scroll to bottom when section is mounted (opened)
if (onSectionMount) {
scrollToBottom();
onSectionMount();
}
}, [onSectionMount]);
const loadExistingSubtasks = async () => {
try {
setIsLoading(true);
const existingSubtasks = await fetchSubtasks(parentTaskId);
const subtaskData = existingSubtasks.map(task => ({
id: task.id,
name: task.name,
isNew: false,
}));
onSubtasksChange(subtaskData);
} catch (error) {
// Handle silently or show error if needed
} finally {
setIsLoading(false);
}
};
const scrollToBottom = () => {
setTimeout(() => {
if (subtasksSectionRef.current) {
subtasksSectionRef.current.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}, 100);
};
const handleCreateSubtask = () => {
if (!newSubtaskName.trim()) return;
const newSubtask: SubtaskData = {
name: newSubtaskName.trim(),
isNew: true,
};
onSubtasksChange([...subtasks, newSubtask]);
setNewSubtaskName('');
// Scroll to bottom after adding new subtask
scrollToBottom();
};
const handleDeleteSubtask = (index: number) => {
const updatedSubtasks = subtasks.filter((_, i) => i !== index);
onSubtasksChange(updatedSubtasks);
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateSubtask();
}
};
const handleEditSubtask = (index: number) => {
setEditingIndex(index);
setEditingName(subtasks[index].name);
};
const handleSaveEdit = () => {
if (!editingName.trim() || editingIndex === null) return;
const updatedSubtasks = subtasks.map((subtask, index) => {
if (index === editingIndex) {
const isNameChanged = subtask.name !== editingName.trim();
return {
...subtask,
name: editingName.trim(),
isNew: subtask.isNew || false,
isEdited: !subtask.isNew && isNameChanged, // Mark as edited if it's existing and name changed
};
}
return subtask;
});
onSubtasksChange(updatedSubtasks);
setEditingIndex(null);
setEditingName('');
};
const handleCancelEdit = () => {
setEditingIndex(null);
setEditingName('');
};
const handleEditKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSaveEdit();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancelEdit();
}
};
return (
<div ref={subtasksSectionRef} className="space-y-3">
{/* Existing Subtasks */}
{isLoading ? (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('loading.subtasks', 'Loading subtasks...')}
</div>
) : subtasks.length > 0 ? (
<div className="space-y-2">
{subtasks.map((subtask, index) => (
<div
key={subtask.id || index}
className="flex items-center justify-between p-2 bg-gray-50 dark:bg-gray-700 rounded-md"
>
{editingIndex === index ? (
<div className="flex-1 flex items-center space-x-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
onKeyPress={handleEditKeyPress}
onBlur={handleSaveEdit}
className="flex-1 px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-600 dark:text-white"
autoFocus
/>
<button
type="button"
onClick={handleCancelEdit}
className="p-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-400"
title={t('actions.cancel', 'Cancel')}
>
×
</button>
</div>
) : (
<span
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer flex-1 hover:text-blue-600 dark:hover:text-blue-400"
onClick={() => handleEditSubtask(index)}
title={t('actions.clickToEdit', 'Click to edit')}
>
{subtask.name}
{subtask.isNew && (
<span className="ml-2 text-xs text-blue-500 dark:text-blue-400">
(new)
</span>
)}
{subtask.isEdited && (
<span className="ml-2 text-xs text-orange-500 dark:text-orange-400">
(edited)
</span>
)}
</span>
)}
<button
type="button"
onClick={() => handleDeleteSubtask(index)}
className="p-1 text-red-500 hover:text-red-700 dark:hover:text-red-400"
title={t('actions.delete', 'Delete')}
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks yet')}
</div>
)}
{/* Add New Subtask */}
<div className="flex items-center space-x-2">
<input
ref={addInputRef}
type="text"
value={newSubtaskName}
onChange={(e) => setNewSubtaskName(e.target.value)}
onKeyPress={handleKeyPress}
placeholder={t('subtasks.placeholder', 'Add a subtask...')}
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
/>
<button
type="button"
onClick={handleCreateSubtask}
disabled={!newSubtaskName.trim()}
className="p-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed"
title={t('actions.add', 'Add')}
>
<PlusIcon className="h-4 w-4" />
</button>
</div>
</div>
);
};
export default TaskSubtasksSection;

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
CalendarDaysIcon,
@ -6,12 +6,14 @@ import {
PlayIcon,
ArrowPathIcon,
ArrowRightIcon,
Squares2X2Icon,
} from '@heroicons/react/24/outline';
import { TagIcon, FolderIcon } from '@heroicons/react/24/solid';
import { useTranslation } from 'react-i18next';
import TaskPriorityIcon from './TaskPriorityIcon';
import { Project } from '../../entities/Project';
import { Task, StatusType } from '../../entities/Task';
import { fetchSubtasks } from '../../utils/tasksService';
interface TaskHeaderProps {
task: Task;
@ -22,6 +24,10 @@ interface TaskHeaderProps {
onToggleToday?: (taskId: number) => Promise<void>;
onTaskUpdate?: (task: Task) => Promise<void>;
isOverdue?: boolean;
// Props for subtasks functionality
showSubtasks?: boolean;
hasSubtasks?: boolean;
onSubtasksToggle?: (e: React.MouseEvent) => void;
}
const TaskHeader: React.FC<TaskHeaderProps> = ({
@ -33,9 +39,14 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
onToggleToday,
onTaskUpdate,
isOverdue = false,
// Props for subtasks functionality
showSubtasks,
hasSubtasks,
onSubtasksToggle,
}) => {
const { t } = useTranslation();
const formatDueDate = (dueDate: string) => {
const today = new Date().toISOString().split('T')[0];
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000)
@ -75,6 +86,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}
};
const handleTodayToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal
if (onToggleToday && task.id) {
@ -273,6 +285,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<PlayIcon className="h-3 w-3" />
</button>
)}
{/* Show Subtasks Controls */}
{hasSubtasks && (
<button
onClick={(e) => {
console.log('Subtasks button clicked', e);
if (onSubtasksToggle) {
onSubtasksToggle(e);
}
}}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
showSubtasks
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`}
title={
showSubtasks
? t('tasks.hideSubtasks', 'Hide subtasks')
: t('tasks.showSubtasks', 'Show subtasks')
}
>
<Squares2X2Icon className="h-3 w-3" />
</button>
)}
</div>
</div>
@ -420,6 +456,30 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<PlayIcon className="h-3 w-3" />
</button>
)}
{/* Show Subtasks Controls - Mobile */}
{hasSubtasks && (
<button
onClick={(e) => {
console.log('Subtasks button clicked (mobile)', e);
if (onSubtasksToggle) {
onSubtasksToggle(e);
}
}}
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
showSubtasks
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400 hover:bg-blue-200 dark:hover:bg-blue-800'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
}`}
title={
showSubtasks
? t('tasks.hideSubtasks', 'Hide subtasks')
: t('tasks.showSubtasks', 'Show subtasks')
}
>
<Squares2X2Icon className="h-3 w-3" />
</button>
)}
</div>
</div>
</div>
@ -427,4 +487,163 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
);
};
// 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-2 cursor-pointer transition-all duration-200 ${
subtask.status === 'in_progress' || subtask.status === 1
? 'border-green-400/60 dark:border-green-500/60'
: 'border-gray-50 dark:border-gray-800'
}`}
onClick={(e) => {
e.stopPropagation();
onTaskClick(e, subtask);
}}
>
<div className="px-3 py-1.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 flex-1 truncate ${
subtask.status === 'done' || subtask.status === 2
? 'text-gray-500 dark:text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'
}`}>
{subtask.name}
</span>
</div>
{/* Right side - Status indicator */}
<div className="flex items-center space-x-1">
{subtask.status === 'done' || subtask.status === 2 ? (
<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 [hasSubtasks, setHasSubtasks] = useState(false);
// Check if task has subtasks on mount
useEffect(() => {
const checkSubtasks = async () => {
if (!props.task.id) return;
console.log('Checking subtasks for task:', props.task.id, props.task.name);
try {
const subtasksData = await fetchSubtasks(props.task.id);
console.log('Subtasks data:', subtasksData);
setHasSubtasks(subtasksData.length > 0);
} catch (error) {
console.error('Error fetching subtasks:', error);
setHasSubtasks(false);
}
};
checkSubtasks();
}, [props.task.id]);
const loadSubtasks = async () => {
if (!props.task.id) return;
setLoadingSubtasks(true);
try {
const subtasksData = await fetchSubtasks(props.task.id);
setSubtasks(subtasksData);
} catch (error) {
console.error('Failed to load subtasks:', error);
setSubtasks([]);
} finally {
setLoadingSubtasks(false);
}
};
const handleSubtasksToggle = async (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent opening task modal
if (!showSubtasks && subtasks.length === 0) {
await loadSubtasks();
}
setShowSubtasks(!showSubtasks);
};
return (
<>
<TaskHeader
{...props}
// Pass the subtasks state to the header
showSubtasks={showSubtasks}
hasSubtasks={hasSubtasks}
onSubtasksToggle={handleSubtasksToggle}
/>
<SubtasksDisplay
showSubtasks={showSubtasks}
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e, task) => {
e.stopPropagation();
if (props.onSubtaskClick) {
props.onSubtaskClick(task);
}
}}
/>
</>
);
};
export { TaskWithSubtasks };
export default TaskHeader;

View file

@ -1,10 +1,137 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import TaskHeader from './TaskHeader';
// Import SubtasksDisplay component from TaskHeader
interface SubtasksDisplayProps {
showSubtasks: boolean;
loadingSubtasks: boolean;
subtasks: Task[];
onTaskClick: (e: React.MouseEvent, task: Task) => void;
onTaskUpdate: (task: Task) => Promise<void>;
loadSubtasks: () => Promise<void>;
}
const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
showSubtasks,
loadingSubtasks,
subtasks,
onTaskClick,
onTaskUpdate,
loadSubtasks,
}) => {
const { t } = useTranslation();
if (!showSubtasks) return null;
return (
<div className="mt-1 space-y-1">
{loadingSubtasks ? (
<div className="ml-12 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-12 group"
>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 cursor-pointer transition-all duration-200 ${
subtask.status === 'in_progress' || subtask.status === 1
? 'border-green-400/60 dark:border-green-500/60'
: 'border-gray-50 dark:border-gray-800'
}`}
onClick={(e) => {
e.stopPropagation();
onTaskClick(e, subtask);
}}
>
<div className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{subtask.status === 'done' || subtask.status === 2 ? (
<div
className="h-5 w-5 cursor-pointer hover:scale-110 transition-transform text-green-500 flex items-center justify-center"
style={{ width: '16px', height: '16px' }}
onClick={async (e) => {
e.stopPropagation();
if (subtask.id) {
try {
const updatedSubtask = await toggleTaskCompletion(subtask.id);
await onTaskUpdate(updatedSubtask);
// Refresh subtasks to show updated status
await loadSubtasks();
} catch (error) {
console.error('Error toggling subtask completion:', error);
}
}
}}
>
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
) : (
<div
className={`h-5 w-5 cursor-pointer hover:scale-110 transition-transform border-2 border-current rounded-full flex-shrink-0 ${
subtask.priority === 'high'
? 'text-red-500'
: subtask.priority === 'medium'
? 'text-yellow-500'
: 'text-gray-300'
}`}
style={{ width: '16px', height: '16px' }}
onClick={async (e) => {
e.stopPropagation();
if (subtask.id) {
try {
const updatedSubtask = await toggleTaskCompletion(subtask.id);
await onTaskUpdate(updatedSubtask);
// Refresh subtasks to show updated status
await loadSubtasks();
} catch (error) {
console.error('Error toggling subtask completion:', error);
}
}
}}
/>
)}
<span className={`text-base flex-1 truncate ${
subtask.status === 'done' || subtask.status === 2
? 'text-gray-500 dark:text-gray-400 line-through'
: 'text-gray-900 dark:text-gray-100'
}`}>
{subtask.name}
</span>
</div>
<div className="flex items-center space-x-1">
{subtask.status === 'done' || subtask.status === 2 ? (
<span className="text-sm text-green-600 dark:text-green-400 font-medium">
</span>
) : subtask.status === 'in_progress' || subtask.status === 1 ? (
<span className="text-sm text-blue-600 dark:text-blue-400 font-medium">
</span>
) : null}
</div>
</div>
</div>
</div>
))
) : (
<div className="ml-12 text-sm text-gray-500 dark:text-gray-400">
{t('subtasks.noSubtasks', 'No subtasks found')}
</div>
)}
</div>
);
};
import TaskModal from './TaskModal';
import { toggleTaskCompletion } from '../../utils/tasksService';
import { toggleTaskCompletion, fetchSubtasks } from '../../utils/tasksService';
import { isTaskOverdue } from '../../utils/dateUtils';
import { useTranslation } from 'react-i18next';
interface TaskItemProps {
task: Task;
@ -24,14 +151,99 @@ const TaskItem: React.FC<TaskItemProps> = ({
onToggleToday,
}) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [subtaskModalOpen, setSubtaskModalOpen] = useState(false);
const [selectedSubtask, setSelectedSubtask] = useState<Task | null>(null);
const [projectList, setProjectList] = useState<Project[]>(projects);
// Subtasks state
const [showSubtasks, setShowSubtasks] = useState(false);
const [subtasks, setSubtasks] = useState<Task[]>([]);
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
const [hasSubtasks, setHasSubtasks] = useState(false);
// Calculate completion percentage
const calculateCompletionPercentage = () => {
if (subtasks.length === 0) return 0;
const completedCount = subtasks.filter(subtask =>
subtask.status === 'done' || subtask.status === 2
).length;
return Math.round((completedCount / subtasks.length) * 100);
};
const completionPercentage = calculateCompletionPercentage();
// Dispatch global modal events
// Check if task has subtasks on mount
useEffect(() => {
const checkSubtasks = async () => {
if (!task.id) return;
try {
const subtasksData = await fetchSubtasks(task.id);
setHasSubtasks(subtasksData.length > 0);
} catch (error) {
console.error('Error fetching subtasks:', error);
setHasSubtasks(false);
}
};
checkSubtasks();
}, [task.id]);
const loadSubtasks = async () => {
if (!task.id) return;
setLoadingSubtasks(true);
try {
const subtasksData = await fetchSubtasks(task.id);
setSubtasks(subtasksData);
} catch (error) {
console.error('Failed to load subtasks:', error);
setSubtasks([]);
} finally {
setLoadingSubtasks(false);
}
};
// Reload subtasks when showSubtasks changes to true
useEffect(() => {
if (showSubtasks && subtasks.length === 0) {
loadSubtasks();
}
}, [showSubtasks, subtasks.length]);
const handleSubtasksToggle = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!showSubtasks && subtasks.length === 0) {
await loadSubtasks();
}
setShowSubtasks(!showSubtasks);
};
const handleTaskClick = () => {
setIsModalOpen(true);
};
const handleSubtaskClick = (subtask: Task) => {
setSelectedSubtask(subtask);
setSubtaskModalOpen(true);
};
const handleSubtaskSave = async (updatedSubtask: Task) => {
await onTaskUpdate(updatedSubtask);
setSubtaskModalOpen(false);
setSelectedSubtask(null);
};
const handleSubtaskDelete = async () => {
if (selectedSubtask && selectedSubtask.id) {
await onTaskDelete(selectedSubtask.id);
setSubtaskModalOpen(false);
setSelectedSubtask(null);
}
};
const handleSave = async (updatedTask: Task) => {
await onTaskUpdate(updatedTask);
setIsModalOpen(false);
@ -93,22 +305,53 @@ const TaskItem: React.FC<TaskItemProps> = ({
const isOverdue = isTaskOverdue(task);
return (
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 transition-all duration-200 ease-in-out ${
isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60'
: 'border-2 border-gray-50 dark:border-gray-800'
}`}
>
<TaskHeader
task={task}
project={project}
onTaskClick={handleTaskClick}
onToggleCompletion={handleToggleCompletion}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
<>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 relative overflow-hidden transition-all duration-200 ease-in-out ${
isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60'
: 'border-2 border-gray-50 dark:border-gray-800'
}`}
>
<TaskHeader
task={task}
project={project}
onTaskClick={handleTaskClick}
onToggleCompletion={handleToggleCompletion}
hideProjectName={hideProjectName}
onToggleToday={onToggleToday}
onTaskUpdate={onTaskUpdate}
isOverdue={isOverdue}
showSubtasks={showSubtasks}
hasSubtasks={hasSubtasks}
onSubtasksToggle={handleSubtasksToggle}
/>
{/* Progress bar at bottom of parent task */}
{subtasks.length > 0 && (
<div className={`absolute bottom-0 left-0 right-0 h-1 transition-all duration-300 ease-in-out ${
showSubtasks ? 'opacity-100 transform translate-y-0' : 'opacity-0 transform translate-y-2'
}`}>
<div className="w-full h-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full bg-gradient-to-r from-green-400 via-green-500 to-green-600 transition-all duration-500 ease-out"
style={{ width: `${completionPercentage}%` }}
/>
</div>
</div>
)}
</div>
<SubtasksDisplay
showSubtasks={showSubtasks}
loadingSubtasks={loadingSubtasks}
subtasks={subtasks}
onTaskClick={(e, task) => {
e.stopPropagation();
handleSubtaskClick(task);
}}
onTaskUpdate={onTaskUpdate}
isOverdue={isOverdue}
loadSubtasks={loadSubtasks}
/>
<TaskModal
@ -120,7 +363,22 @@ const TaskItem: React.FC<TaskItemProps> = ({
projects={projectList}
onCreateProject={handleCreateProject}
/>
</div>
{selectedSubtask && (
<TaskModal
isOpen={subtaskModalOpen}
onClose={() => {
setSubtaskModalOpen(false);
setSelectedSubtask(null);
}}
task={selectedSubtask}
onSave={handleSubtaskSave}
onDelete={handleSubtaskDelete}
projects={projectList}
onCreateProject={handleCreateProject}
/>
)}
</>
);
};

View file

@ -6,7 +6,7 @@ import { useToast } from '../Shared/ToastContext';
import TimelinePanel from './TimelinePanel';
import { Project } from '../../entities/Project';
import { useStore } from '../../store/useStore';
import { fetchTaskById } from '../../utils/tasksService';
import { fetchTaskById, fetchSubtasks } from '../../utils/tasksService';
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
import {
analyzeTaskName,
@ -20,6 +20,7 @@ import {
Cog6ToothIcon,
ArrowPathIcon,
TrashIcon,
Squares2X2Icon,
} from '@heroicons/react/24/outline';
// Import form sections
@ -29,6 +30,7 @@ import TaskTagsSection from './TaskForm/TaskTagsSection';
import TaskProjectSection from './TaskForm/TaskProjectSection';
import TaskMetadataSection from './TaskForm/TaskMetadataSection';
import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
import TaskSubtasksSection from './TaskForm/TaskSubtasksSection';
interface TaskModalProps {
isOpen: boolean;
@ -73,6 +75,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] =
useState(true);
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
const [subtasks, setSubtasks] = useState<Array<{id?: number; name: string; isNew?: boolean}>>([]);
// Collapsible section states
const [expandedSections, setExpandedSections] = useState({
@ -80,11 +83,24 @@ const TaskModal: React.FC<TaskModalProps> = ({
project: false,
metadata: false,
recurrence: false,
subtasks: false,
});
const { showSuccessToast, showErrorToast } = useToast();
const { t } = useTranslation();
const scrollToSubtasksSection = () => {
setTimeout(() => {
const subtasksSection = document.querySelector('[data-section="subtasks"]');
if (subtasksSection) {
subtasksSection.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}, 300); // Give time for section to expand
};
const toggleSection = useCallback(
(section: keyof typeof expandedSections) => {
setExpandedSections((prev) => {
@ -95,17 +111,22 @@ const TaskModal: React.FC<TaskModalProps> = ({
// Auto-scroll to show the expanded section
if (newExpanded[section]) {
setTimeout(() => {
const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
});
}
}, 100); // Small delay to ensure DOM is updated
// Special handling for subtasks section
if (section === 'subtasks') {
scrollToSubtasksSection();
} else {
setTimeout(() => {
const scrollContainer = document.querySelector(
'.absolute.inset-0.overflow-y-auto'
);
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: 'smooth',
});
}
}, 100); // Small delay to ensure DOM is updated
}
}
return newExpanded;
@ -278,7 +299,11 @@ const TaskModal: React.FC<TaskModalProps> = ({
};
const handleSubmit = () => {
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
onSave({
...formData,
tags: tags.map((tag) => ({ name: tag })),
subtasks: subtasks
} as any);
const taskLink = (
<span>
{t('task.updated', 'Task')}{' '}
@ -389,6 +414,31 @@ const TaskModal: React.FC<TaskModalProps> = ({
};
}, [isOpen]);
// Load existing subtasks when modal opens
useEffect(() => {
if (isOpen && task.id) {
const loadExistingSubtasks = async () => {
try {
const existingSubtasks = await fetchSubtasks(task.id!);
const subtaskData = existingSubtasks.map(subtask => ({
id: subtask.id,
name: subtask.name,
isNew: false,
}));
setSubtasks(subtaskData);
} catch (error) {
// Handle silently - don't show error for this
setSubtasks([]);
}
};
loadExistingSubtasks();
} else if (!isOpen) {
// Reset subtasks when modal closes
setSubtasks([]);
}
}, [isOpen, task.id]);
if (!isOpen) return null;
return createPortal(
@ -589,6 +639,23 @@ const TaskModal: React.FC<TaskModalProps> = ({
/>
</div>
)}
{expandedSections.subtasks && (
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4 px-4" data-section="subtasks">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
{t(
'forms.task.subtasks',
'Subtasks'
)}
</h3>
<TaskSubtasksSection
parentTaskId={task.id!}
subtasks={subtasks}
onSubtasksChange={setSubtasks}
onSectionMount={scrollToSubtasksSection}
/>
</div>
)}
</fieldset>
</form>
</div>
@ -705,6 +772,27 @@ const TaskModal: React.FC<TaskModalProps> = ({
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
{/* Subtasks Toggle */}
<button
onClick={() =>
toggleSection('subtasks')
}
className={`relative p-2 rounded-full transition-colors ${
expandedSections.subtasks
? 'bg-blue-100 dark:bg-blue-900 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title={t(
'forms.task.subtasks',
'Subtasks'
)}
>
<Squares2X2Icon className="h-5 w-5" />
{subtasks.length > 0 && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full"></span>
)}
</button>
</div>
{/* Right side: Timeline Toggle Button */}

View file

@ -97,6 +97,16 @@ export const fetchTaskByUuid = async (uuid: string): Promise<Task> => {
return await response.json();
};
export const fetchSubtasks = async (parentTaskId: number): Promise<Task[]> => {
const response = await fetch(`/api/task/${parentTaskId}/subtasks`, {
credentials: 'include',
headers: getDefaultHeaders(),
});
await handleAuthResponse(response, 'Failed to fetch subtasks.');
return await response.json();
};
export const toggleTaskToday = async (taskId: number): Promise<Task> => {
const response = await fetch(`/api/task/${taskId}/toggle-today`, {
method: 'PATCH',