Time Management Web App: Grouping Tasks in Projects

Shawn
6 min readJan 8, 2023

--

We finished the first iteration of this tutorial series with a basic time/task management system. It gets the job done, but there’s a lot more that can be done with it.

As the list of tasks grows, eventually it’ll become handy to group them into projects. That way tasks can be organized.

Database Change

Doing this first requires creating the project table:

create table project (id integer primary key auto_increment, name varchar(100), description text);

Then add an association between project and task to the task table:

alter table task add column project integer;

Then we do our backend changes. These will be in the index.js file.

Create the Sequelize model for the projects:

const Project = db.define('project', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: Sequelize.STRING,
description: Sequelize.TEXT
}, {
freezeTableName: true,
timestamps: false
});

And update the Task model:

const Task = db.define('task', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: Sequelize.STRING,
cron_string: Sequelize.STRING,
completed: Sequelize.BOOLEAN,
project: Sequelize.INTEGER
}, {
freezeTableName: true,
timestamps: false
});

Setting Up the API

First make the ProjectFunctions object:

const ProjectFunctions = {
async getAll() {
return Project.findAll();
},

async get(id) {
return Project.findByPk(id);
},

async create(data) {
return Project.create({
name: data.name,
description: data.description
});
},

async update(id, data) {
const project = await Project.findByPk(id);
project.name = data.name || project.name;
project.description = data.description || project.description;
await project.save();
return project;
},

async del(id) {
const project = await Project.destroy({where: {id}});
return id;
},

async getProjectTasks(id) {
return Task.findAll({where: {project: id}});
}
}

Over in the existing TaskFunctions object, we update the create and update functions to include the project id:

    async create(data) {
const task = Task.create({
name: data.name,
cron_string: data.cron_string,
parent: data.parent || null,
project: data.project || null
});
return task;
},

async update(id, data) {
const task = await Task.findByPk(id);
task.name = data.name || task.name;
task.cron_string = data.cron_string || data.cron_string == ''
? data.cron_string : task.cron_string;
task.completed = [true, false].includes(data.completed) ? data.completed : task.completed;
task.project = data.project || task.project;
await task.save();
return task;
},

Now we can add the actual API routes for dealing with projects:

app.get('/api/project/all', async (req, res) => {
const result = await ProjectFunctions.getAll();
res.send({result});
});

app.get('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.get(req.params.id);
res.send({result});
});

app.get('/api/project/:id/tasks', async (req, res) => {
const result = await ProjectFunctions.getTasks(req.params.id);
res.send({result});
});

app.post('/api/project', async (req, res) => {
const result = await ProjectFunctions.create(req.body);
res.send({result});
});

app.put('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.update(req.params.id, req.body);
res.send({result});
});

app.delete('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.del(+req.params.id);
res.send({result});
});

Listing the Projects

On the frontend, we’ll first create the table which lists the projects. It’ll be similar to, but simpler, than the table created for showing and creating tasks. Because of the similarity, we’ll hurry along a bit in this section. Create the file projects.js :

import {useState, useEffect} from 'react';
import {Link} from 'react-router-dom';

const url = 'http://localhost:3001/api/project';
const headers = {
'Content-Type': 'application/json'
};

const API = {
async getAll() {
const res = await fetch(`${url}/all`, {method: 'GET', headers});
const {result} = await res.json();
return result;
},

async create(body) {
const res = await fetch(url, {method: 'POST', headers, body: JSON.stringify(body)});
const {result} = await res.json();
return result;
},

async update(id, body) {
const res = await fetch(`${url}/${id}`, {method: 'PUT',headers, body:JSON.stringify(body)});
const {result} = await res.json();
return result;
},

async del(id) {
const res = await fetch(`${url}/${id}`, {method: 'DELETE', headers});
const {result} = await res.json();
return result;
}
}

const NewProject = props => {
const [project, setProject] = useState({name: '', description: ''});
const handleChange = e => setProject({...project, [e.target.name]: e.target.value});

const save = async e => {
const result = await API.create(project);
props.handleSave(result);
setProject({name: '', description: ''});
}

return <tr>
<td></td>
<td><input type='text' name='name' value={project.name} onChange={handleChange} /></td>
<td><input type='text' name='description' value={project.description} onChange={handleChange} /></td>
<td><button onClick={save} disabled={!project.name || !project.description}>Save</button></td>
<td></td>
</tr>
}

const ProjectRow = props => {
const [project, setProject] = useState(props.project);
const handleChange = e => setProject({...project, [e.target.name]: e.target.value});

const update = async e => {
const {id, ...rest} = project;
const result = await API.update(id, rest);
props.handleUpdate(id, result);
}

const del = async e => {
if(window.confirm(`Delete project: ${project.name}?`)) {
const result = await API.del(project.id);
props.handleDelete(project.id);
}
}

return <tr>
<td><Link to={`/project/${project.id}`}>{project.id}</Link></td>
<td><input type='text' name='name' value={project.name} onChange={handleChange} /></td>
<td><input type='text' name='description' value={project.description} onChange={handleChange} /></td>
<td><button onClick={update} disabled={!project.name || !project.description}>Update</button></td>
<td><button onClick={del}>Delete</button></td>
</tr>
}

const ProjectTable = props => {
const [projects, setProjects] = useState([]);

const handleSave = project => {
setProjects([...projects, project]);
}

const handleUpdate = (id, project) => {
const oldProjects = [...projects];
const find = oldProjects.find(t => t.id == id);
if (find) {
find.name = project.name;
find.description = project.description;
}
setProjects(oldProjects);
}

const handleDelete = id => {
const oldProjects = [...projects];
const index = oldProjects.findIndex(t => t.id == id);
if (index > -1) {
oldProjects.splice(index, 1);
}
setProjects(oldProjects);
}

useEffect(() => {
async function getProjects() {
const data = await API.getAll();
setProjects(data);
}
getProjects();
}, []);

return <table border='1'>
<thead>
<tr>
<th>View</th>
<th>Name</th>
<th>Description</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{projects.map(p => <ProjectRow key={p.id} project={p} handleUpdate={handleUpdate} handleDelete={handleDelete} />)}
<NewProject handleSave={handleSave} />
</tbody>
</table>
}

export default ProjectTable;

The most interesting difference between this and the tasks is, instead of having that checkbox, it contains a link to the project itself. The page for the individual project shows all the tasks associated with it.

Showing a Project

Over in project.js, we fetch the project info and the tasks associated with it.

import {useState, useEffect} from 'react';
import {useParams} from 'react-router-dom';
import TaskTable from './tasks';

const url = 'http://localhost:3001/api/project';
const headers = {
'Content-Type': 'application/json'
};

const API = {
async getProject(id) {
const res = await fetch(`${url}/${id}`, {method: 'GET', headers});
const {result} = await res.json();
return result;
}
};

const Project = props => {
const [project, setProject] = useState({});
const {id} = useParams();

useEffect(() => {
async function getProject() {
const data = await API.getProject(id);
setProject(data);
}
getProject();
}, []);

return <>
{project.id && <>
<h1>{project.name}</h1>
<div>{project.description}</div>
</>}
<TaskTable project={id} />
</>
}

export default Project;

This component takes the id passed in the URL and fetches the project with that. The project is displayed when it’s fetched. We need to modify TaskTableand NewTask to account for this new usage.

We make a slightly confusing decision here. One would think the Project component should fetch the tasks, then pass them to TaskTable, which either uses them as a prop or as state. But there’s a problem with both approaches:

  • If it’s passed as a prop, then we need the functions to add/update/delete the tasks in the Project component, which doesn’t make sense.
  • Alternatively, we could pass it through props and set it in state in TaskTable. The problem is, when the array of tasks is first passed, it’s empty because the server call to get the tasks hasn’t finished. In order to update the state, we’d need something like:
useEffect(() => {
setState(props.tasks);
}, [props.tasks]);

Which is just plain unsightly. The best solution seems to be TaskTable fetching the tasks. To do this we replace getAll in the API object with this:

async getProjectTasks(id) {
const res = await fetch(`${projectUrl}/${id}/tasks`, {method: 'GET', headers});
const {result} = await res.json();
return result;
},

Then we make 2 changes to TaskTable itself:

  • In the useEffect, replace API.getAll() with API.getProjectTasks(props.project);
  • In the call to NewTask, add project={props.project}

NewTask only has 1 change:

  • In save, API.createTask becomes API.create({…task, project: props.project})

Routing and Navigation

A couple easy changes. Over in index.js, import the two components we’ve created and add their routes with the rest:

import ProjectTable from './projects';
import Project from './project';

...

<Route path='project/:id' element={<Project />} />
<Route path='projects' element={<ProjectTable />} />

Then, we want the project list page to replace the task list page. In nav.js, replace <Link to=’/tasks’>Tasks</Link> with <Link to=’/projects'>Projects</Link>

Putting It Together

If you go to http://localhost:3000/projects, you should now be able to add a couple projects. Here’s an example:

Clicking on the number next to a project name should take you to that project’s tasks:

And clicking on Projects at the top of the page should return you to the projects list.

Here’s what the files touched by this section should look like now. (You may notice the file names in the Gist start with client_ or server_. Those should be client/ and server/, but Gists don’t allow slashes in the names.)

--

--